Compare commits
17 commits
main
...
2-producti
| Author | SHA1 | Date | |
|---|---|---|---|
| a5307f3e5d | |||
| a7036cda3d | |||
| cb1c3fda3d | |||
| 034ccf5fc5 | |||
| a0d0640b13 | |||
| 59d785c248 | |||
| 24b59c5755 | |||
| c66fd753cf | |||
| ebc3c87116 | |||
| f910dbc856 | |||
| 3ad4994856 | |||
| 70440e03d5 | |||
| f12d13f16c | |||
| 938869c53b | |||
| f1b3c1c8ac | |||
| 8c68cdfe9f | |||
| 5764a2fb4e |
127 changed files with 16856 additions and 166 deletions
63
.dockerignore
Normal file
63
.dockerignore
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
# Git
|
||||||
|
.git/
|
||||||
|
.github/
|
||||||
|
.gitignore
|
||||||
|
.gitattributes
|
||||||
|
|
||||||
|
# CI/CD
|
||||||
|
.woodpecker.yml
|
||||||
|
|
||||||
|
# Environment
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
|
||||||
|
# Development
|
||||||
|
.editorconfig
|
||||||
|
.eslintrc*
|
||||||
|
.prettierrc*
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
|
# Documentation
|
||||||
|
*.md
|
||||||
|
LICENSE
|
||||||
|
|
||||||
|
# Docker
|
||||||
|
docker-compose*.yml
|
||||||
|
|
||||||
|
# Claude files
|
||||||
|
.claude/
|
||||||
|
|
||||||
|
# Node modules (will be installed in build)
|
||||||
|
node_modules/
|
||||||
|
frontend/node_modules/
|
||||||
|
|
||||||
|
# Backend vendor (will be installed in build)
|
||||||
|
vendor/
|
||||||
|
backend/vendor/
|
||||||
|
|
||||||
|
# Build artifacts
|
||||||
|
frontend/dist/
|
||||||
|
frontend/build/
|
||||||
|
|
||||||
|
# Cache and logs
|
||||||
|
backend/storage/logs/*
|
||||||
|
backend/storage/framework/cache/*
|
||||||
|
backend/storage/framework/sessions/*
|
||||||
|
backend/storage/framework/views/*
|
||||||
|
backend/bootstrap/cache/*
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# Tests
|
||||||
|
tests/
|
||||||
|
backend/tests/
|
||||||
|
frontend/tests/
|
||||||
|
|
||||||
|
# Data directories
|
||||||
|
docker/data/
|
||||||
6
.env
Normal file
6
.env
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
APP_KEY=base64:dGVzdGluZ19rZXlfZm9yX3Byb2R1Y3Rpb25fdGVzdGluZ19vbmx5X25vdF9zZWN1cmU=
|
||||||
|
DB_PASSWORD=test_password_123
|
||||||
|
MYSQL_ROOT_PASSWORD=root_password_456
|
||||||
|
DB_USERNAME=trip_user
|
||||||
|
DB_DATABASE=trip_planner
|
||||||
|
APP_URL=http://localhost:8080
|
||||||
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -1 +1,5 @@
|
||||||
/.idea
|
/.idea
|
||||||
|
/docker/data
|
||||||
|
/.claude
|
||||||
|
CLAUDE.md
|
||||||
|
/.trees
|
||||||
|
|
|
||||||
100
.woodpecker.yml
Normal file
100
.woodpecker.yml
Normal file
|
|
@ -0,0 +1,100 @@
|
||||||
|
---
|
||||||
|
# Woodpecker CI Pipeline for Trip Planner
|
||||||
|
# Builds and pushes production Docker image to Codeberg Container Registry
|
||||||
|
|
||||||
|
when:
|
||||||
|
- event: push
|
||||||
|
branch: main
|
||||||
|
|
||||||
|
variables:
|
||||||
|
- &image_repo 'codeberg.org/lvl0/trip-planner'
|
||||||
|
|
||||||
|
steps:
|
||||||
|
# Extract version from commit message if merging from release branch
|
||||||
|
- name: extract-version
|
||||||
|
image: alpine:latest
|
||||||
|
commands:
|
||||||
|
- |
|
||||||
|
# Install git and grep with PCRE support
|
||||||
|
apk add --no-cache git grep
|
||||||
|
|
||||||
|
# Get the commit message to check if it's a merge from release branch
|
||||||
|
COMMIT_MSG=$(git log -1 --pretty=%B)
|
||||||
|
|
||||||
|
# Try to extract version from merge commit message (e.g., "Merge branch 'release/v0.1.0'")
|
||||||
|
VERSION=$(echo "$COMMIT_MSG" | grep -oP "release/v?\K[0-9]+\.[0-9]+\.[0-9]+" || echo "")
|
||||||
|
|
||||||
|
if [ -z "$VERSION" ]; then
|
||||||
|
# No version found, use commit SHA as version
|
||||||
|
VERSION="dev-$(git rev-parse --short HEAD)"
|
||||||
|
echo "No release version detected, using: $VERSION"
|
||||||
|
echo "$VERSION" > /woodpecker/version.txt
|
||||||
|
else
|
||||||
|
echo "Detected release version: $VERSION"
|
||||||
|
echo "$VERSION" > /woodpecker/version.txt
|
||||||
|
# Export for use in build step
|
||||||
|
echo "export VERSION_TAG=$VERSION" >> /tmp/version_env.sh
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Build and push with latest tag (always)
|
||||||
|
- name: build-and-push-latest
|
||||||
|
image: woodpeckerci/plugin-docker-buildx
|
||||||
|
settings:
|
||||||
|
repo: *image_repo
|
||||||
|
registry: codeberg.org
|
||||||
|
username:
|
||||||
|
from_secret: container_registry_username
|
||||||
|
password:
|
||||||
|
from_secret: container_registry_token
|
||||||
|
dockerfile: Dockerfile.prod
|
||||||
|
context: .
|
||||||
|
platforms: linux/amd64
|
||||||
|
build_args:
|
||||||
|
- BUILDKIT_INLINE_CACHE=1
|
||||||
|
auto_tag: false
|
||||||
|
tags:
|
||||||
|
- latest
|
||||||
|
depends_on:
|
||||||
|
- extract-version
|
||||||
|
|
||||||
|
# Check if this is a release build
|
||||||
|
- name: check-release
|
||||||
|
image: alpine:latest
|
||||||
|
commands:
|
||||||
|
- |
|
||||||
|
VERSION=$(cat /woodpecker/version.txt)
|
||||||
|
if echo "$VERSION" | grep -qv "^dev-"; then
|
||||||
|
echo "Release version detected: $VERSION"
|
||||||
|
echo "true" > /woodpecker/is_release.txt
|
||||||
|
else
|
||||||
|
echo "Development build ($VERSION), will skip version tag"
|
||||||
|
echo "false" > /woodpecker/is_release.txt
|
||||||
|
fi
|
||||||
|
depends_on:
|
||||||
|
- build-and-push-latest
|
||||||
|
|
||||||
|
# Notify build status
|
||||||
|
- name: notify-success
|
||||||
|
image: alpine:latest
|
||||||
|
commands:
|
||||||
|
- |
|
||||||
|
VERSION=$(cat /woodpecker/version.txt 2>/dev/null || echo "unknown")
|
||||||
|
IS_RELEASE=$(cat /woodpecker/is_release.txt 2>/dev/null || echo "false")
|
||||||
|
echo "✅ Successfully built and pushed trip-planner:latest"
|
||||||
|
echo "Version: $VERSION"
|
||||||
|
if [ "$IS_RELEASE" = "true" ]; then
|
||||||
|
echo "ℹ️ This is a release build. To tag with version $VERSION:"
|
||||||
|
echo " docker pull codeberg.org/lvl0/trip-planner:latest"
|
||||||
|
echo " docker tag codeberg.org/lvl0/trip-planner:latest codeberg.org/lvl0/trip-planner:$VERSION"
|
||||||
|
echo " docker push codeberg.org/lvl0/trip-planner:$VERSION"
|
||||||
|
fi
|
||||||
|
echo "Image: codeberg.org/lvl0/trip-planner:latest"
|
||||||
|
when:
|
||||||
|
status: success
|
||||||
|
|
||||||
|
- name: notify-failure
|
||||||
|
image: alpine:latest
|
||||||
|
commands:
|
||||||
|
- echo "❌ Build failed! Check the logs above for details."
|
||||||
|
when:
|
||||||
|
status: failure
|
||||||
134
Dockerfile.prod
Normal file
134
Dockerfile.prod
Normal file
|
|
@ -0,0 +1,134 @@
|
||||||
|
# =============================================================================
|
||||||
|
# Stage 1: Build Frontend
|
||||||
|
# =============================================================================
|
||||||
|
FROM node:20-alpine AS frontend-builder
|
||||||
|
|
||||||
|
WORKDIR /app/frontend
|
||||||
|
|
||||||
|
# Copy frontend package files
|
||||||
|
COPY frontend/package*.json ./
|
||||||
|
|
||||||
|
# Install dependencies (including dev for build)
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
# Copy frontend source
|
||||||
|
COPY frontend/ ./
|
||||||
|
|
||||||
|
# Build frontend
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Stage 2: Build Backend Dependencies
|
||||||
|
# =============================================================================
|
||||||
|
FROM php:8.3-fpm-alpine AS backend-builder
|
||||||
|
|
||||||
|
# Install build dependencies
|
||||||
|
RUN apk add --no-cache \
|
||||||
|
libpng-dev \
|
||||||
|
oniguruma-dev \
|
||||||
|
libxml2-dev \
|
||||||
|
zip \
|
||||||
|
unzip
|
||||||
|
|
||||||
|
# Install PHP extensions
|
||||||
|
RUN docker-php-ext-install pdo_mysql mbstring exif pcntl bcmath gd opcache
|
||||||
|
|
||||||
|
# Install Composer
|
||||||
|
COPY --from=composer:latest /usr/bin/composer /usr/bin/composer
|
||||||
|
|
||||||
|
WORKDIR /var/www/html
|
||||||
|
|
||||||
|
# Copy composer files
|
||||||
|
COPY backend/composer.json backend/composer.lock ./
|
||||||
|
|
||||||
|
# Install PHP dependencies (production only)
|
||||||
|
RUN composer install --no-dev --no-scripts --no-autoloader --prefer-dist
|
||||||
|
|
||||||
|
# Copy backend source
|
||||||
|
COPY backend/ ./
|
||||||
|
|
||||||
|
# Generate optimized autoloader
|
||||||
|
RUN composer dump-autoload --optimize --classmap-authoritative
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Stage 3: Final Production Image (All-in-One)
|
||||||
|
# =============================================================================
|
||||||
|
FROM php:8.3-fpm-alpine
|
||||||
|
|
||||||
|
# Install runtime dependencies
|
||||||
|
RUN apk add --no-cache \
|
||||||
|
libpng \
|
||||||
|
oniguruma \
|
||||||
|
libxml2 \
|
||||||
|
nginx \
|
||||||
|
supervisor \
|
||||||
|
mariadb \
|
||||||
|
mariadb-client \
|
||||||
|
redis \
|
||||||
|
curl \
|
||||||
|
bash
|
||||||
|
|
||||||
|
# Copy PHP extensions from backend-builder
|
||||||
|
COPY --from=backend-builder /usr/local/lib/php/extensions/ /usr/local/lib/php/extensions/
|
||||||
|
COPY --from=backend-builder /usr/local/etc/php/conf.d/docker-php-ext-*.ini /usr/local/etc/php/conf.d/
|
||||||
|
|
||||||
|
# Configure PHP for production
|
||||||
|
RUN echo "opcache.enable=1" >> /usr/local/etc/php/conf.d/opcache.ini && \
|
||||||
|
echo "opcache.memory_consumption=128" >> /usr/local/etc/php/conf.d/opcache.ini && \
|
||||||
|
echo "opcache.max_accelerated_files=10000" >> /usr/local/etc/php/conf.d/opcache.ini && \
|
||||||
|
echo "opcache.validate_timestamps=0" >> /usr/local/etc/php/conf.d/opcache.ini && \
|
||||||
|
echo "expose_php=0" >> /usr/local/etc/php/conf.d/security.ini && \
|
||||||
|
echo "display_errors=0" >> /usr/local/etc/php/conf.d/security.ini && \
|
||||||
|
echo "log_errors=1" >> /usr/local/etc/php/conf.d/security.ini
|
||||||
|
|
||||||
|
# Create application user
|
||||||
|
RUN addgroup -g 1000 appuser && \
|
||||||
|
adduser -D -u 1000 -G appuser appuser && \
|
||||||
|
mkdir -p /var/www/html /usr/share/nginx/html && \
|
||||||
|
chown -R appuser:appuser /var/www/html /usr/share/nginx/html
|
||||||
|
|
||||||
|
# Create necessary directories for services
|
||||||
|
RUN mkdir -p /run/nginx /run/mysqld /var/lib/mysql /var/log/supervisor /data/redis && \
|
||||||
|
chown -R appuser:appuser /run/nginx /usr/share/nginx/html && \
|
||||||
|
chown -R mysql:mysql /run/mysqld /var/lib/mysql && \
|
||||||
|
chown -R redis:redis /data/redis
|
||||||
|
|
||||||
|
# Copy backend from builder
|
||||||
|
WORKDIR /var/www/html
|
||||||
|
COPY --from=backend-builder --chown=appuser:appuser /var/www/html ./
|
||||||
|
|
||||||
|
# Create Laravel required directories
|
||||||
|
RUN mkdir -p storage/framework/{sessions,views,cache} \
|
||||||
|
storage/logs \
|
||||||
|
bootstrap/cache && \
|
||||||
|
chown -R appuser:appuser storage bootstrap/cache && \
|
||||||
|
chmod -R 775 storage bootstrap/cache
|
||||||
|
|
||||||
|
# Copy frontend built assets
|
||||||
|
COPY --from=frontend-builder --chown=appuser:appuser /app/frontend/dist /usr/share/nginx/html
|
||||||
|
|
||||||
|
# Copy nginx configurations
|
||||||
|
COPY docker/nginx/production.conf /etc/nginx/http.d/default.conf
|
||||||
|
|
||||||
|
# Copy supervisor configuration
|
||||||
|
COPY docker/supervisord.conf /etc/supervisor/conf.d/supervisord.conf
|
||||||
|
|
||||||
|
# Initialize MariaDB data directory
|
||||||
|
RUN mysql_install_db --user=mysql --datadir=/var/lib/mysql
|
||||||
|
|
||||||
|
# Copy entrypoint script
|
||||||
|
COPY docker/entrypoint.sh /usr/local/bin/entrypoint.sh
|
||||||
|
RUN chmod +x /usr/local/bin/entrypoint.sh
|
||||||
|
|
||||||
|
# Health check
|
||||||
|
HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
|
||||||
|
CMD curl -f http://localhost/up || exit 1
|
||||||
|
|
||||||
|
# Expose HTTP port
|
||||||
|
EXPOSE 80
|
||||||
|
|
||||||
|
# Set entrypoint
|
||||||
|
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
|
||||||
|
|
||||||
|
# Start supervisor
|
||||||
|
CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/conf.d/supervisord.conf"]
|
||||||
48
backend/.dockerignore
Normal file
48
backend/.dockerignore
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
# Dependencies
|
||||||
|
node_modules/
|
||||||
|
vendor/
|
||||||
|
|
||||||
|
# Environment files
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
|
||||||
|
# Testing
|
||||||
|
tests/
|
||||||
|
.phpunit.result.cache
|
||||||
|
phpunit.xml
|
||||||
|
|
||||||
|
# Development files
|
||||||
|
.git/
|
||||||
|
.github/
|
||||||
|
.gitignore
|
||||||
|
.gitattributes
|
||||||
|
.editorconfig
|
||||||
|
.php-cs-fixer.php
|
||||||
|
.php-cs-fixer.cache
|
||||||
|
phpstan.neon
|
||||||
|
phpstan-baseline.neon
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
|
# Storage and cache (will be generated in container)
|
||||||
|
storage/framework/cache/*
|
||||||
|
storage/framework/sessions/*
|
||||||
|
storage/framework/views/*
|
||||||
|
storage/logs/*
|
||||||
|
bootstrap/cache/*
|
||||||
|
|
||||||
|
# Documentation
|
||||||
|
README.md
|
||||||
|
CHANGELOG.md
|
||||||
|
LICENSE
|
||||||
|
|
||||||
|
# Docker files
|
||||||
|
Dockerfile*
|
||||||
|
docker-compose*.yml
|
||||||
|
.dockerignore
|
||||||
1
backend/.gitignore
vendored
1
backend/.gitignore
vendored
|
|
@ -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
|
||||||
|
|
|
||||||
45
backend/app/Domain/Trip/Observers/TripObserver.php
Normal file
45
backend/app/Domain/Trip/Observers/TripObserver.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
20
backend/app/Domain/Trip/Providers/TripServiceProvider.php
Normal file
20
backend/app/Domain/Trip/Providers/TripServiceProvider.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
72
backend/app/Domain/Trip/Services/CalendarSlotService.php
Normal file
72
backend/app/Domain/Trip/Services/CalendarSlotService.php
Normal file
|
|
@ -0,0 +1,72 @@
|
||||||
|
<?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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,8 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Http\Controllers;
|
|
||||||
|
|
||||||
abstract class Controller
|
|
||||||
{
|
|
||||||
//
|
|
||||||
}
|
|
||||||
|
|
@ -0,0 +1,61 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Infrastructure\Http\Controllers\API\CalendarSlot;
|
||||||
|
|
||||||
|
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 index(Trip $trip): JsonResponse
|
||||||
|
{
|
||||||
|
// Check if user owns the trip
|
||||||
|
if ($trip->created_by_user_id !== auth()->id()) {
|
||||||
|
return response()->json(['message' => 'Forbidden'], 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$calendarSlots = $trip->calendarSlots()
|
||||||
|
->with(['plannableItems'])
|
||||||
|
->orderBy('slot_order')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
return response()->json(['data' => $calendarSlots]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(Request $request, CalendarSlot $calendarSlot): JsonResponse
|
||||||
|
{
|
||||||
|
// Check if user owns the trip
|
||||||
|
if ($calendarSlot->trip->created_by_user_id !== auth()->id()) {
|
||||||
|
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
|
||||||
|
{
|
||||||
|
$validated = $request->validate([
|
||||||
|
'items' => 'required|array',
|
||||||
|
'items.*.plannable_item_id' => 'required|exists:plannable_items,id',
|
||||||
|
'items.*.sort_order' => 'required|integer',
|
||||||
|
]);
|
||||||
|
|
||||||
|
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']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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"
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,79 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Infrastructure\Http\Controllers\API\PlannableItem;
|
||||||
|
|
||||||
|
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 index(Trip $trip): JsonResponse
|
||||||
|
{
|
||||||
|
// Check if user owns the trip
|
||||||
|
if ($trip->created_by_user_id !== auth()->id()) {
|
||||||
|
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
|
||||||
|
{
|
||||||
|
// Check if user owns the trip
|
||||||
|
if ($trip->created_by_user_id !== auth()->id()) {
|
||||||
|
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
|
||||||
|
{
|
||||||
|
$plannableItem->load(['calendarSlots', 'trip']);
|
||||||
|
return response()->json($plannableItem);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(Request $request, PlannableItem $plannableItem): JsonResponse
|
||||||
|
{
|
||||||
|
// Check if user owns the trip
|
||||||
|
if ($plannableItem->trip->created_by_user_id !== auth()->id()) {
|
||||||
|
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
|
||||||
|
{
|
||||||
|
$plannableItem->delete();
|
||||||
|
|
||||||
|
return response()->json(null, 204);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,53 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Infrastructure\Http\Controllers\API\PlannedItem;
|
||||||
|
|
||||||
|
use App\Infrastructure\Http\Controllers\Controller;
|
||||||
|
use App\Models\PlannedItem;
|
||||||
|
use App\Models\PlannableItem;
|
||||||
|
use App\Models\CalendarSlot;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
|
||||||
|
class PlannedItemController extends Controller
|
||||||
|
{
|
||||||
|
public function store(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$validated = $request->validate([
|
||||||
|
'plannable_item_id' => 'required|exists:plannable_items,id',
|
||||||
|
'calendar_slot_id' => 'required|exists:calendar_slots,id',
|
||||||
|
'sort_order' => 'nullable|integer',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$plannedItem = PlannedItem::updateOrCreate(
|
||||||
|
[
|
||||||
|
'plannable_item_id' => $validated['plannable_item_id'],
|
||||||
|
'calendar_slot_id' => $validated['calendar_slot_id'],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'sort_order' => $validated['sort_order'] ?? 0,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
return response()->json($plannedItem->load(['plannableItem', 'calendarSlot']), 201);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(Request $request, PlannedItem $plannedItem): JsonResponse
|
||||||
|
{
|
||||||
|
$validated = $request->validate([
|
||||||
|
'calendar_slot_id' => 'sometimes|required|exists:calendar_slots,id',
|
||||||
|
'sort_order' => 'nullable|integer',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$plannedItem->update($validated);
|
||||||
|
|
||||||
|
return response()->json($plannedItem->load(['plannableItem', 'calendarSlot']));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function destroy(PlannedItem $plannedItem): JsonResponse
|
||||||
|
{
|
||||||
|
$plannedItem->delete();
|
||||||
|
|
||||||
|
return response()->json(null, 204);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,90 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Infrastructure\Http\Controllers\API\Trip;
|
||||||
|
|
||||||
|
use App\Infrastructure\Http\Controllers\Controller;
|
||||||
|
use App\Models\Trip;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Validation\ValidationException;
|
||||||
|
|
||||||
|
class TripController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* 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, string $id): JsonResponse
|
||||||
|
{
|
||||||
|
$trip = Trip::where('id', $id)
|
||||||
|
->where('created_by_user_id', $request->user()->id)
|
||||||
|
->firstOrFail();
|
||||||
|
|
||||||
|
return response()->json(['data' => $trip]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the specified resource in storage.
|
||||||
|
*/
|
||||||
|
public function update(Request $request, string $id): JsonResponse
|
||||||
|
{
|
||||||
|
$trip = Trip::where('id', $id)
|
||||||
|
->where('created_by_user_id', $request->user()->id)
|
||||||
|
->firstOrFail();
|
||||||
|
|
||||||
|
$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, string $id): JsonResponse
|
||||||
|
{
|
||||||
|
$trip = Trip::where('id', $id)
|
||||||
|
->where('created_by_user_id', $request->user()->id)
|
||||||
|
->firstOrFail();
|
||||||
|
|
||||||
|
$trip->delete();
|
||||||
|
|
||||||
|
return response()->json(['message' => 'Trip deleted successfully']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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'
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Infrastructure\Http\Controllers;
|
||||||
|
|
||||||
|
abstract class Controller
|
||||||
|
{
|
||||||
|
//
|
||||||
|
}
|
||||||
39
backend/app/Infrastructure/Http/Middleware/Cors.php
Normal file
39
backend/app/Infrastructure/Http/Middleware/Cors.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
46
backend/app/Models/CalendarSlot.php
Normal file
46
backend/app/Models/CalendarSlot.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
44
backend/app/Models/PlannableItem.php
Normal file
44
backend/app/Models/PlannableItem.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
28
backend/app/Models/PlannedItem.php
Normal file
28
backend/app/Models/PlannedItem.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
40
backend/app/Models/Trip.php
Normal file
40
backend/app/Models/Trip.php
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
//
|
//
|
||||||
|
|
|
||||||
|
|
@ -2,4 +2,5 @@
|
||||||
|
|
||||||
return [
|
return [
|
||||||
App\Providers\AppServiceProvider::class,
|
App\Providers\AppServiceProvider::class,
|
||||||
|
App\Domain\Trip\Providers\TripServiceProvider::class,
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -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
349
backend/composer.lock
generated
|
|
@ -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
34
backend/config/cors.php
Normal 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,
|
||||||
|
|
||||||
|
];
|
||||||
84
backend/config/sanctum.php
Normal file
84
backend/config/sanctum.php
Normal 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,
|
||||||
|
],
|
||||||
|
|
||||||
|
];
|
||||||
62
backend/database/factories/PlannableItemFactory.php
Normal file
62
backend/database/factories/PlannableItemFactory.php
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Database\Factories;
|
||||||
|
|
||||||
|
use App\Models\PlannableItem;
|
||||||
|
use App\Models\Trip;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||||
|
|
||||||
|
class PlannableItemFactory extends Factory
|
||||||
|
{
|
||||||
|
protected $model = PlannableItem::class;
|
||||||
|
|
||||||
|
public function definition()
|
||||||
|
{
|
||||||
|
$types = ['hotel', 'restaurant', 'attraction', 'transport', 'activity'];
|
||||||
|
$type = $this->faker->randomElement($types);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'trip_id' => Trip::factory(),
|
||||||
|
'name' => $this->faker->company(),
|
||||||
|
'type' => $type,
|
||||||
|
'address' => $this->faker->address(),
|
||||||
|
'notes' => $this->faker->sentence(),
|
||||||
|
'metadata' => $this->getMetadataForType($type),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getMetadataForType($type)
|
||||||
|
{
|
||||||
|
switch ($type) {
|
||||||
|
case 'hotel':
|
||||||
|
return [
|
||||||
|
'checkin_time' => '15:00',
|
||||||
|
'checkout_time' => '11:00',
|
||||||
|
'confirmation_number' => $this->faker->uuid()
|
||||||
|
];
|
||||||
|
case 'restaurant':
|
||||||
|
return [
|
||||||
|
'reservation_time' => '19:00',
|
||||||
|
'party_size' => $this->faker->numberBetween(2, 6)
|
||||||
|
];
|
||||||
|
case 'transport':
|
||||||
|
return [
|
||||||
|
'departure_time' => '10:00',
|
||||||
|
'arrival_time' => '14:00',
|
||||||
|
'transport_type' => $this->faker->randomElement(['flight', 'train', 'bus'])
|
||||||
|
];
|
||||||
|
case 'attraction':
|
||||||
|
return [
|
||||||
|
'opening_hours' => '9:00 AM - 5:00 PM',
|
||||||
|
'ticket_price' => '$' . $this->faker->numberBetween(10, 50)
|
||||||
|
];
|
||||||
|
case 'activity':
|
||||||
|
return [
|
||||||
|
'duration' => $this->faker->numberBetween(1, 4) . ' hours',
|
||||||
|
'meeting_point' => $this->faker->streetAddress()
|
||||||
|
];
|
||||||
|
default:
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
72
backend/database/factories/TripFactory.php
Normal file
72
backend/database/factories/TripFactory.php
Normal 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'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -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');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -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');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,36 @@
|
||||||
|
<?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();
|
||||||
|
|
||||||
|
$table->index(['trip_id', 'slot_date']);
|
||||||
|
$table->index(['trip_id', 'slot_order']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('calendar_slots');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -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');
|
||||||
|
}
|
||||||
|
};
|
||||||
36
backend/database/seeders/TestUserSeeder.php
Normal file
36
backend/database/seeders/TestUserSeeder.php
Normal 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
24
backend/phpstan.neon
Normal 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
|
||||||
|
|
@ -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
54
backend/routes/api.php
Normal 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']);
|
||||||
|
});
|
||||||
457
backend/tests/Feature/AuthTest.php
Normal file
457
backend/tests/Feature/AuthTest.php
Normal 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']);
|
||||||
|
}
|
||||||
|
}
|
||||||
230
backend/tests/Feature/CalendarSlotTest.php
Normal file
230
backend/tests/Feature/CalendarSlotTest.php
Normal file
|
|
@ -0,0 +1,230 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature;
|
||||||
|
|
||||||
|
use Tests\TestCase;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\Trip;
|
||||||
|
use App\Models\CalendarSlot;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
|
||||||
|
class CalendarSlotTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
private $user;
|
||||||
|
private $token;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
|
||||||
|
$this->user = User::factory()->create();
|
||||||
|
$this->token = $this->user->createToken('test-token')->plainTextToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_calendar_slots_are_auto_created_when_trip_is_created()
|
||||||
|
{
|
||||||
|
$tripData = [
|
||||||
|
'name' => 'Test Trip',
|
||||||
|
'description' => 'Test Description',
|
||||||
|
'start_date' => '2024-01-01',
|
||||||
|
'end_date' => '2024-01-03'
|
||||||
|
];
|
||||||
|
|
||||||
|
$response = $this->withHeaders([
|
||||||
|
'Authorization' => 'Bearer ' . $this->token,
|
||||||
|
'Accept' => 'application/json'
|
||||||
|
])->postJson('/api/trips', $tripData);
|
||||||
|
|
||||||
|
$response->assertStatus(201);
|
||||||
|
$tripId = $response->json('data.id');
|
||||||
|
|
||||||
|
// Check that 3 calendar slots were created 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
188
backend/tests/Feature/PlannableItemTest.php
Normal file
188
backend/tests/Feature/PlannableItemTest.php
Normal file
|
|
@ -0,0 +1,188 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature;
|
||||||
|
|
||||||
|
use Tests\TestCase;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\Trip;
|
||||||
|
use App\Models\PlannableItem;
|
||||||
|
use App\Models\CalendarSlot;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
|
||||||
|
class PlannableItemTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
private $user;
|
||||||
|
private $trip;
|
||||||
|
private $token;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
|
||||||
|
$this->user = User::factory()->create();
|
||||||
|
$this->trip = Trip::factory()->create([
|
||||||
|
'created_by_user_id' => $this->user->id,
|
||||||
|
'start_date' => '2024-01-01',
|
||||||
|
'end_date' => '2024-01-03'
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->token = $this->user->createToken('test-token')->plainTextToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_can_create_plannable_item()
|
||||||
|
{
|
||||||
|
$data = [
|
||||||
|
'name' => 'Eiffel Tower',
|
||||||
|
'type' => 'attraction',
|
||||||
|
'address' => 'Champ de Mars, Paris',
|
||||||
|
'notes' => 'Visit in the morning',
|
||||||
|
'metadata' => [
|
||||||
|
'opening_hours' => '9:00 AM - 11:00 PM',
|
||||||
|
'ticket_price' => '25 EUR'
|
||||||
|
]
|
||||||
|
];
|
||||||
|
|
||||||
|
$response = $this->withHeaders([
|
||||||
|
'Authorization' => 'Bearer ' . $this->token,
|
||||||
|
'Accept' => 'application/json'
|
||||||
|
])->postJson("/api/trips/{$this->trip->id}/plannables", $data);
|
||||||
|
|
||||||
|
$response->assertStatus(201)
|
||||||
|
->assertJsonPath('data.name', 'Eiffel Tower')
|
||||||
|
->assertJsonPath('data.type', 'attraction')
|
||||||
|
->assertJsonPath('data.address', 'Champ de Mars, Paris')
|
||||||
|
->assertJsonPath('data.metadata.opening_hours', '9:00 AM - 11:00 PM');
|
||||||
|
|
||||||
|
$this->assertDatabaseHas('plannable_items', [
|
||||||
|
'trip_id' => $this->trip->id,
|
||||||
|
'name' => 'Eiffel Tower',
|
||||||
|
'type' => 'attraction'
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_can_list_plannable_items_for_trip()
|
||||||
|
{
|
||||||
|
PlannableItem::factory()->count(3)->create([
|
||||||
|
'trip_id' => $this->trip->id
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = $this->withHeaders([
|
||||||
|
'Authorization' => 'Bearer ' . $this->token,
|
||||||
|
'Accept' => 'application/json'
|
||||||
|
])->getJson("/api/trips/{$this->trip->id}/plannables");
|
||||||
|
|
||||||
|
$response->assertStatus(200)
|
||||||
|
->assertJsonCount(3, 'data');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_can_update_plannable_item()
|
||||||
|
{
|
||||||
|
$item = PlannableItem::factory()->create([
|
||||||
|
'trip_id' => $this->trip->id,
|
||||||
|
'name' => 'Old Name'
|
||||||
|
]);
|
||||||
|
|
||||||
|
$updateData = [
|
||||||
|
'name' => 'Updated Name',
|
||||||
|
'type' => 'restaurant',
|
||||||
|
'notes' => 'Updated notes'
|
||||||
|
];
|
||||||
|
|
||||||
|
$response = $this->withHeaders([
|
||||||
|
'Authorization' => 'Bearer ' . $this->token,
|
||||||
|
'Accept' => 'application/json'
|
||||||
|
])->putJson("/api/plannables/{$item->id}", $updateData);
|
||||||
|
|
||||||
|
$response->assertStatus(200)
|
||||||
|
->assertJsonPath('data.name', 'Updated Name')
|
||||||
|
->assertJsonPath('data.type', 'restaurant');
|
||||||
|
|
||||||
|
$this->assertDatabaseHas('plannable_items', [
|
||||||
|
'id' => $item->id,
|
||||||
|
'name' => 'Updated Name',
|
||||||
|
'type' => 'restaurant'
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_can_delete_plannable_item()
|
||||||
|
{
|
||||||
|
$item = PlannableItem::factory()->create([
|
||||||
|
'trip_id' => $this->trip->id
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = $this->withHeaders([
|
||||||
|
'Authorization' => 'Bearer ' . $this->token,
|
||||||
|
'Accept' => 'application/json'
|
||||||
|
])->deleteJson("/api/plannables/{$item->id}");
|
||||||
|
|
||||||
|
$response->assertStatus(204);
|
||||||
|
|
||||||
|
$this->assertDatabaseMissing('plannable_items', [
|
||||||
|
'id' => $item->id
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_cannot_access_plannable_items_of_other_users_trip()
|
||||||
|
{
|
||||||
|
$otherUser = User::factory()->create();
|
||||||
|
$otherTrip = Trip::factory()->create([
|
||||||
|
'created_by_user_id' => $otherUser->id
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = $this->withHeaders([
|
||||||
|
'Authorization' => 'Bearer ' . $this->token,
|
||||||
|
'Accept' => 'application/json'
|
||||||
|
])->getJson("/api/trips/{$otherTrip->id}/plannables");
|
||||||
|
|
||||||
|
$response->assertStatus(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_cannot_update_plannable_item_of_other_users_trip()
|
||||||
|
{
|
||||||
|
$otherUser = User::factory()->create();
|
||||||
|
$otherTrip = Trip::factory()->create([
|
||||||
|
'created_by_user_id' => $otherUser->id
|
||||||
|
]);
|
||||||
|
$item = PlannableItem::factory()->create([
|
||||||
|
'trip_id' => $otherTrip->id
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = $this->withHeaders([
|
||||||
|
'Authorization' => 'Bearer ' . $this->token,
|
||||||
|
'Accept' => 'application/json'
|
||||||
|
])->putJson("/api/plannables/{$item->id}", [
|
||||||
|
'name' => 'Hacked Name'
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response->assertStatus(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_validates_required_fields_when_creating_plannable_item()
|
||||||
|
{
|
||||||
|
$response = $this->withHeaders([
|
||||||
|
'Authorization' => 'Bearer ' . $this->token,
|
||||||
|
'Accept' => 'application/json'
|
||||||
|
])->postJson("/api/trips/{$this->trip->id}/plannables", []);
|
||||||
|
|
||||||
|
$response->assertStatus(422)
|
||||||
|
->assertJsonValidationErrors(['name', 'type']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_validates_type_enum_values()
|
||||||
|
{
|
||||||
|
$data = [
|
||||||
|
'name' => 'Test Item',
|
||||||
|
'type' => 'invalid_type'
|
||||||
|
];
|
||||||
|
|
||||||
|
$response = $this->withHeaders([
|
||||||
|
'Authorization' => 'Bearer ' . $this->token,
|
||||||
|
'Accept' => 'application/json'
|
||||||
|
])->postJson("/api/trips/{$this->trip->id}/plannables", $data);
|
||||||
|
|
||||||
|
$response->assertStatus(422)
|
||||||
|
->assertJsonValidationErrors(['type']);
|
||||||
|
}
|
||||||
|
}
|
||||||
267
backend/tests/Feature/TestSetupControllerTest.php
Normal file
267
backend/tests/Feature/TestSetupControllerTest.php
Normal 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']);
|
||||||
|
}
|
||||||
|
}
|
||||||
420
backend/tests/Feature/TripTest.php
Normal file
420
backend/tests/Feature/TripTest.php
Normal 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']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
248
backend/tests/Unit/TripTest.php
Normal file
248
backend/tests/Unit/TripTest.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
224
backend/tests/Unit/UserTest.php
Normal file
224
backend/tests/Unit/UserTest.php
Normal 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
51
bin/phpstan
Executable 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
55
bin/phpunit
Executable 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
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -1,58 +1,66 @@
|
||||||
version: '3.8'
|
version: '3.8'
|
||||||
|
|
||||||
services:
|
services:
|
||||||
frontend:
|
app:
|
||||||
build:
|
build:
|
||||||
context: ./frontend
|
context: .
|
||||||
dockerfile: ../docker/frontend/Dockerfile.prod
|
dockerfile: Dockerfile.prod
|
||||||
container_name: trip-planner-frontend
|
container_name: trip-planner-production
|
||||||
ports:
|
ports:
|
||||||
- "${FRONTEND_PORT:-80}:80"
|
- "${APP_PORT:-8080}:80"
|
||||||
restart: unless-stopped
|
|
||||||
networks:
|
|
||||||
- trip-planner-network
|
|
||||||
|
|
||||||
backend:
|
|
||||||
build:
|
|
||||||
context: ./backend
|
|
||||||
dockerfile: ../docker/backend/Dockerfile.prod
|
|
||||||
container_name: trip-planner-backend
|
|
||||||
ports:
|
|
||||||
- "${BACKEND_PORT:-8080}:80"
|
|
||||||
environment:
|
environment:
|
||||||
|
# Laravel Application
|
||||||
|
APP_NAME: "Trip Planner"
|
||||||
APP_ENV: production
|
APP_ENV: production
|
||||||
APP_DEBUG: false
|
APP_DEBUG: false
|
||||||
APP_URL: ${APP_URL}
|
APP_KEY: ${APP_KEY}
|
||||||
|
APP_URL: ${APP_URL:-http://localhost:8080}
|
||||||
|
|
||||||
|
# Database (internal MariaDB)
|
||||||
DB_CONNECTION: mysql
|
DB_CONNECTION: mysql
|
||||||
DB_HOST: ${DB_HOST}
|
DB_HOST: 127.0.0.1
|
||||||
DB_PORT: ${DB_PORT:-3306}
|
DB_PORT: 3306
|
||||||
DB_DATABASE: ${DB_DATABASE}
|
DB_DATABASE: ${DB_DATABASE:-trip_planner}
|
||||||
DB_USERNAME: ${DB_USERNAME}
|
DB_USERNAME: ${DB_USERNAME:-trip_user}
|
||||||
DB_PASSWORD: ${DB_PASSWORD}
|
DB_PASSWORD: ${DB_PASSWORD:?DB_PASSWORD must be set}
|
||||||
REDIS_HOST: redis
|
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:?MYSQL_ROOT_PASSWORD must be set}
|
||||||
|
|
||||||
|
# Redis (internal)
|
||||||
|
REDIS_HOST: 127.0.0.1
|
||||||
REDIS_PORT: 6379
|
REDIS_PORT: 6379
|
||||||
|
REDIS_PASSWORD: null
|
||||||
|
|
||||||
|
# Cache & Session
|
||||||
CACHE_DRIVER: redis
|
CACHE_DRIVER: redis
|
||||||
QUEUE_CONNECTION: redis
|
QUEUE_CONNECTION: redis
|
||||||
SESSION_DRIVER: redis
|
SESSION_DRIVER: redis
|
||||||
depends_on:
|
SESSION_LIFETIME: 120
|
||||||
- redis
|
|
||||||
restart: unless-stopped
|
|
||||||
networks:
|
|
||||||
- trip-planner-network
|
|
||||||
|
|
||||||
redis:
|
# Mail (configure as needed)
|
||||||
image: docker.io/library/redis:alpine
|
MAIL_MAILER: ${MAIL_MAILER:-log}
|
||||||
container_name: trip-planner-redis
|
MAIL_HOST: ${MAIL_HOST:-}
|
||||||
|
MAIL_PORT: ${MAIL_PORT:-}
|
||||||
|
MAIL_USERNAME: ${MAIL_USERNAME:-}
|
||||||
|
MAIL_PASSWORD: ${MAIL_PASSWORD:-}
|
||||||
|
MAIL_ENCRYPTION: ${MAIL_ENCRYPTION:-}
|
||||||
|
MAIL_FROM_ADDRESS: ${MAIL_FROM_ADDRESS:-noreply@tripplanner.local}
|
||||||
|
MAIL_FROM_NAME: "${MAIL_FROM_NAME:-Trip Planner}"
|
||||||
volumes:
|
volumes:
|
||||||
- redis-data:/data
|
# Persistent data for database
|
||||||
command: redis-server --appendonly yes
|
- db-data:/var/lib/mysql
|
||||||
|
# Persistent data for redis
|
||||||
|
- redis-data:/data/redis
|
||||||
|
# Persistent storage for uploaded files
|
||||||
|
- storage-data:/var/www/html/storage/app
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
networks:
|
healthcheck:
|
||||||
- trip-planner-network
|
test: ["CMD", "curl", "-f", "http://localhost/up"]
|
||||||
|
interval: 30s
|
||||||
networks:
|
timeout: 10s
|
||||||
trip-planner-network:
|
retries: 3
|
||||||
driver: bridge
|
start_period: 60s
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
|
db-data:
|
||||||
redis-data:
|
redis-data:
|
||||||
|
storage-data:
|
||||||
160
docker/README.md
Normal file
160
docker/README.md
Normal file
|
|
@ -0,0 +1,160 @@
|
||||||
|
# Trip Planner - Docker Setup
|
||||||
|
|
||||||
|
This directory contains Docker configurations for both development and production environments.
|
||||||
|
|
||||||
|
## Directory Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
docker/
|
||||||
|
├── README.md # This file
|
||||||
|
├── dev/ # Development environment documentation
|
||||||
|
│ └── README.md
|
||||||
|
├── prod/ # Production environment documentation
|
||||||
|
│ └── README.md
|
||||||
|
├── backend/ # Backend service configurations
|
||||||
|
│ ├── Dockerfile.dev
|
||||||
|
│ ├── Dockerfile.prod
|
||||||
|
│ └── supervisord.conf
|
||||||
|
├── frontend/ # Frontend service configurations
|
||||||
|
│ ├── Dockerfile.dev
|
||||||
|
│ └── Dockerfile.prod
|
||||||
|
├── nginx/ # Nginx configurations
|
||||||
|
│ ├── backend.conf # Backend API nginx config
|
||||||
|
│ ├── frontend.conf # Frontend SPA nginx config
|
||||||
|
│ └── production.conf # All-in-one production nginx config
|
||||||
|
├── supervisord.conf # Supervisord config for production
|
||||||
|
└── entrypoint.sh # Production container entrypoint script
|
||||||
|
```
|
||||||
|
|
||||||
|
## Environments
|
||||||
|
|
||||||
|
### Development Environment
|
||||||
|
|
||||||
|
**Architecture**: Multi-container setup with separate services
|
||||||
|
- Frontend (React + Vite dev server)
|
||||||
|
- Backend (Laravel + PHP-FPM)
|
||||||
|
- Database (MariaDB)
|
||||||
|
- Redis
|
||||||
|
- Mailpit (email testing)
|
||||||
|
|
||||||
|
**Use case**: Local development with hot-reloading and debugging
|
||||||
|
|
||||||
|
**Documentation**: See [dev/README.md](dev/README.md)
|
||||||
|
|
||||||
|
**Docker Compose**: `docker-compose.dev.yml` (in project root)
|
||||||
|
|
||||||
|
### Production Environment
|
||||||
|
|
||||||
|
**Architecture**: Single all-in-one container with all services
|
||||||
|
- Frontend (built React app served by Nginx)
|
||||||
|
- Backend (Laravel + PHP-FPM + Nginx)
|
||||||
|
- Database (MariaDB - internal)
|
||||||
|
- Redis (internal)
|
||||||
|
- All managed by Supervisord
|
||||||
|
|
||||||
|
**Use case**: Production deployment with minimal footprint
|
||||||
|
|
||||||
|
**Documentation**: See [prod/README.md](prod/README.md)
|
||||||
|
|
||||||
|
**Docker Compose**: `docker-compose.prod.yml` (in project root)
|
||||||
|
|
||||||
|
## Key Differences
|
||||||
|
|
||||||
|
| Aspect | Development | Production |
|
||||||
|
|--------|------------|------------|
|
||||||
|
| **Containers** | Multiple (5 services) | Single all-in-one |
|
||||||
|
| **Frontend** | Vite dev server with HMR | Pre-built static files |
|
||||||
|
| **Backend** | Live code mounting | Copied into image |
|
||||||
|
| **Database** | Separate container | Internal to main container |
|
||||||
|
| **Redis** | Separate container | Internal to main container |
|
||||||
|
| **Volumes** | Source code mounted | Persistent data only |
|
||||||
|
| **Ports** | Multiple (5173, 8000, 3306, etc.) | Single port (80) |
|
||||||
|
| **Size** | ~2GB+ | ~800MB |
|
||||||
|
|
||||||
|
## Port Allocation
|
||||||
|
|
||||||
|
### Development (default ports)
|
||||||
|
- Frontend: 5173
|
||||||
|
- Backend: 8000
|
||||||
|
- Database: 3306
|
||||||
|
- Redis: 6379
|
||||||
|
- Mailpit UI: 8025
|
||||||
|
- Mailpit SMTP: 1025
|
||||||
|
|
||||||
|
### Production (default ports)
|
||||||
|
- Application: 8080 (configurable via `APP_PORT`)
|
||||||
|
|
||||||
|
**Note**: When running both dev and production locally, ensure they don't use conflicting ports. The production setup defaults to port 8080 to avoid conflicts with the dev setup.
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
### Development
|
||||||
|
```bash
|
||||||
|
# Start all dev services
|
||||||
|
docker compose -f docker-compose.dev.yml up
|
||||||
|
|
||||||
|
# Stop all dev services
|
||||||
|
docker compose -f docker-compose.dev.yml down
|
||||||
|
```
|
||||||
|
|
||||||
|
### Production (Local Testing)
|
||||||
|
```bash
|
||||||
|
# Build and start production container
|
||||||
|
docker compose -f docker-compose.prod.yml up --build
|
||||||
|
|
||||||
|
# Stop production container
|
||||||
|
docker compose -f docker-compose.prod.yml down
|
||||||
|
```
|
||||||
|
|
||||||
|
## Environment Variables
|
||||||
|
|
||||||
|
Both environments use environment variables for configuration:
|
||||||
|
|
||||||
|
- **Development**: `.env.local` in project root
|
||||||
|
- **Production**: `.env` or pass via docker-compose environment section
|
||||||
|
|
||||||
|
See the respective README files for detailed environment variable documentation.
|
||||||
|
|
||||||
|
## Building Images
|
||||||
|
|
||||||
|
### Development
|
||||||
|
Development images are built automatically when you run `docker compose up`.
|
||||||
|
|
||||||
|
### Production
|
||||||
|
```bash
|
||||||
|
# Build the production image
|
||||||
|
docker build -f Dockerfile.prod -t trip-planner:latest .
|
||||||
|
|
||||||
|
# Or use docker-compose
|
||||||
|
docker compose -f docker-compose.prod.yml build
|
||||||
|
```
|
||||||
|
|
||||||
|
## CI/CD
|
||||||
|
|
||||||
|
The production image is automatically built and pushed to Codeberg Container Registry when changes are merged to the `main` branch.
|
||||||
|
|
||||||
|
See `.woodpecker.yml` in the project root for pipeline configuration.
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Development Issues
|
||||||
|
See [dev/README.md](dev/README.md#troubleshooting)
|
||||||
|
|
||||||
|
### Production Issues
|
||||||
|
See [prod/README.md](prod/README.md#troubleshooting)
|
||||||
|
|
||||||
|
## Security Notes
|
||||||
|
|
||||||
|
- Development setup runs with elevated privileges for convenience
|
||||||
|
- Production setup follows security best practices:
|
||||||
|
- Non-root users where possible
|
||||||
|
- Minimal base images
|
||||||
|
- No unnecessary privileges
|
||||||
|
- Security headers configured
|
||||||
|
- Internal services (DB, Redis) bound to localhost only
|
||||||
|
|
||||||
|
## Need Help?
|
||||||
|
|
||||||
|
- Check the specific environment README files in `dev/` or `prod/`
|
||||||
|
- Review the main project documentation
|
||||||
|
- Check container logs: `docker logs <container-name>`
|
||||||
|
|
@ -11,30 +11,31 @@ 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
|
||||||
|
|
|
||||||
342
docker/dev/README.md
Normal file
342
docker/dev/README.md
Normal file
|
|
@ -0,0 +1,342 @@
|
||||||
|
# Trip Planner - Development Environment
|
||||||
|
|
||||||
|
This document describes the development Docker setup for Trip Planner.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The development environment uses a multi-container architecture with Docker Compose, providing:
|
||||||
|
- **Hot Module Replacement (HMR)** for frontend development
|
||||||
|
- **Live code mounting** for instant backend changes
|
||||||
|
- **Separate services** for easy debugging
|
||||||
|
- **Development tools** like Mailpit for email testing
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────┐
|
||||||
|
│ trip-planner-network (Docker Bridge Network) │
|
||||||
|
│ │
|
||||||
|
│ ┌──────────────┐ ┌──────────────┐ ┌──────────┐ │
|
||||||
|
│ │ Frontend │ │ Backend │ │ Database │ │
|
||||||
|
│ │ React+Vite │ │ Laravel+PHP │ │ MariaDB │ │
|
||||||
|
│ │ Port: 5173 │ │ Port: 8000 │ │ Port:3306│ │
|
||||||
|
│ └──────────────┘ └──────────────┘ └──────────┘ │
|
||||||
|
│ │
|
||||||
|
│ ┌──────────────┐ ┌──────────────┐ │
|
||||||
|
│ │ Redis │ │ Mailpit │ │
|
||||||
|
│ │ Port: 6379 │ │ UI: 8025 │ │
|
||||||
|
│ └──────────────┘ │ SMTP: 1025 │ │
|
||||||
|
│ └──────────────┘ │
|
||||||
|
└─────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## Services
|
||||||
|
|
||||||
|
### Frontend (trip-planner-frontend-dev)
|
||||||
|
- **Image**: Built from `docker/frontend/Dockerfile.dev`
|
||||||
|
- **Port**: 5173
|
||||||
|
- **Technology**: React 19 + Vite 7
|
||||||
|
- **Features**: Hot Module Replacement, ESLint
|
||||||
|
- **Volume**: `./frontend:/app` (live code mounting)
|
||||||
|
|
||||||
|
### Backend (trip-planner-backend-dev)
|
||||||
|
- **Image**: Built from `docker/backend/Dockerfile.dev`
|
||||||
|
- **Port**: 8000
|
||||||
|
- **Technology**: Laravel 12 + PHP 8.3
|
||||||
|
- **Features**: Artisan commands, PHP-FPM
|
||||||
|
- **Volume**: `./backend:/var/www/html` (live code mounting)
|
||||||
|
|
||||||
|
### Database (trip-planner-db-dev)
|
||||||
|
- **Image**: MariaDB 11
|
||||||
|
- **Port**: 3306
|
||||||
|
- **Data**: Persisted in `./docker/data/mysql-data`
|
||||||
|
- **Credentials**: Configured via `.env.local`
|
||||||
|
|
||||||
|
### Redis (trip-planner-redis-dev)
|
||||||
|
- **Image**: Redis Alpine
|
||||||
|
- **Port**: 6379
|
||||||
|
- **Usage**: Cache, sessions, queues
|
||||||
|
- **Data**: Named volume `redis-data`
|
||||||
|
|
||||||
|
### Mailpit (trip-planner-mailpit-dev)
|
||||||
|
- **Image**: Axllent Mailpit
|
||||||
|
- **Ports**:
|
||||||
|
- SMTP: 1025
|
||||||
|
- Web UI: 8025
|
||||||
|
- **Usage**: Email testing (catches all outgoing emails)
|
||||||
|
|
||||||
|
## Getting Started
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
- Docker Engine 20.10+
|
||||||
|
- Docker Compose 2.0+
|
||||||
|
- At least 4GB RAM available for Docker
|
||||||
|
|
||||||
|
### Initial Setup
|
||||||
|
|
||||||
|
1. **Clone the repository** (if not already done):
|
||||||
|
```bash
|
||||||
|
git clone ssh://git@codeberg.org/lvl0/trip-planner.git
|
||||||
|
cd trip-planner
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Create environment file**:
|
||||||
|
```bash
|
||||||
|
# Copy the example environment file
|
||||||
|
cp .env.local.example .env.local
|
||||||
|
|
||||||
|
# Edit .env.local with your settings
|
||||||
|
nano .env.local
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Start the development environment**:
|
||||||
|
```bash
|
||||||
|
docker compose -f docker-compose.dev.yml up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Wait for services to be ready** (check with):
|
||||||
|
```bash
|
||||||
|
docker compose -f docker-compose.dev.yml ps
|
||||||
|
```
|
||||||
|
|
||||||
|
5. **Run initial Laravel setup**:
|
||||||
|
```bash
|
||||||
|
# Generate application key
|
||||||
|
docker exec trip-planner-backend-dev php artisan key:generate
|
||||||
|
|
||||||
|
# Run migrations
|
||||||
|
docker exec trip-planner-backend-dev php artisan migrate
|
||||||
|
|
||||||
|
# Seed database (optional)
|
||||||
|
docker exec trip-planner-backend-dev php artisan db:seed
|
||||||
|
```
|
||||||
|
|
||||||
|
6. **Access the application**:
|
||||||
|
- Frontend: http://localhost:5173
|
||||||
|
- Backend API: http://localhost:8000
|
||||||
|
- Mailpit UI: http://localhost:8025
|
||||||
|
|
||||||
|
### Daily Development Workflow
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Start all services
|
||||||
|
docker compose -f docker-compose.dev.yml up -d
|
||||||
|
|
||||||
|
# View logs
|
||||||
|
docker compose -f docker-compose.dev.yml logs -f
|
||||||
|
|
||||||
|
# Stop all services
|
||||||
|
docker compose -f docker-compose.dev.yml down
|
||||||
|
|
||||||
|
# Restart a specific service
|
||||||
|
docker compose -f docker-compose.dev.yml restart backend
|
||||||
|
```
|
||||||
|
|
||||||
|
## Environment Variables
|
||||||
|
|
||||||
|
The development environment reads from `.env.local` in the project root.
|
||||||
|
|
||||||
|
### Required Variables
|
||||||
|
|
||||||
|
```env
|
||||||
|
# Application
|
||||||
|
APP_NAME="Trip Planner"
|
||||||
|
APP_KEY=base64:your-generated-key-here
|
||||||
|
|
||||||
|
# Database
|
||||||
|
DB_DATABASE=trip_planner
|
||||||
|
DB_USERNAME=trip_user
|
||||||
|
DB_PASSWORD=secret
|
||||||
|
|
||||||
|
# Mail
|
||||||
|
MAIL_MAILER=smtp
|
||||||
|
MAIL_HOST=mailpit
|
||||||
|
MAIL_PORT=1025
|
||||||
|
```
|
||||||
|
|
||||||
|
### Optional Variables
|
||||||
|
|
||||||
|
```env
|
||||||
|
# Frontend
|
||||||
|
VITE_API_URL=http://localhost:8000
|
||||||
|
|
||||||
|
# Redis
|
||||||
|
REDIS_HOST=redis
|
||||||
|
REDIS_PORT=6379
|
||||||
|
```
|
||||||
|
|
||||||
|
## Common Tasks
|
||||||
|
|
||||||
|
### Backend Tasks
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run Artisan commands
|
||||||
|
docker exec trip-planner-backend-dev php artisan <command>
|
||||||
|
|
||||||
|
# Examples:
|
||||||
|
docker exec trip-planner-backend-dev php artisan migrate
|
||||||
|
docker exec trip-planner-backend-dev php artisan make:controller UserController
|
||||||
|
docker exec trip-planner-backend-dev php artisan tinker
|
||||||
|
|
||||||
|
# Install PHP dependencies
|
||||||
|
docker exec trip-planner-backend-dev composer install
|
||||||
|
|
||||||
|
# Run tests
|
||||||
|
docker exec trip-planner-backend-dev php artisan test
|
||||||
|
|
||||||
|
# Clear caches
|
||||||
|
docker exec trip-planner-backend-dev php artisan cache:clear
|
||||||
|
docker exec trip-planner-backend-dev php artisan config:clear
|
||||||
|
docker exec trip-planner-backend-dev php artisan route:clear
|
||||||
|
```
|
||||||
|
|
||||||
|
### Frontend Tasks
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install npm dependencies
|
||||||
|
docker exec trip-planner-frontend-dev npm install
|
||||||
|
|
||||||
|
# Run linter
|
||||||
|
docker exec trip-planner-frontend-dev npm run lint
|
||||||
|
|
||||||
|
# Build for preview
|
||||||
|
docker exec trip-planner-frontend-dev npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
### Database Tasks
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Access MySQL shell
|
||||||
|
docker exec -it trip-planner-db-dev mysql -u trip_user -p trip_planner
|
||||||
|
|
||||||
|
# Backup database
|
||||||
|
docker exec trip-planner-db-dev mysqldump -u trip_user -p trip_planner > backup.sql
|
||||||
|
|
||||||
|
# Restore database
|
||||||
|
docker exec -i trip-planner-db-dev mysql -u trip_user -p trip_planner < backup.sql
|
||||||
|
|
||||||
|
# Reset database
|
||||||
|
docker compose -f docker-compose.dev.yml down -v # Removes volumes!
|
||||||
|
docker compose -f docker-compose.dev.yml up -d
|
||||||
|
docker exec trip-planner-backend-dev php artisan migrate --seed
|
||||||
|
```
|
||||||
|
|
||||||
|
### Viewing Logs
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# All services
|
||||||
|
docker compose -f docker-compose.dev.yml logs -f
|
||||||
|
|
||||||
|
# Specific service
|
||||||
|
docker compose -f docker-compose.dev.yml logs -f backend
|
||||||
|
|
||||||
|
# Laravel logs
|
||||||
|
docker exec trip-planner-backend-dev tail -f storage/logs/laravel.log
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Services won't start
|
||||||
|
|
||||||
|
**Check for port conflicts:**
|
||||||
|
```bash
|
||||||
|
# Check what's using the ports
|
||||||
|
lsof -i :5173 # Frontend
|
||||||
|
lsof -i :8000 # Backend
|
||||||
|
lsof -i :3306 # Database
|
||||||
|
|
||||||
|
# Stop conflicting services or change ports in docker-compose.dev.yml
|
||||||
|
```
|
||||||
|
|
||||||
|
### Frontend HMR not working
|
||||||
|
|
||||||
|
**SELinux issue (Fedora/RHEL):**
|
||||||
|
The `:Z` flag in volume mounts handles this, but if HMR still doesn't work:
|
||||||
|
```bash
|
||||||
|
# Check if SELinux is enforcing
|
||||||
|
getenforce
|
||||||
|
|
||||||
|
# If needed, you can temporarily set to permissive
|
||||||
|
sudo setenforce 0
|
||||||
|
```
|
||||||
|
|
||||||
|
### Backend not connecting to database
|
||||||
|
|
||||||
|
**Wait for database to be fully ready:**
|
||||||
|
```bash
|
||||||
|
# Check database status
|
||||||
|
docker compose -f docker-compose.dev.yml ps database
|
||||||
|
|
||||||
|
# Check database logs
|
||||||
|
docker compose -f docker-compose.dev.yml logs database
|
||||||
|
|
||||||
|
# Verify connection
|
||||||
|
docker exec trip-planner-backend-dev php artisan migrate:status
|
||||||
|
```
|
||||||
|
|
||||||
|
### Permission issues
|
||||||
|
|
||||||
|
**Vendor/node_modules ownership:**
|
||||||
|
```bash
|
||||||
|
# Fix backend vendor permissions
|
||||||
|
docker exec trip-planner-backend-dev chown -R www-data:www-data vendor
|
||||||
|
|
||||||
|
# Fix frontend node_modules (usually not needed with named volumes)
|
||||||
|
docker compose -f docker-compose.dev.yml down
|
||||||
|
docker volume rm trip-planner_node_modules
|
||||||
|
docker compose -f docker-compose.dev.yml up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
### Clean slate rebuild
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Stop everything
|
||||||
|
docker compose -f docker-compose.dev.yml down -v
|
||||||
|
|
||||||
|
# Remove images
|
||||||
|
docker rmi trip-planner-frontend-dev trip-planner-backend-dev
|
||||||
|
|
||||||
|
# Rebuild and start
|
||||||
|
docker compose -f docker-compose.dev.yml up --build
|
||||||
|
```
|
||||||
|
|
||||||
|
## Performance Tips
|
||||||
|
|
||||||
|
### Disable Features You Don't Need
|
||||||
|
|
||||||
|
If a service is not needed for your current task:
|
||||||
|
```bash
|
||||||
|
# Start only specific services
|
||||||
|
docker compose -f docker-compose.dev.yml up -d backend database redis
|
||||||
|
```
|
||||||
|
|
||||||
|
### Use Cached Volumes
|
||||||
|
|
||||||
|
The dev setup uses named volumes for `node_modules` and `vendor` to improve performance:
|
||||||
|
- `node_modules`: Frontend dependencies
|
||||||
|
- `vendor`: Backend PHP dependencies
|
||||||
|
|
||||||
|
These are NOT mounted from your host, keeping filesystem operations fast.
|
||||||
|
|
||||||
|
## Differences from Production
|
||||||
|
|
||||||
|
| Feature | Development | Production |
|
||||||
|
|---------|------------|------------|
|
||||||
|
| Code loading | Live mounted volumes | Copied into image |
|
||||||
|
| Caching | Disabled/minimal | Aggressive (OPcache, etc.) |
|
||||||
|
| Error display | Verbose | Hidden |
|
||||||
|
| Debug mode | Enabled | Disabled |
|
||||||
|
| Privileges | Elevated for convenience | Minimal (security) |
|
||||||
|
| Rebuilding | Rarely needed | Required for changes |
|
||||||
|
|
||||||
|
## Security Note
|
||||||
|
|
||||||
|
⚠️ **The development environment is NOT secure** - it runs with `privileged: true` for convenience and mounts source code directly. **Never use this setup in production!**
|
||||||
|
|
||||||
|
## Need Help?
|
||||||
|
|
||||||
|
- Check the main [docker/README.md](../README.md)
|
||||||
|
- Review Laravel logs: `docker exec trip-planner-backend-dev tail -f storage/logs/laravel.log`
|
||||||
|
- Check container health: `docker compose -f docker-compose.dev.yml ps`
|
||||||
|
- Inspect a container: `docker inspect trip-planner-backend-dev`
|
||||||
141
docker/entrypoint.sh
Normal file
141
docker/entrypoint.sh
Normal file
|
|
@ -0,0 +1,141 @@
|
||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# Configuration
|
||||||
|
MYSQL_WAIT_ATTEMPTS=15
|
||||||
|
MYSQL_WAIT_INTERVAL=2
|
||||||
|
REDIS_WAIT_ATTEMPTS=10
|
||||||
|
|
||||||
|
echo "========================================="
|
||||||
|
echo "[INIT] Trip Planner - Production Container Init"
|
||||||
|
echo "========================================="
|
||||||
|
|
||||||
|
# Validate required environment variables
|
||||||
|
if [ -z "$APP_KEY" ]; then
|
||||||
|
echo "[INIT] ERROR: APP_KEY is not set!"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Laravel APP_KEY must be base64: prefix + minimum 44 chars (32 bytes base64-encoded)
|
||||||
|
if [[ ! "$APP_KEY" =~ ^base64:.{44,}$ ]]; then
|
||||||
|
echo "[INIT] ERROR: APP_KEY format is invalid! Must be base64:xxxxx (generated with 'php artisan key:generate')"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "$DB_PASSWORD" ]; then
|
||||||
|
echo "[INIT] ERROR: DB_PASSWORD is not set!"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "$MYSQL_ROOT_PASSWORD" ]; then
|
||||||
|
echo "[INIT] ERROR: MYSQL_ROOT_PASSWORD is not set!"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Start MariaDB in background
|
||||||
|
echo "[INIT] Starting MariaDB..."
|
||||||
|
mysqld --user=mysql --datadir=/var/lib/mysql --bind-address=127.0.0.1 &
|
||||||
|
MYSQL_PID=$!
|
||||||
|
|
||||||
|
# Start Redis in background
|
||||||
|
echo "[INIT] Starting Redis..."
|
||||||
|
redis-server --bind 127.0.0.1 --port 6379 --dir /data/redis --appendonly yes --daemonize yes
|
||||||
|
|
||||||
|
# Wait for MariaDB to be ready
|
||||||
|
echo "[INIT] Waiting for MariaDB to be ready..."
|
||||||
|
for i in $(seq 1 $MYSQL_WAIT_ATTEMPTS); do
|
||||||
|
if mysqladmin ping --socket=/run/mysqld/mysqld.sock --silent 2>/dev/null; then
|
||||||
|
echo "[INIT] MariaDB is up! (took ${i} attempts)"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
if [ $i -eq $MYSQL_WAIT_ATTEMPTS ]; then
|
||||||
|
echo "[INIT] ERROR: MariaDB failed to start within $((MYSQL_WAIT_ATTEMPTS * MYSQL_WAIT_INTERVAL)) seconds"
|
||||||
|
kill $MYSQL_PID 2>/dev/null || true
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
sleep $MYSQL_WAIT_INTERVAL
|
||||||
|
done
|
||||||
|
|
||||||
|
# Wait for Redis to be ready
|
||||||
|
echo "[INIT] Waiting for Redis to be ready..."
|
||||||
|
for i in $(seq 1 $REDIS_WAIT_ATTEMPTS); do
|
||||||
|
if redis-cli ping 2>/dev/null | grep -q PONG; then
|
||||||
|
echo "[INIT] Redis is up! (took ${i} attempts)"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
if [ $i -eq $REDIS_WAIT_ATTEMPTS ]; then
|
||||||
|
echo "[INIT] ERROR: Redis failed to start within ${REDIS_WAIT_ATTEMPTS} seconds"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
|
||||||
|
# Check if database needs initialization
|
||||||
|
echo "[INIT] Checking database initialization..."
|
||||||
|
if ! mysql --socket=/run/mysqld/mysqld.sock -u root -p"${MYSQL_ROOT_PASSWORD}" -e "SELECT 1" &>/dev/null; then
|
||||||
|
echo "[INIT] Setting root password for first-time setup..."
|
||||||
|
mysqladmin --socket=/run/mysqld/mysqld.sock -u root password "${MYSQL_ROOT_PASSWORD}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Create database and user if they don't exist
|
||||||
|
echo "[INIT] Ensuring database and user exist..."
|
||||||
|
# Escape single quotes in password for SQL safety
|
||||||
|
DB_PASSWORD_ESCAPED=$(echo "${DB_PASSWORD}" | sed "s/'/''/g")
|
||||||
|
mysql --socket=/run/mysqld/mysqld.sock -u root -p"${MYSQL_ROOT_PASSWORD}" <<EOSQL
|
||||||
|
CREATE DATABASE IF NOT EXISTS \`${DB_DATABASE}\`;
|
||||||
|
CREATE USER IF NOT EXISTS '${DB_USERNAME}'@'localhost' IDENTIFIED BY '${DB_PASSWORD_ESCAPED}';
|
||||||
|
GRANT SELECT, INSERT, UPDATE, DELETE, CREATE, DROP, INDEX, ALTER, LOCK TABLES ON \`${DB_DATABASE}\`.* TO '${DB_USERNAME}'@'localhost';
|
||||||
|
FLUSH PRIVILEGES;
|
||||||
|
EOSQL
|
||||||
|
echo "[INIT] Database setup verified"
|
||||||
|
|
||||||
|
# Fix permissions for persistent volumes
|
||||||
|
echo "[INIT] Fixing permissions..."
|
||||||
|
chown -R redis:redis /data/redis 2>/dev/null || true
|
||||||
|
chown -R appuser:appuser /var/www/html/storage 2>/dev/null || true
|
||||||
|
chmod -R 775 /var/www/html/storage 2>/dev/null || true
|
||||||
|
|
||||||
|
# Run Laravel migrations
|
||||||
|
echo "[INIT] Running Laravel migrations..."
|
||||||
|
cd /var/www/html
|
||||||
|
php artisan migrate --force
|
||||||
|
|
||||||
|
# Ensure storage link exists
|
||||||
|
if [ ! -L /var/www/html/public/storage ]; then
|
||||||
|
echo "[INIT] Creating storage link..."
|
||||||
|
php artisan storage:link
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Stop background services gracefully (supervisor will manage them)
|
||||||
|
echo "[INIT] Stopping temporary database services..."
|
||||||
|
|
||||||
|
# Stop Redis first (faster shutdown)
|
||||||
|
echo "[INIT] Stopping temporary Redis..."
|
||||||
|
redis-cli shutdown 2>/dev/null || true
|
||||||
|
|
||||||
|
# Gracefully stop MariaDB
|
||||||
|
echo "[INIT] Stopping temporary MariaDB..."
|
||||||
|
mysqladmin --socket=/run/mysqld/mysqld.sock -u root -p"${MYSQL_ROOT_PASSWORD}" shutdown 2>/dev/null || kill $MYSQL_PID 2>/dev/null || true
|
||||||
|
|
||||||
|
# Wait for processes to actually terminate
|
||||||
|
echo "[INIT] Waiting for services to fully stop..."
|
||||||
|
for i in {1..15}; do
|
||||||
|
if ! pgrep -x mysqld >/dev/null && ! pgrep -x redis-server >/dev/null; then
|
||||||
|
echo "[INIT] All temporary services stopped successfully"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
if [ $i -eq 15 ]; then
|
||||||
|
echo "[INIT] WARNING: Services took too long to stop, forcing..."
|
||||||
|
pkill -9 mysqld || true
|
||||||
|
pkill -9 redis-server || true
|
||||||
|
sleep 1
|
||||||
|
fi
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "========================================="
|
||||||
|
echo "[INIT] Initialization complete! Starting services..."
|
||||||
|
echo "========================================="
|
||||||
|
|
||||||
|
# Execute the main command (supervisord)
|
||||||
|
exec "$@"
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
100
docker/nginx/production.conf
Normal file
100
docker/nginx/production.conf
Normal file
|
|
@ -0,0 +1,100 @@
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name _;
|
||||||
|
|
||||||
|
# Security headers
|
||||||
|
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||||
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
|
add_header X-XSS-Protection "1; mode=block" always;
|
||||||
|
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||||
|
|
||||||
|
# Disable server tokens
|
||||||
|
server_tokens off;
|
||||||
|
|
||||||
|
# Gzip compression
|
||||||
|
gzip on;
|
||||||
|
gzip_vary on;
|
||||||
|
gzip_min_length 1024;
|
||||||
|
gzip_types text/plain text/css text/xml text/javascript application/json application/javascript application/xml+rss application/rss+xml application/atom+xml image/svg+xml application/vnd.ms-fontobject application/x-font-ttf font/opentype;
|
||||||
|
|
||||||
|
# API Backend
|
||||||
|
location /api {
|
||||||
|
root /var/www/html/public;
|
||||||
|
try_files $uri $uri/ /index.php?$query_string;
|
||||||
|
|
||||||
|
location ~ \.php$ {
|
||||||
|
try_files $uri =404;
|
||||||
|
fastcgi_pass 127.0.0.1:9000;
|
||||||
|
fastcgi_index index.php;
|
||||||
|
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
|
||||||
|
include fastcgi_params;
|
||||||
|
fastcgi_buffer_size 32k;
|
||||||
|
fastcgi_buffers 8 16k;
|
||||||
|
fastcgi_read_timeout 240;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Sanctum/CSRF cookie endpoint
|
||||||
|
location /sanctum/csrf-cookie {
|
||||||
|
root /var/www/html/public;
|
||||||
|
try_files $uri /index.php?$query_string;
|
||||||
|
|
||||||
|
location ~ \.php$ {
|
||||||
|
try_files $uri =404;
|
||||||
|
fastcgi_pass 127.0.0.1:9000;
|
||||||
|
fastcgi_index index.php;
|
||||||
|
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
|
||||||
|
include fastcgi_params;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Storage files (user uploads, etc)
|
||||||
|
location /storage {
|
||||||
|
alias /var/www/html/storage/app/public;
|
||||||
|
try_files $uri =404;
|
||||||
|
expires 1y;
|
||||||
|
add_header Cache-Control "public, immutable";
|
||||||
|
}
|
||||||
|
|
||||||
|
# Frontend SPA
|
||||||
|
location / {
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
|
||||||
|
# Cache static assets
|
||||||
|
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||||
|
expires 1y;
|
||||||
|
add_header Cache-Control "public, immutable";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Health check endpoints
|
||||||
|
location /health {
|
||||||
|
access_log off;
|
||||||
|
return 200 "healthy\n";
|
||||||
|
add_header Content-Type text/plain;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Laravel health check endpoint
|
||||||
|
location /up {
|
||||||
|
root /var/www/html/public;
|
||||||
|
try_files $uri /index.php?$query_string;
|
||||||
|
|
||||||
|
location ~ \.php$ {
|
||||||
|
try_files $uri =404;
|
||||||
|
fastcgi_pass 127.0.0.1:9000;
|
||||||
|
fastcgi_index index.php;
|
||||||
|
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
|
||||||
|
include fastcgi_params;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Deny access to hidden files
|
||||||
|
location ~ /\.(?!well-known).* {
|
||||||
|
deny all;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Logging
|
||||||
|
access_log /var/log/nginx/access.log;
|
||||||
|
error_log /var/log/nginx/error.log warn;
|
||||||
|
}
|
||||||
559
docker/prod/README.md
Normal file
559
docker/prod/README.md
Normal file
|
|
@ -0,0 +1,559 @@
|
||||||
|
# Trip Planner - Production Environment
|
||||||
|
|
||||||
|
This document describes the production Docker setup for Trip Planner.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The production environment uses a **single all-in-one container** that includes:
|
||||||
|
- ✅ Frontend (React SPA built and served by Nginx)
|
||||||
|
- ✅ Backend (Laravel API with PHP-FPM)
|
||||||
|
- ✅ Database (MariaDB)
|
||||||
|
- ✅ Cache/Sessions (Redis)
|
||||||
|
- ✅ Web Server (Nginx as reverse proxy)
|
||||||
|
|
||||||
|
All services are managed by **Supervisord** within a single container.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────┐
|
||||||
|
│ trip-planner-production (Single Container) │
|
||||||
|
│ │
|
||||||
|
│ ┌─────────────────────────────────────────┐ │
|
||||||
|
│ │ Nginx (Port 80) │ │
|
||||||
|
│ │ ┌──────────────┐ ┌──────────────┐ │ │
|
||||||
|
│ │ │ Frontend │ │ Backend API │ │ │
|
||||||
|
│ │ │ Static Files│ │ PHP-FPM:9000│ │ │
|
||||||
|
│ │ │ (/) │ │ (/api/*) │ │ │
|
||||||
|
│ │ └──────────────┘ └──────────────┘ │ │
|
||||||
|
│ └─────────────────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ ┌──────────────┐ ┌──────────────┐ │
|
||||||
|
│ │ MariaDB │ │ Redis │ │
|
||||||
|
│ │ localhost │ │ localhost │ │
|
||||||
|
│ │ :3306 │ │ :6379 │ │
|
||||||
|
│ └──────────────┘ └──────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ Managed by Supervisord │
|
||||||
|
└─────────────────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
└─ Port 80 (or configured APP_PORT)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Key Features
|
||||||
|
|
||||||
|
### Security
|
||||||
|
- ✅ Non-root users for services where possible
|
||||||
|
- ✅ Minimal Alpine-based image
|
||||||
|
- ✅ Database and Redis bound to localhost only
|
||||||
|
- ✅ Security headers configured
|
||||||
|
- ✅ OPcache enabled with production settings
|
||||||
|
- ✅ PHP display_errors disabled
|
||||||
|
|
||||||
|
### Optimization
|
||||||
|
- ✅ Multi-stage build (smaller image size ~800MB)
|
||||||
|
- ✅ OPcache with no timestamp validation
|
||||||
|
- ✅ Gzip compression enabled
|
||||||
|
- ✅ Static asset caching (1 year)
|
||||||
|
- ✅ Optimized Composer autoloader
|
||||||
|
|
||||||
|
### Reliability
|
||||||
|
- ✅ Health checks configured
|
||||||
|
- ✅ Automatic service restart via Supervisord
|
||||||
|
- ✅ Persistent data volumes for database, redis, and storage
|
||||||
|
- ✅ Proper initialization and migration on startup
|
||||||
|
|
||||||
|
## Building the Image
|
||||||
|
|
||||||
|
### Locally
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# From project root
|
||||||
|
docker build -f Dockerfile.prod -t trip-planner:latest .
|
||||||
|
|
||||||
|
# Check image size
|
||||||
|
docker images trip-planner:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
### Using Docker Compose
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f docker-compose.prod.yml build
|
||||||
|
```
|
||||||
|
|
||||||
|
### CI/CD (Automatic)
|
||||||
|
|
||||||
|
The image is automatically built and pushed to Codeberg Container Registry when:
|
||||||
|
- Changes are merged to `main` branch
|
||||||
|
- Pipeline extracts version from merge commit (e.g., from `release/v0.1.0`)
|
||||||
|
- Tagged as both `latest` and version number (e.g., `0.1.0`)
|
||||||
|
|
||||||
|
## Running the Container
|
||||||
|
|
||||||
|
### Using Docker Compose (Recommended)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Start the container
|
||||||
|
docker compose -f docker-compose.prod.yml up -d
|
||||||
|
|
||||||
|
# View logs
|
||||||
|
docker compose -f docker-compose.prod.yml logs -f
|
||||||
|
|
||||||
|
# Stop the container
|
||||||
|
docker compose -f docker-compose.prod.yml down
|
||||||
|
|
||||||
|
# Stop and remove volumes (⚠️ deletes data!)
|
||||||
|
docker compose -f docker-compose.prod.yml down -v
|
||||||
|
```
|
||||||
|
|
||||||
|
### Using Docker Run
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run -d \
|
||||||
|
--name trip-planner \
|
||||||
|
-p 8080:80 \
|
||||||
|
-e APP_KEY=base64:your-key-here \
|
||||||
|
-e DB_PASSWORD=secure-password \
|
||||||
|
-e DB_USERNAME=trip_user \
|
||||||
|
-e DB_DATABASE=trip_planner \
|
||||||
|
-v trip-planner-db:/var/lib/mysql \
|
||||||
|
-v trip-planner-redis:/data/redis \
|
||||||
|
-v trip-planner-storage:/var/www/html/storage/app \
|
||||||
|
trip-planner:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
### Using the Published Image
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Pull from Codeberg Container Registry
|
||||||
|
docker pull codeberg.org/lvl0/trip-planner:latest
|
||||||
|
|
||||||
|
# Or a specific version
|
||||||
|
docker pull codeberg.org/lvl0/trip-planner:0.1.0
|
||||||
|
|
||||||
|
# Run it
|
||||||
|
docker run -d \
|
||||||
|
--name trip-planner \
|
||||||
|
-p 8080:80 \
|
||||||
|
-e APP_KEY=base64:your-key-here \
|
||||||
|
-e DB_PASSWORD=secure-password \
|
||||||
|
codeberg.org/lvl0/trip-planner:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
## Environment Variables
|
||||||
|
|
||||||
|
### Required Variables
|
||||||
|
|
||||||
|
```env
|
||||||
|
# Application Key (generate with: php artisan key:generate)
|
||||||
|
APP_KEY=base64:your-generated-key-here
|
||||||
|
|
||||||
|
# Database Credentials
|
||||||
|
DB_PASSWORD=your-secure-password
|
||||||
|
DB_USERNAME=trip_user
|
||||||
|
DB_DATABASE=trip_planner
|
||||||
|
```
|
||||||
|
|
||||||
|
### Optional Variables (with defaults)
|
||||||
|
|
||||||
|
```env
|
||||||
|
# Application
|
||||||
|
APP_NAME="Trip Planner"
|
||||||
|
APP_ENV=production
|
||||||
|
APP_DEBUG=false
|
||||||
|
APP_URL=http://localhost:8080
|
||||||
|
|
||||||
|
# Database (internal MariaDB)
|
||||||
|
DB_CONNECTION=mysql
|
||||||
|
DB_HOST=127.0.0.1
|
||||||
|
DB_PORT=3306
|
||||||
|
|
||||||
|
# Redis (internal)
|
||||||
|
REDIS_HOST=127.0.0.1
|
||||||
|
REDIS_PORT=6379
|
||||||
|
REDIS_PASSWORD=null
|
||||||
|
|
||||||
|
# Cache & Session
|
||||||
|
CACHE_DRIVER=redis
|
||||||
|
QUEUE_CONNECTION=redis
|
||||||
|
SESSION_DRIVER=redis
|
||||||
|
SESSION_LIFETIME=120
|
||||||
|
|
||||||
|
# Mail
|
||||||
|
MAIL_MAILER=log
|
||||||
|
MAIL_FROM_ADDRESS=noreply@tripplanner.local
|
||||||
|
MAIL_FROM_NAME="Trip Planner"
|
||||||
|
|
||||||
|
# Ports
|
||||||
|
APP_PORT=8080 # External port to expose
|
||||||
|
```
|
||||||
|
|
||||||
|
### Setting Environment Variables
|
||||||
|
|
||||||
|
**Docker Compose (recommended):**
|
||||||
|
```yaml
|
||||||
|
# Create .env file in project root
|
||||||
|
APP_KEY=base64:...
|
||||||
|
DB_PASSWORD=secret
|
||||||
|
APP_URL=https://tripplanner.example.com
|
||||||
|
```
|
||||||
|
|
||||||
|
**Docker Run:**
|
||||||
|
```bash
|
||||||
|
docker run -e APP_KEY=base64:... -e DB_PASSWORD=secret ...
|
||||||
|
```
|
||||||
|
|
||||||
|
## Persistent Data
|
||||||
|
|
||||||
|
The production setup uses three volumes for persistent data:
|
||||||
|
|
||||||
|
| Volume | Purpose | Path in Container |
|
||||||
|
|--------|---------|-------------------|
|
||||||
|
| `db-data` | MariaDB database files | `/var/lib/mysql` |
|
||||||
|
| `redis-data` | Redis persistence | `/data/redis` |
|
||||||
|
| `storage-data` | User uploads, files | `/var/www/html/storage/app` |
|
||||||
|
|
||||||
|
### Backup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Backup database
|
||||||
|
docker exec trip-planner-production mysqldump -u trip_user -p trip_planner > backup.sql
|
||||||
|
|
||||||
|
# Backup volumes
|
||||||
|
docker run --rm \
|
||||||
|
-v trip-planner-db-data:/data \
|
||||||
|
-v $(pwd):/backup \
|
||||||
|
alpine tar czf /backup/db-backup.tar.gz /data
|
||||||
|
|
||||||
|
# Backup uploaded files
|
||||||
|
docker run --rm \
|
||||||
|
-v trip-planner-storage-data:/data \
|
||||||
|
-v $(pwd):/backup \
|
||||||
|
alpine tar czf /backup/storage-backup.tar.gz /data
|
||||||
|
```
|
||||||
|
|
||||||
|
### Restore
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Restore database
|
||||||
|
docker exec -i trip-planner-production mysql -u trip_user -p trip_planner < backup.sql
|
||||||
|
|
||||||
|
# Restore volumes
|
||||||
|
docker run --rm \
|
||||||
|
-v trip-planner-db-data:/data \
|
||||||
|
-v $(pwd):/backup \
|
||||||
|
alpine sh -c "cd / && tar xzf /backup/db-backup.tar.gz"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Health Checks
|
||||||
|
|
||||||
|
The container includes a health check endpoint:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check container health
|
||||||
|
docker inspect trip-planner-production | grep -A 5 Health
|
||||||
|
|
||||||
|
# Manual health check
|
||||||
|
curl http://localhost:8080/health
|
||||||
|
# Should return: healthy
|
||||||
|
|
||||||
|
# Check specific services
|
||||||
|
docker exec trip-planner-production supervisorctl status
|
||||||
|
```
|
||||||
|
|
||||||
|
## Accessing Services
|
||||||
|
|
||||||
|
When the container is running:
|
||||||
|
|
||||||
|
- **Application**: http://localhost:8080 (or your configured `APP_PORT`)
|
||||||
|
- **Health Check**: http://localhost:8080/health
|
||||||
|
- **API**: http://localhost:8080/api/*
|
||||||
|
|
||||||
|
### Internal Services (not exposed)
|
||||||
|
|
||||||
|
These services run inside the container and are not accessible from outside:
|
||||||
|
- MariaDB: `127.0.0.1:3306`
|
||||||
|
- Redis: `127.0.0.1:6379`
|
||||||
|
- PHP-FPM: `127.0.0.1:9000`
|
||||||
|
|
||||||
|
## Maintenance
|
||||||
|
|
||||||
|
### View Logs
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# All services
|
||||||
|
docker compose -f docker-compose.prod.yml logs -f
|
||||||
|
|
||||||
|
# Specific service logs via supervisord
|
||||||
|
docker exec trip-planner-production supervisorctl tail -f nginx
|
||||||
|
docker exec trip-planner-production supervisorctl tail -f php-fpm
|
||||||
|
docker exec trip-planner-production supervisorctl tail -f mariadb
|
||||||
|
|
||||||
|
# Laravel logs
|
||||||
|
docker exec trip-planner-production tail -f /var/www/html/storage/logs/laravel.log
|
||||||
|
```
|
||||||
|
|
||||||
|
### Execute Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Laravel Artisan
|
||||||
|
docker exec trip-planner-production php artisan <command>
|
||||||
|
|
||||||
|
# Examples:
|
||||||
|
docker exec trip-planner-production php artisan migrate:status
|
||||||
|
docker exec trip-planner-production php artisan cache:clear
|
||||||
|
docker exec trip-planner-production php artisan queue:work # Run queue worker
|
||||||
|
|
||||||
|
# Database access
|
||||||
|
docker exec -it trip-planner-production mysql -u trip_user -p trip_planner
|
||||||
|
|
||||||
|
# Shell access
|
||||||
|
docker exec -it trip-planner-production sh
|
||||||
|
```
|
||||||
|
|
||||||
|
### Update Application
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Pull latest image
|
||||||
|
docker pull codeberg.org/lvl0/trip-planner:latest
|
||||||
|
|
||||||
|
# Recreate container (preserves volumes)
|
||||||
|
docker compose -f docker-compose.prod.yml up -d --force-recreate
|
||||||
|
|
||||||
|
# Or specific version
|
||||||
|
docker pull codeberg.org/lvl0/trip-planner:0.2.0
|
||||||
|
docker compose -f docker-compose.prod.yml up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
## Deployment
|
||||||
|
|
||||||
|
### On a VPS/Server
|
||||||
|
|
||||||
|
1. **Install Docker**:
|
||||||
|
```bash
|
||||||
|
curl -fsSL https://get.docker.com -o get-docker.sh
|
||||||
|
sudo sh get-docker.sh
|
||||||
|
sudo usermod -aG docker $USER
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Create deployment directory**:
|
||||||
|
```bash
|
||||||
|
mkdir -p ~/trip-planner
|
||||||
|
cd ~/trip-planner
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Create docker-compose.yml**:
|
||||||
|
```yaml
|
||||||
|
version: '3.8'
|
||||||
|
services:
|
||||||
|
app:
|
||||||
|
image: codeberg.org/lvl0/trip-planner:latest
|
||||||
|
container_name: trip-planner
|
||||||
|
ports:
|
||||||
|
- "8080:80"
|
||||||
|
environment:
|
||||||
|
APP_KEY: ${APP_KEY}
|
||||||
|
DB_PASSWORD: ${DB_PASSWORD}
|
||||||
|
APP_URL: https://your-domain.com
|
||||||
|
volumes:
|
||||||
|
- db-data:/var/lib/mysql
|
||||||
|
- redis-data:/data/redis
|
||||||
|
- storage-data:/var/www/html/storage/app
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
db-data:
|
||||||
|
redis-data:
|
||||||
|
storage-data:
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Create .env file**:
|
||||||
|
```bash
|
||||||
|
echo "APP_KEY=base64:$(openssl rand -base64 32)" > .env
|
||||||
|
echo "DB_PASSWORD=$(openssl rand -base64 24)" >> .env
|
||||||
|
```
|
||||||
|
|
||||||
|
5. **Start the application**:
|
||||||
|
```bash
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
6. **Set up reverse proxy** (optional, recommended):
|
||||||
|
Use Nginx, Caddy, or Traefik to handle HTTPS.
|
||||||
|
|
||||||
|
### With Reverse Proxy (Nginx Example)
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name tripplanner.example.com;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://localhost:8080;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Then use Certbot for HTTPS:
|
||||||
|
```bash
|
||||||
|
sudo certbot --nginx -d tripplanner.example.com
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Container won't start
|
||||||
|
|
||||||
|
**Check logs:**
|
||||||
|
```bash
|
||||||
|
docker compose -f docker-compose.prod.yml logs
|
||||||
|
```
|
||||||
|
|
||||||
|
**Common issues:**
|
||||||
|
- Missing `APP_KEY`: Generate one with `php artisan key:generate`
|
||||||
|
- Port already in use: Change `APP_PORT` in docker-compose
|
||||||
|
- Insufficient memory: Allocate at least 1GB RAM
|
||||||
|
|
||||||
|
### Database initialization fails
|
||||||
|
|
||||||
|
**Manually initialize:**
|
||||||
|
```bash
|
||||||
|
docker exec -it trip-planner-production sh
|
||||||
|
mysql_install_db --user=mysql --datadir=/var/lib/mysql
|
||||||
|
```
|
||||||
|
|
||||||
|
### Services not responding
|
||||||
|
|
||||||
|
**Check Supervisord status:**
|
||||||
|
```bash
|
||||||
|
docker exec trip-planner-production supervisorctl status
|
||||||
|
```
|
||||||
|
|
||||||
|
**Restart a service:**
|
||||||
|
```bash
|
||||||
|
docker exec trip-planner-production supervisorctl restart nginx
|
||||||
|
docker exec trip-planner-production supervisorctl restart php-fpm
|
||||||
|
```
|
||||||
|
|
||||||
|
### Permission errors
|
||||||
|
|
||||||
|
**Fix storage permissions:**
|
||||||
|
```bash
|
||||||
|
docker exec trip-planner-production chown -R appuser:appuser /var/www/html/storage
|
||||||
|
docker exec trip-planner-production chmod -R 775 /var/www/html/storage
|
||||||
|
```
|
||||||
|
|
||||||
|
### Health check failing
|
||||||
|
|
||||||
|
**Test manually:**
|
||||||
|
```bash
|
||||||
|
docker exec trip-planner-production curl -f http://localhost/health
|
||||||
|
|
||||||
|
# Check individual services
|
||||||
|
docker exec trip-planner-production supervisorctl status
|
||||||
|
```
|
||||||
|
|
||||||
|
### Performance issues
|
||||||
|
|
||||||
|
**Check resource usage:**
|
||||||
|
```bash
|
||||||
|
docker stats trip-planner-production
|
||||||
|
|
||||||
|
# Allocate more resources if needed (docker-compose)
|
||||||
|
# Add under 'app' service:
|
||||||
|
# deploy:
|
||||||
|
# resources:
|
||||||
|
# limits:
|
||||||
|
# memory: 2G
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing Production Locally
|
||||||
|
|
||||||
|
To test the production setup alongside your dev environment:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Production runs on port 8080 (default)
|
||||||
|
docker compose -f docker-compose.prod.yml up -d
|
||||||
|
|
||||||
|
# Dev runs on separate ports (5173, 8000, etc.)
|
||||||
|
docker compose -f docker-compose.dev.yml up -d
|
||||||
|
|
||||||
|
# Both can run simultaneously without conflicts
|
||||||
|
```
|
||||||
|
|
||||||
|
Access:
|
||||||
|
- Production: http://localhost:8080
|
||||||
|
- Development: http://localhost:5173
|
||||||
|
|
||||||
|
## Security Considerations
|
||||||
|
|
||||||
|
### Securing the Production Deployment
|
||||||
|
|
||||||
|
1. **Change default passwords** in `.env`
|
||||||
|
2. **Use strong APP_KEY** (generate with `php artisan key:generate`)
|
||||||
|
3. **Enable HTTPS** with a reverse proxy
|
||||||
|
4. **Firewall rules**: Only expose necessary ports
|
||||||
|
5. **Regular updates**: Pull latest images regularly
|
||||||
|
6. **Monitor logs**: Set up log aggregation
|
||||||
|
7. **Backup regularly**: Automate volume backups
|
||||||
|
|
||||||
|
### Environment Variable Security
|
||||||
|
|
||||||
|
**Never commit secrets to git!**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# .env files are gitignored
|
||||||
|
# Use a secrets manager for production
|
||||||
|
# Or use Docker secrets/Kubernetes secrets
|
||||||
|
```
|
||||||
|
|
||||||
|
## Performance Tips
|
||||||
|
|
||||||
|
- The image includes OPcache with aggressive caching
|
||||||
|
- Static assets are cached for 1 year
|
||||||
|
- Gzip compression is enabled
|
||||||
|
- Redis handles sessions and cache
|
||||||
|
- Database is optimized for InnoDB
|
||||||
|
|
||||||
|
For high-traffic scenarios:
|
||||||
|
- Run multiple container replicas behind a load balancer
|
||||||
|
- Use external managed database (RDS, etc.)
|
||||||
|
- Use external Redis cluster
|
||||||
|
- Configure CDN for static assets
|
||||||
|
|
||||||
|
## Differences from Development
|
||||||
|
|
||||||
|
| Feature | Development | Production |
|
||||||
|
|---------|------------|------------|
|
||||||
|
| Containers | 5 separate | 1 all-in-one |
|
||||||
|
| Code | Live mounted | Baked into image |
|
||||||
|
| Frontend | Vite dev server | Pre-built static files |
|
||||||
|
| Debugging | Enabled | Disabled |
|
||||||
|
| Caching | Minimal | Aggressive |
|
||||||
|
| Security | Relaxed | Hardened |
|
||||||
|
| Size | ~2GB+ | ~800MB |
|
||||||
|
|
||||||
|
## CI/CD Integration
|
||||||
|
|
||||||
|
Images are automatically built via Woodpecker CI on Codeberg:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# .woodpecker.yml extracts version from merge commits
|
||||||
|
# Example: merging release/v0.1.0 → tags 0.1.0 and latest
|
||||||
|
```
|
||||||
|
|
||||||
|
**Registry**: `codeberg.org/lvl0/trip-planner`
|
||||||
|
|
||||||
|
**Tags**:
|
||||||
|
- `latest`: Most recent build from main
|
||||||
|
- `0.1.0`, `0.2.0`, etc.: Version tags
|
||||||
|
|
||||||
|
## Need Help?
|
||||||
|
|
||||||
|
- Check the main [docker/README.md](../README.md)
|
||||||
|
- Review container logs: `docker logs trip-planner-production`
|
||||||
|
- Check service status: `docker exec trip-planner-production supervisorctl status`
|
||||||
|
- Inspect health: `docker inspect trip-planner-production`
|
||||||
66
docker/supervisord.conf
Normal file
66
docker/supervisord.conf
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
[unix_http_server]
|
||||||
|
file=/var/run/supervisor.sock
|
||||||
|
chmod=0700
|
||||||
|
|
||||||
|
[supervisorctl]
|
||||||
|
serverurl=unix:///var/run/supervisor.sock
|
||||||
|
|
||||||
|
[rpcinterface:supervisor]
|
||||||
|
supervisor.rpcinterface_factory = supervisor.rpcinterface:make_main_rpcinterface
|
||||||
|
|
||||||
|
[supervisord]
|
||||||
|
nodaemon=true
|
||||||
|
user=root
|
||||||
|
logfile=/var/log/supervisor/supervisord.log
|
||||||
|
pidfile=/var/run/supervisord.pid
|
||||||
|
loglevel=info
|
||||||
|
childlogdir=/var/log/supervisor
|
||||||
|
|
||||||
|
[program:mariadb]
|
||||||
|
command=/usr/bin/mysqld --user=mysql --datadir=/var/lib/mysql --bind-address=127.0.0.1 --port=3306
|
||||||
|
autostart=true
|
||||||
|
autorestart=true
|
||||||
|
priority=10
|
||||||
|
stopwaitsecs=30
|
||||||
|
stopsignal=TERM
|
||||||
|
stdout_logfile=/dev/stdout
|
||||||
|
stdout_logfile_maxbytes=0
|
||||||
|
stderr_logfile=/dev/stderr
|
||||||
|
stderr_logfile_maxbytes=0
|
||||||
|
user=mysql
|
||||||
|
|
||||||
|
[program:redis]
|
||||||
|
command=/usr/bin/redis-server --bind 127.0.0.1 --port 6379 --dir /data/redis --appendonly yes --logfile ""
|
||||||
|
autostart=true
|
||||||
|
autorestart=true
|
||||||
|
priority=20
|
||||||
|
stopwaitsecs=10
|
||||||
|
stopsignal=TERM
|
||||||
|
stdout_logfile=/var/log/supervisor/redis.log
|
||||||
|
stdout_logfile_maxbytes=10MB
|
||||||
|
stderr_logfile=/var/log/supervisor/redis-error.log
|
||||||
|
stderr_logfile_maxbytes=10MB
|
||||||
|
|
||||||
|
[program:php-fpm]
|
||||||
|
command=/usr/local/sbin/php-fpm -F
|
||||||
|
autostart=true
|
||||||
|
autorestart=true
|
||||||
|
priority=30
|
||||||
|
stopwaitsecs=10
|
||||||
|
stopsignal=QUIT
|
||||||
|
stdout_logfile=/var/log/supervisor/php-fpm.log
|
||||||
|
stdout_logfile_maxbytes=10MB
|
||||||
|
stderr_logfile=/var/log/supervisor/php-fpm-error.log
|
||||||
|
stderr_logfile_maxbytes=10MB
|
||||||
|
|
||||||
|
[program:nginx]
|
||||||
|
command=/usr/sbin/nginx -g "daemon off;"
|
||||||
|
autostart=true
|
||||||
|
autorestart=true
|
||||||
|
priority=40
|
||||||
|
stopwaitsecs=10
|
||||||
|
stopsignal=QUIT
|
||||||
|
stdout_logfile=/dev/stdout
|
||||||
|
stdout_logfile_maxbytes=0
|
||||||
|
stderr_logfile=/dev/stderr
|
||||||
|
stderr_logfile_maxbytes=0
|
||||||
51
frontend/.dockerignore
Normal file
51
frontend/.dockerignore
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
# Dependencies
|
||||||
|
node_modules/
|
||||||
|
package-lock.json
|
||||||
|
yarn.lock
|
||||||
|
pnpm-lock.yaml
|
||||||
|
|
||||||
|
# Development
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
.env.local
|
||||||
|
.env.development
|
||||||
|
|
||||||
|
# Build artifacts (will be rebuilt)
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
.vite/
|
||||||
|
|
||||||
|
# Testing
|
||||||
|
coverage/
|
||||||
|
.nyc_output/
|
||||||
|
|
||||||
|
# Development files
|
||||||
|
.git/
|
||||||
|
.github/
|
||||||
|
.gitignore
|
||||||
|
.editorconfig
|
||||||
|
.eslintrc*
|
||||||
|
.prettierrc*
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
|
# Documentation
|
||||||
|
README.md
|
||||||
|
CHANGELOG.md
|
||||||
|
LICENSE
|
||||||
|
|
||||||
|
# Docker files
|
||||||
|
Dockerfile*
|
||||||
|
docker-compose*.yml
|
||||||
|
.dockerignore
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
logs/
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
2
frontend/.env
Normal file
2
frontend/.env
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
VITE_API_URL=http://localhost:8000
|
||||||
|
VITE_API_BASE_URL=http://localhost:8000/api
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
346
frontend/package-lock.json
generated
346
frontend/package-lock.json
generated
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content::before {
|
||||||
|
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%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header {
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.card {
|
|
||||||
padding: 2em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.read-the-docs {
|
|
||||||
color: #888;
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
</>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
48
frontend/src/components/BaseModal.jsx
Normal file
48
frontend/src/components/BaseModal.jsx
Normal 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;
|
||||||
60
frontend/src/components/ConfirmDialog.jsx
Normal file
60
frontend/src/components/ConfirmDialog.jsx
Normal 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;
|
||||||
166
frontend/src/components/Dashboard.jsx
Normal file
166
frontend/src/components/Dashboard.jsx
Normal 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;
|
||||||
120
frontend/src/components/LoginForm.jsx
Normal file
120
frontend/src/components/LoginForm.jsx
Normal 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;
|
||||||
148
frontend/src/components/RegistrationForm.jsx
Normal file
148
frontend/src/components/RegistrationForm.jsx
Normal 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;
|
||||||
108
frontend/src/components/TripCard.jsx
Normal file
108
frontend/src/components/TripCard.jsx
Normal 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;
|
||||||
190
frontend/src/components/TripDetail.css
Normal file
190
frontend/src/components/TripDetail.css
Normal file
|
|
@ -0,0 +1,190 @@
|
||||||
|
.trip-detail {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: var(--color-bg-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Header */
|
||||||
|
.trip-detail-header {
|
||||||
|
background: var(--color-bg-primary);
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
padding: var(--spacing-md) var(--spacing-xl) var(--spacing-lg);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-nav {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-back {
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-xs);
|
||||||
|
transition: color var(--transition-normal);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-back:hover {
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-content h1 {
|
||||||
|
margin: 0 0 0.5rem 0;
|
||||||
|
font-size: 2rem;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.trip-description {
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
margin: 0 0 1rem 0;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trip-dates {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-label {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-value {
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-separator {
|
||||||
|
color: var(--color-text-light);
|
||||||
|
margin: 0 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Content Layout */
|
||||||
|
.trip-detail-content {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
gap: 0;
|
||||||
|
height: calc(100vh - var(--header-height));
|
||||||
|
}
|
||||||
|
|
||||||
|
.trip-detail-sidebar {
|
||||||
|
width: var(--sidebar-width);
|
||||||
|
background: var(--color-bg-primary);
|
||||||
|
border-right: 1px solid var(--color-border);
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trip-detail-main {
|
||||||
|
flex: 1;
|
||||||
|
padding: var(--spacing-xl);
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Calendar Placeholder */
|
||||||
|
.calendar-placeholder {
|
||||||
|
background: var(--color-bg-primary);
|
||||||
|
border-radius: var(--border-radius-lg);
|
||||||
|
padding: var(--spacing-xl);
|
||||||
|
text-align: center;
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-placeholder h2 {
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
margin: 0 0 1rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-placeholder p {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Loading State */
|
||||||
|
.trip-detail-loading {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 100vh;
|
||||||
|
gap: var(--spacing-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border: 3px solid #f3f3f3;
|
||||||
|
border-top: 3px solid #333;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
0% { transform: rotate(0deg); }
|
||||||
|
100% { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Error State */
|
||||||
|
.trip-detail-error {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 100vh;
|
||||||
|
text-align: center;
|
||||||
|
gap: var(--spacing-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.trip-detail-error h2 {
|
||||||
|
color: var(--color-danger);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trip-detail-error p {
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trip-detail-error .btn-back {
|
||||||
|
margin-top: 1rem;
|
||||||
|
padding: var(--spacing-sm) var(--spacing-md);
|
||||||
|
background: #333;
|
||||||
|
color: white;
|
||||||
|
text-decoration: none;
|
||||||
|
border-radius: var(--border-radius-sm);
|
||||||
|
transition: background var(--transition-normal);
|
||||||
|
}
|
||||||
|
|
||||||
|
.trip-detail-error .btn-back:hover {
|
||||||
|
background: #555;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.trip-detail-content {
|
||||||
|
flex-direction: column;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trip-detail-sidebar {
|
||||||
|
width: 100%;
|
||||||
|
border-right: none;
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
min-height: 400px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trip-detail-main {
|
||||||
|
padding: var(--spacing-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.trip-detail-header {
|
||||||
|
padding: var(--spacing-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-content h1 {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
95
frontend/src/components/TripDetail.jsx
Normal file
95
frontend/src/components/TripDetail.jsx
Normal file
|
|
@ -0,0 +1,95 @@
|
||||||
|
import { useState, useEffect, useMemo } from 'react';
|
||||||
|
import { useParams, useNavigate, Link } from 'react-router-dom';
|
||||||
|
import { formatDate } from '../utils/dateFormatter';
|
||||||
|
import { useTrip } from '../hooks/useTrip';
|
||||||
|
import PlannablesList from './plannables/PlannablesList';
|
||||||
|
import './TripDetail.css';
|
||||||
|
|
||||||
|
const TripDetail = () => {
|
||||||
|
const { id } = useParams();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [trip, setTrip] = useState(null);
|
||||||
|
const { fetchTrip, loading, error } = useTrip();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const loadTrip = async () => {
|
||||||
|
try {
|
||||||
|
const tripData = await fetchTrip(id);
|
||||||
|
setTrip(tripData);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error loading trip:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadTrip();
|
||||||
|
}, [id, fetchTrip]);
|
||||||
|
|
||||||
|
// Memoize trip dates display to prevent unnecessary re-renders
|
||||||
|
const tripDatesDisplay = useMemo(() => {
|
||||||
|
if (!trip) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="trip-dates">
|
||||||
|
<span className="date-label">Start:</span>
|
||||||
|
<span className="date-value">{formatDate(trip.start_date)}</span>
|
||||||
|
<span className="date-separator">•</span>
|
||||||
|
<span className="date-label">End:</span>
|
||||||
|
<span className="date-value">{formatDate(trip.end_date)}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}, [trip]);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="trip-detail-loading">
|
||||||
|
<div className="spinner"></div>
|
||||||
|
<p>Loading trip details...</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="trip-detail-error">
|
||||||
|
<h2>Error</h2>
|
||||||
|
<p>{error}</p>
|
||||||
|
<Link to="/" className="btn-back">Back to Dashboard</Link>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!trip) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="trip-detail">
|
||||||
|
<header className="trip-detail-header">
|
||||||
|
<div className="header-nav">
|
||||||
|
<Link to="/" className="btn-back">← Back to Dashboard</Link>
|
||||||
|
</div>
|
||||||
|
<div className="header-content">
|
||||||
|
<h1>{trip.name}</h1>
|
||||||
|
{trip.description && (
|
||||||
|
<p className="trip-description">{trip.description}</p>
|
||||||
|
)}
|
||||||
|
{tripDatesDisplay}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="trip-detail-content">
|
||||||
|
<div className="trip-detail-sidebar">
|
||||||
|
<PlannablesList tripId={trip.id} />
|
||||||
|
</div>
|
||||||
|
<div className="trip-detail-main">
|
||||||
|
<div className="calendar-placeholder">
|
||||||
|
<h2>Calendar View</h2>
|
||||||
|
<p>Calendar view will be implemented here in the future</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TripDetail;
|
||||||
54
frontend/src/components/TripList.jsx
Normal file
54
frontend/src/components/TripList.jsx
Normal 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;
|
||||||
175
frontend/src/components/TripModal.jsx
Normal file
175
frontend/src/components/TripModal.jsx
Normal 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;
|
||||||
74
frontend/src/components/auth/AuthGuard.jsx
Normal file
74
frontend/src/components/auth/AuthGuard.jsx
Normal 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;
|
||||||
26
frontend/src/components/auth/ProtectedRoute.jsx
Normal file
26
frontend/src/components/auth/ProtectedRoute.jsx
Normal 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;
|
||||||
118
frontend/src/components/common/ConfirmDialog.css
Normal file
118
frontend/src/components/common/ConfirmDialog.css
Normal file
|
|
@ -0,0 +1,118 @@
|
||||||
|
.confirm-dialog-overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: var(--color-bg-overlay);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: var(--z-toast);
|
||||||
|
animation: fadeIn var(--transition-normal);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from { opacity: 0; }
|
||||||
|
to { opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-dialog-modal {
|
||||||
|
background: var(--color-bg-primary);
|
||||||
|
border-radius: var(--border-radius-lg);
|
||||||
|
width: 90%;
|
||||||
|
max-width: 400px;
|
||||||
|
max-height: 90vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
animation: slideUp var(--transition-slow);
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideUp {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(20px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-dialog-header {
|
||||||
|
padding: var(--spacing-lg) var(--spacing-lg) 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-dialog-header h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-dialog-body {
|
||||||
|
padding: var(--spacing-md) var(--spacing-lg);
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-dialog-body p {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-dialog-footer {
|
||||||
|
padding: var(--spacing-md) var(--spacing-lg) var(--spacing-lg);
|
||||||
|
display: flex;
|
||||||
|
gap: var(--spacing-md);
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-dialog-footer .btn-secondary,
|
||||||
|
.confirm-dialog-footer .btn-primary {
|
||||||
|
padding: var(--spacing-sm) var(--spacing-lg);
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--border-radius-sm);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all var(--transition-normal);
|
||||||
|
min-width: 80px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-dialog-footer .btn-secondary {
|
||||||
|
background: var(--color-bg-secondary);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-dialog-footer .btn-secondary:hover {
|
||||||
|
background: var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-dialog-footer .btn-primary {
|
||||||
|
background: var(--color-primary);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-dialog-footer .btn-primary:hover {
|
||||||
|
background: var(--color-primary-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-dialog-footer .btn-primary.btn-danger {
|
||||||
|
background: var(--color-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-dialog-footer .btn-primary.btn-danger:hover {
|
||||||
|
background: var(--color-danger-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-dialog-footer .btn-primary:focus,
|
||||||
|
.confirm-dialog-footer .btn-secondary:focus {
|
||||||
|
outline: 2px solid var(--color-primary);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-dialog-footer .btn-primary.btn-danger:focus {
|
||||||
|
outline-color: var(--color-danger);
|
||||||
|
}
|
||||||
68
frontend/src/components/common/ConfirmDialog.jsx
Normal file
68
frontend/src/components/common/ConfirmDialog.jsx
Normal file
|
|
@ -0,0 +1,68 @@
|
||||||
|
import './ConfirmDialog.css';
|
||||||
|
|
||||||
|
const ConfirmDialog = ({
|
||||||
|
isOpen,
|
||||||
|
title,
|
||||||
|
message,
|
||||||
|
confirmText = 'Confirm',
|
||||||
|
cancelText = 'Cancel',
|
||||||
|
onConfirm,
|
||||||
|
onCancel,
|
||||||
|
variant = 'default' // 'default', 'danger'
|
||||||
|
}) => {
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
const handleOverlayClick = (e) => {
|
||||||
|
if (e.target === e.currentTarget) {
|
||||||
|
onCancel();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyDown = (e) => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
onCancel();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="confirm-dialog-overlay"
|
||||||
|
onClick={handleOverlayClick}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="confirm-dialog-title"
|
||||||
|
aria-describedby="confirm-dialog-message"
|
||||||
|
>
|
||||||
|
<div className="confirm-dialog-modal">
|
||||||
|
<div className="confirm-dialog-header">
|
||||||
|
<h3 id="confirm-dialog-title">{title}</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="confirm-dialog-body">
|
||||||
|
<p id="confirm-dialog-message">{message}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="confirm-dialog-footer">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn-secondary"
|
||||||
|
onClick={onCancel}
|
||||||
|
>
|
||||||
|
{cancelText}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`btn-primary ${variant === 'danger' ? 'btn-danger' : ''}`}
|
||||||
|
onClick={onConfirm}
|
||||||
|
autoFocus
|
||||||
|
>
|
||||||
|
{confirmText}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ConfirmDialog;
|
||||||
65
frontend/src/components/common/ModalErrorDisplay.css
Normal file
65
frontend/src/components/common/ModalErrorDisplay.css
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
.modal-error-display {
|
||||||
|
margin-bottom: var(--spacing-md);
|
||||||
|
animation: slideDown var(--transition-normal) ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideDown {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-10px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-error-content {
|
||||||
|
background: var(--color-danger-light);
|
||||||
|
border: 1px solid var(--color-danger);
|
||||||
|
border-radius: var(--border-radius-md);
|
||||||
|
padding: var(--spacing-md);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-error-icon {
|
||||||
|
font-size: var(--font-size-lg);
|
||||||
|
color: var(--color-danger);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-error-message {
|
||||||
|
color: var(--color-danger);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
flex: 1;
|
||||||
|
line-height: var(--line-height-normal);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-error-dismiss {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--color-danger);
|
||||||
|
font-size: var(--font-size-lg);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: var(--border-radius-sm);
|
||||||
|
transition: background-color var(--transition-fast);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-error-dismiss:hover {
|
||||||
|
background: rgba(244, 67, 54, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-error-dismiss:focus {
|
||||||
|
outline: 2px solid var(--color-danger);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
27
frontend/src/components/common/ModalErrorDisplay.jsx
Normal file
27
frontend/src/components/common/ModalErrorDisplay.jsx
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
import './ModalErrorDisplay.css';
|
||||||
|
|
||||||
|
const ModalErrorDisplay = ({ error, onDismiss }) => {
|
||||||
|
if (!error) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="modal-error-display">
|
||||||
|
<div className="modal-error-content">
|
||||||
|
<div className="modal-error-icon">⚠️</div>
|
||||||
|
<div className="modal-error-message">
|
||||||
|
{typeof error === 'string' ? error : error.message || 'An error occurred'}
|
||||||
|
</div>
|
||||||
|
{onDismiss && (
|
||||||
|
<button
|
||||||
|
className="modal-error-dismiss"
|
||||||
|
onClick={onDismiss}
|
||||||
|
aria-label="Dismiss error"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ModalErrorDisplay;
|
||||||
126
frontend/src/components/common/Toast.css
Normal file
126
frontend/src/components/common/Toast.css
Normal file
|
|
@ -0,0 +1,126 @@
|
||||||
|
.toast {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 1rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
background: white;
|
||||||
|
border-radius: 6px;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||||
|
border-left: 4px solid;
|
||||||
|
max-width: 400px;
|
||||||
|
animation: slideInRight 0.3s ease-out;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideInRight {
|
||||||
|
from {
|
||||||
|
transform: translateX(100%);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: translateX(0);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast.toast-exit {
|
||||||
|
animation: slideOutRight 0.3s ease-in;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideOutRight {
|
||||||
|
from {
|
||||||
|
transform: translateX(0);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: translateX(100%);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-success {
|
||||||
|
border-left-color: #4CAF50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-error {
|
||||||
|
border-left-color: #f44336;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-warning {
|
||||||
|
border-left-color: #ff9800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-info {
|
||||||
|
border-left-color: #2196F3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-icon {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: bold;
|
||||||
|
color: white;
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-top: 0.125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-success .toast-icon {
|
||||||
|
background: #4CAF50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-error .toast-icon {
|
||||||
|
background: #f44336;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-warning .toast-icon {
|
||||||
|
background: #ff9800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-info .toast-icon {
|
||||||
|
background: #2196F3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-content {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-content p {
|
||||||
|
margin: 0;
|
||||||
|
color: #333;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
line-height: 1.4;
|
||||||
|
word-wrap: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-close {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
color: #999;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 2px;
|
||||||
|
transition: all 0.2s;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-close:hover {
|
||||||
|
background: #f5f5f5;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-close:focus {
|
||||||
|
outline: 2px solid #4CAF50;
|
||||||
|
outline-offset: 1px;
|
||||||
|
}
|
||||||
69
frontend/src/components/common/Toast.jsx
Normal file
69
frontend/src/components/common/Toast.jsx
Normal file
|
|
@ -0,0 +1,69 @@
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import './Toast.css';
|
||||||
|
|
||||||
|
const Toast = ({
|
||||||
|
message,
|
||||||
|
type = 'info', // 'success', 'error', 'warning', 'info'
|
||||||
|
duration = 4000,
|
||||||
|
onClose
|
||||||
|
}) => {
|
||||||
|
const [isVisible, setIsVisible] = useState(true);
|
||||||
|
const [isExiting, setIsExiting] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
setIsExiting(true);
|
||||||
|
setTimeout(() => {
|
||||||
|
setIsVisible(false);
|
||||||
|
onClose?.();
|
||||||
|
}, 300); // Animation duration
|
||||||
|
}, duration);
|
||||||
|
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [duration, onClose]);
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
setIsExiting(true);
|
||||||
|
setTimeout(() => {
|
||||||
|
setIsVisible(false);
|
||||||
|
onClose?.();
|
||||||
|
}, 300);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isVisible) return null;
|
||||||
|
|
||||||
|
const getIcon = () => {
|
||||||
|
switch (type) {
|
||||||
|
case 'success': return '✓';
|
||||||
|
case 'error': return '✕';
|
||||||
|
case 'warning': return '⚠';
|
||||||
|
case 'info':
|
||||||
|
default: return 'ℹ';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`toast toast-${type} ${isExiting ? 'toast-exit' : ''}`}
|
||||||
|
role="alert"
|
||||||
|
aria-live="polite"
|
||||||
|
>
|
||||||
|
<div className="toast-icon">
|
||||||
|
{getIcon()}
|
||||||
|
</div>
|
||||||
|
<div className="toast-content">
|
||||||
|
<p>{message}</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="toast-close"
|
||||||
|
onClick={handleClose}
|
||||||
|
aria-label="Close notification"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Toast;
|
||||||
23
frontend/src/components/common/ToastContainer.css
Normal file
23
frontend/src/components/common/ToastContainer.css
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
.toast-container {
|
||||||
|
position: fixed;
|
||||||
|
top: 1rem;
|
||||||
|
right: 1rem;
|
||||||
|
z-index: 1000;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-container .toast {
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.toast-container {
|
||||||
|
top: 1rem;
|
||||||
|
left: 1rem;
|
||||||
|
right: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-container .toast {
|
||||||
|
max-width: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
69
frontend/src/components/common/ToastContainer.jsx
Normal file
69
frontend/src/components/common/ToastContainer.jsx
Normal file
|
|
@ -0,0 +1,69 @@
|
||||||
|
import { createContext, useContext, useState, useCallback } from 'react';
|
||||||
|
import Toast from './Toast';
|
||||||
|
import './ToastContainer.css';
|
||||||
|
|
||||||
|
const ToastContext = createContext();
|
||||||
|
|
||||||
|
export const useToast = () => {
|
||||||
|
const context = useContext(ToastContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useToast must be used within a ToastProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ToastProvider = ({ children }) => {
|
||||||
|
const [toasts, setToasts] = useState([]);
|
||||||
|
|
||||||
|
const addToast = useCallback((message, type = 'info', duration = 4000) => {
|
||||||
|
const id = Date.now() + Math.random();
|
||||||
|
const toast = { id, message, type, duration };
|
||||||
|
|
||||||
|
setToasts(prev => [...prev, toast]);
|
||||||
|
|
||||||
|
return id;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const removeToast = useCallback((id) => {
|
||||||
|
setToasts(prev => prev.filter(toast => toast.id !== id));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const showSuccess = useCallback((message, duration) => addToast(message, 'success', duration), [addToast]);
|
||||||
|
const showError = useCallback((message, duration) => addToast(message, 'error', duration), [addToast]);
|
||||||
|
const showWarning = useCallback((message, duration) => addToast(message, 'warning', duration), [addToast]);
|
||||||
|
const showInfo = useCallback((message, duration) => addToast(message, 'info', duration), [addToast]);
|
||||||
|
|
||||||
|
const value = {
|
||||||
|
addToast,
|
||||||
|
removeToast,
|
||||||
|
showSuccess,
|
||||||
|
showError,
|
||||||
|
showWarning,
|
||||||
|
showInfo
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ToastContext.Provider value={value}>
|
||||||
|
{children}
|
||||||
|
<ToastContainer toasts={toasts} onRemove={removeToast} />
|
||||||
|
</ToastContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const ToastContainer = ({ toasts, onRemove }) => {
|
||||||
|
if (toasts.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="toast-container">
|
||||||
|
{toasts.map(toast => (
|
||||||
|
<Toast
|
||||||
|
key={toast.id}
|
||||||
|
message={toast.message}
|
||||||
|
type={toast.type}
|
||||||
|
duration={toast.duration}
|
||||||
|
onClose={() => onRemove(toast.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
198
frontend/src/components/plannables/PlannableForm.css
Normal file
198
frontend/src/components/plannables/PlannableForm.css
Normal file
|
|
@ -0,0 +1,198 @@
|
||||||
|
.plannable-form-overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: var(--color-bg-overlay);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: var(--z-modal);
|
||||||
|
animation: fadeIn var(--transition-normal);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from { opacity: 0; }
|
||||||
|
to { opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.plannable-form-modal {
|
||||||
|
background: var(--color-bg-primary);
|
||||||
|
border-radius: var(--border-radius-lg);
|
||||||
|
width: 90%;
|
||||||
|
max-width: 600px;
|
||||||
|
max-height: 90vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
animation: slideUp var(--transition-slow);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideUp {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(20px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-header {
|
||||||
|
padding: var(--spacing-lg);
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-header h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-close {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
font-size: 2rem;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
line-height: 1;
|
||||||
|
padding: 0;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: var(--border-radius-sm);
|
||||||
|
transition: all var(--transition-normal);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-close:hover {
|
||||||
|
background: var(--color-bg-secondary);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.plannable-form {
|
||||||
|
padding: var(--spacing-lg);
|
||||||
|
overflow-y: auto;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: var(--spacing-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: var(--spacing-sm);
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-control {
|
||||||
|
width: 100%;
|
||||||
|
padding: var(--spacing-sm);
|
||||||
|
border: 1px solid var(--color-border-light);
|
||||||
|
border-radius: var(--border-radius-sm);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
transition: border-color var(--transition-normal);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-control:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
box-shadow: 0 0 0 2px rgba(76, 175, 80, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-control.error {
|
||||||
|
border-color: var(--color-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-control.error:focus {
|
||||||
|
box-shadow: 0 0 0 2px rgba(244, 67, 54, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea.form-control {
|
||||||
|
resize: vertical;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
select.form-control {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-message {
|
||||||
|
color: var(--color-danger);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
margin-top: var(--spacing-xs);
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-actions {
|
||||||
|
padding: var(--spacing-lg);
|
||||||
|
border-top: 1px solid var(--color-border);
|
||||||
|
display: flex;
|
||||||
|
gap: var(--spacing-md);
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary,
|
||||||
|
.btn-secondary {
|
||||||
|
padding: var(--spacing-sm) var(--spacing-lg);
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--border-radius-sm);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all var(--transition-normal);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: var(--color-primary);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover:not(:disabled) {
|
||||||
|
background: var(--color-primary-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
background: var(--color-bg-secondary);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:hover:not(:disabled) {
|
||||||
|
background: var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive */
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.plannable-form-modal {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
max-height: 100vh;
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plannable-form {
|
||||||
|
padding: var(--spacing-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-actions {
|
||||||
|
padding: var(--spacing-md);
|
||||||
|
}
|
||||||
|
}
|
||||||
203
frontend/src/components/plannables/PlannableForm.jsx
Normal file
203
frontend/src/components/plannables/PlannableForm.jsx
Normal file
|
|
@ -0,0 +1,203 @@
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import ModalErrorDisplay from '../common/ModalErrorDisplay';
|
||||||
|
import './PlannableForm.css';
|
||||||
|
|
||||||
|
const PlannableForm = ({ item, tripId, calendarSlots, onSubmit, onCancel }) => {
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
name: '',
|
||||||
|
type: 'attraction',
|
||||||
|
address: '',
|
||||||
|
notes: '',
|
||||||
|
calendar_slot_id: null
|
||||||
|
});
|
||||||
|
|
||||||
|
const [errors, setErrors] = useState({});
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [submitError, setSubmitError] = useState(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (item) {
|
||||||
|
setFormData({
|
||||||
|
name: item.name || '',
|
||||||
|
type: item.type || 'attraction',
|
||||||
|
address: item.address || '',
|
||||||
|
notes: item.notes || '',
|
||||||
|
calendar_slot_id: item.calendar_slot_id || null
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [item]);
|
||||||
|
|
||||||
|
const handleChange = (e) => {
|
||||||
|
const { name, value } = e.target;
|
||||||
|
setFormData(prev => ({
|
||||||
|
...prev,
|
||||||
|
[name]: value === '' ? null : value
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Clear error for this field
|
||||||
|
if (errors[name]) {
|
||||||
|
setErrors(prev => ({
|
||||||
|
...prev,
|
||||||
|
[name]: null
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear submit error when user starts typing
|
||||||
|
if (submitError) {
|
||||||
|
setSubmitError(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const validate = () => {
|
||||||
|
const newErrors = {};
|
||||||
|
|
||||||
|
if (!formData.name || formData.name.trim() === '') {
|
||||||
|
newErrors.name = 'Name is required';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!formData.type) {
|
||||||
|
newErrors.type = 'Type is required';
|
||||||
|
}
|
||||||
|
|
||||||
|
return newErrors;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const newErrors = validate();
|
||||||
|
if (Object.keys(newErrors).length > 0) {
|
||||||
|
setErrors(newErrors);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSubmitting(true);
|
||||||
|
setSubmitError(null);
|
||||||
|
try {
|
||||||
|
await onSubmit(formData);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Form submission error:', err);
|
||||||
|
setSubmitError(err.message || 'Failed to save item. Please try again.');
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="plannable-form-overlay">
|
||||||
|
<div className="plannable-form-modal">
|
||||||
|
<div className="form-header">
|
||||||
|
<h2>{item ? 'Edit Item' : 'Add New Item'}</h2>
|
||||||
|
<button className="btn-close" onClick={onCancel}>×</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="plannable-form">
|
||||||
|
<ModalErrorDisplay
|
||||||
|
error={submitError}
|
||||||
|
onDismiss={() => setSubmitError(null)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Name *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="name"
|
||||||
|
value={formData.name}
|
||||||
|
onChange={handleChange}
|
||||||
|
className={`form-control ${errors.name ? 'error' : ''}`}
|
||||||
|
placeholder="Enter item name"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
{errors.name && <span className="error-message">{errors.name}</span>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Type *</label>
|
||||||
|
<select
|
||||||
|
name="type"
|
||||||
|
value={formData.type}
|
||||||
|
onChange={handleChange}
|
||||||
|
className={`form-control ${errors.type ? 'error' : ''}`}
|
||||||
|
>
|
||||||
|
<option value="hotel">🏨 Hotel</option>
|
||||||
|
<option value="restaurant">🍽️ Restaurant</option>
|
||||||
|
<option value="attraction">🎯 Attraction</option>
|
||||||
|
<option value="transport">✈️ Transport</option>
|
||||||
|
<option value="activity">🎭 Activity</option>
|
||||||
|
</select>
|
||||||
|
{errors.type && <span className="error-message">{errors.type}</span>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Address</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="address"
|
||||||
|
value={formData.address}
|
||||||
|
onChange={handleChange}
|
||||||
|
className="form-control"
|
||||||
|
placeholder="Enter address (optional)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Assign to Day</label>
|
||||||
|
<select
|
||||||
|
name="calendar_slot_id"
|
||||||
|
value={formData.calendar_slot_id || ''}
|
||||||
|
onChange={handleChange}
|
||||||
|
className="form-control"
|
||||||
|
>
|
||||||
|
<option value="">Unplanned</option>
|
||||||
|
{calendarSlots.map(slot => {
|
||||||
|
const slotDate = new Date(slot.slot_date);
|
||||||
|
const dateStr = slotDate.toLocaleDateString('en-US', {
|
||||||
|
weekday: 'short',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric'
|
||||||
|
});
|
||||||
|
return (
|
||||||
|
<option key={slot.id} value={slot.id}>
|
||||||
|
{slot.name} - {dateStr}
|
||||||
|
</option>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Notes</label>
|
||||||
|
<textarea
|
||||||
|
name="notes"
|
||||||
|
value={formData.notes}
|
||||||
|
onChange={handleChange}
|
||||||
|
className="form-control"
|
||||||
|
rows="3"
|
||||||
|
placeholder="Add any additional notes (optional)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn-secondary"
|
||||||
|
onClick={onCancel}
|
||||||
|
disabled={submitting}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="btn-primary"
|
||||||
|
disabled={submitting}
|
||||||
|
>
|
||||||
|
{submitting ? 'Saving...' : (item ? 'Update' : 'Add')} Item
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PlannableForm;
|
||||||
99
frontend/src/components/plannables/PlannableItem.css
Normal file
99
frontend/src/components/plannables/PlannableItem.css
Normal file
|
|
@ -0,0 +1,99 @@
|
||||||
|
.plannable-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
margin: 0.5rem;
|
||||||
|
background: white;
|
||||||
|
border: 1px solid #e0e0e0;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: move;
|
||||||
|
transition: all 0.2s;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plannable-item:hover {
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-icon {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
line-height: 1;
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-top: 0.125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-content {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-name {
|
||||||
|
margin: 0 0 0.25rem 0;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #333;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-address {
|
||||||
|
margin: 0 0 0.25rem 0;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #666;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-notes {
|
||||||
|
margin: 0 0 0.25rem 0;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #888;
|
||||||
|
font-style: italic;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-type {
|
||||||
|
display: inline-block;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
text-transform: capitalize;
|
||||||
|
font-weight: 500;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-actions {
|
||||||
|
position: absolute;
|
||||||
|
top: 0.5rem;
|
||||||
|
right: 0.5rem;
|
||||||
|
display: flex;
|
||||||
|
gap: 0.25rem;
|
||||||
|
background: white;
|
||||||
|
padding: 0.25rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-action {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 1rem;
|
||||||
|
padding: 0.25rem;
|
||||||
|
line-height: 1;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-edit:hover {
|
||||||
|
background: #e3f2fd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-delete:hover {
|
||||||
|
background: #ffebee;
|
||||||
|
}
|
||||||
90
frontend/src/components/plannables/PlannableItem.jsx
Normal file
90
frontend/src/components/plannables/PlannableItem.jsx
Normal file
|
|
@ -0,0 +1,90 @@
|
||||||
|
import { useState, useCallback, useMemo, memo } from 'react';
|
||||||
|
import './PlannableItem.css';
|
||||||
|
|
||||||
|
const PlannableItem = memo(({ item, onEdit, onDelete }) => {
|
||||||
|
const [showActions, setShowActions] = useState(false);
|
||||||
|
|
||||||
|
const getTypeIcon = (type) => {
|
||||||
|
const icons = {
|
||||||
|
hotel: '🏨',
|
||||||
|
restaurant: '🍽️',
|
||||||
|
attraction: '🎯',
|
||||||
|
transport: '✈️',
|
||||||
|
activity: '🎭'
|
||||||
|
};
|
||||||
|
return icons[type] || '📍';
|
||||||
|
};
|
||||||
|
|
||||||
|
const getTypeColor = (type) => {
|
||||||
|
const colors = {
|
||||||
|
hotel: '#1976d2',
|
||||||
|
restaurant: '#f57c00',
|
||||||
|
attraction: '#388e3c',
|
||||||
|
transport: '#7b1fa2',
|
||||||
|
activity: '#d32f2f'
|
||||||
|
};
|
||||||
|
return colors[type] || '#666';
|
||||||
|
};
|
||||||
|
|
||||||
|
// Memoize handlers to prevent unnecessary re-renders
|
||||||
|
const handleEdit = useCallback((e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onEdit(item);
|
||||||
|
}, [onEdit, item]);
|
||||||
|
|
||||||
|
const handleDelete = useCallback((e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onDelete(item.id);
|
||||||
|
}, [onDelete, item.id]);
|
||||||
|
|
||||||
|
// Memoize style objects to prevent unnecessary re-renders
|
||||||
|
const typeStyle = useMemo(() => ({
|
||||||
|
color: getTypeColor(item.type)
|
||||||
|
}), [item.type]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="plannable-item"
|
||||||
|
onMouseEnter={() => setShowActions(true)}
|
||||||
|
onMouseLeave={() => setShowActions(false)}
|
||||||
|
>
|
||||||
|
<div className="item-icon" style={typeStyle}>
|
||||||
|
{getTypeIcon(item.type)}
|
||||||
|
</div>
|
||||||
|
<div className="item-content">
|
||||||
|
<h4 className="item-name">{item.name}</h4>
|
||||||
|
{item.address && (
|
||||||
|
<p className="item-address">{item.address}</p>
|
||||||
|
)}
|
||||||
|
{item.notes && (
|
||||||
|
<p className="item-notes">{item.notes}</p>
|
||||||
|
)}
|
||||||
|
<span className="item-type" style={typeStyle}>
|
||||||
|
{item.type}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{showActions && (
|
||||||
|
<div className="item-actions">
|
||||||
|
<button
|
||||||
|
className="btn-action btn-edit"
|
||||||
|
onClick={handleEdit}
|
||||||
|
title="Edit"
|
||||||
|
>
|
||||||
|
✏️
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn-action btn-delete"
|
||||||
|
onClick={handleDelete}
|
||||||
|
title="Delete"
|
||||||
|
>
|
||||||
|
🗑️
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
PlannableItem.displayName = 'PlannableItem';
|
||||||
|
|
||||||
|
export default PlannableItem;
|
||||||
115
frontend/src/components/plannables/PlannablesList.css
Normal file
115
frontend/src/components/plannables/PlannablesList.css
Normal file
|
|
@ -0,0 +1,115 @@
|
||||||
|
.plannables-list {
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plannables-header {
|
||||||
|
padding: var(--spacing-lg);
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
background: var(--color-bg-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.plannables-header h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-add-item {
|
||||||
|
padding: var(--spacing-sm) var(--spacing-md);
|
||||||
|
background: var(--color-primary);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--border-radius-sm);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: background var(--transition-normal);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-add-item:hover {
|
||||||
|
background: var(--color-primary-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.plannables-loading {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 3rem;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner-small {
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
border: 3px solid #f3f3f3;
|
||||||
|
border-top: 3px solid #333;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plannables-error {
|
||||||
|
background: var(--color-danger-light);
|
||||||
|
color: var(--color-danger);
|
||||||
|
padding: var(--spacing-md);
|
||||||
|
margin: 1rem;
|
||||||
|
border-radius: var(--border-radius-sm);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plannables-sections {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plannables-section {
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
padding: var(--spacing-md) var(--spacing-lg);
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #555;
|
||||||
|
background: var(--color-bg-secondary);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-date {
|
||||||
|
font-weight: 400;
|
||||||
|
color: #888;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-count {
|
||||||
|
background: #e0e0e0;
|
||||||
|
color: #666;
|
||||||
|
padding: var(--spacing-xs) var(--spacing-sm);
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 500;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-items {
|
||||||
|
padding: var(--spacing-sm);
|
||||||
|
min-height: 60px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-message {
|
||||||
|
color: #999;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
padding: var(--spacing-md);
|
||||||
|
text-align: center;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
258
frontend/src/components/plannables/PlannablesList.jsx
Normal file
258
frontend/src/components/plannables/PlannablesList.jsx
Normal file
|
|
@ -0,0 +1,258 @@
|
||||||
|
import { useState, useEffect, useMemo } from 'react';
|
||||||
|
import { useToast } from '../common/ToastContainer';
|
||||||
|
import { usePlannables } from '../../hooks/usePlannables';
|
||||||
|
import PlannableItem from './PlannableItem';
|
||||||
|
import PlannableForm from './PlannableForm';
|
||||||
|
import ConfirmDialog from '../common/ConfirmDialog';
|
||||||
|
import './PlannablesList.css';
|
||||||
|
|
||||||
|
const PlannablesList = ({ tripId }) => {
|
||||||
|
const { showSuccess, showError } = useToast();
|
||||||
|
const {
|
||||||
|
fetchBothData,
|
||||||
|
createPlannable,
|
||||||
|
updatePlannable,
|
||||||
|
deletePlannable,
|
||||||
|
loading: apiLoading
|
||||||
|
} = usePlannables();
|
||||||
|
|
||||||
|
const [plannables, setPlannables] = useState([]);
|
||||||
|
const [calendarSlots, setCalendarSlots] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [showForm, setShowForm] = useState(false);
|
||||||
|
const [editingItem, setEditingItem] = useState(null);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
const [confirmDialog, setConfirmDialog] = useState({
|
||||||
|
isOpen: false,
|
||||||
|
title: '',
|
||||||
|
message: '',
|
||||||
|
onConfirm: null
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const loadData = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const { plannables, calendarSlots, errors } = await fetchBothData(tripId);
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
if (errors.plannables) {
|
||||||
|
console.error('Failed to fetch plannables:', errors.plannables);
|
||||||
|
showError('Failed to load plannable items');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (errors.calendarSlots) {
|
||||||
|
console.error('Failed to fetch calendar slots:', errors.calendarSlots);
|
||||||
|
showError('Failed to load calendar slots');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError('Failed to load data');
|
||||||
|
console.error(err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadData();
|
||||||
|
}, [tripId, fetchBothData, showError]);
|
||||||
|
|
||||||
|
const handleAddItem = () => {
|
||||||
|
setEditingItem(null);
|
||||||
|
setShowForm(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEditItem = (item) => {
|
||||||
|
setEditingItem(item);
|
||||||
|
setShowForm(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteItem = (itemId) => {
|
||||||
|
const item = plannables.find(p => p.id === itemId);
|
||||||
|
|
||||||
|
setConfirmDialog({
|
||||||
|
isOpen: true,
|
||||||
|
title: 'Delete Item',
|
||||||
|
message: `Are you sure you want to delete "${item?.name}"? This action cannot be undone.`,
|
||||||
|
onConfirm: () => performDelete(itemId)
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const performDelete = async (itemId) => {
|
||||||
|
try {
|
||||||
|
await deletePlannable(itemId);
|
||||||
|
setPlannables(plannables.filter(item => item.id !== itemId));
|
||||||
|
showSuccess('Item deleted successfully');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error deleting item:', err);
|
||||||
|
showError('Failed to delete item. Please try again.');
|
||||||
|
} finally {
|
||||||
|
setConfirmDialog({ isOpen: false, title: '', message: '', onConfirm: null });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFormSubmit = async (formData) => {
|
||||||
|
try {
|
||||||
|
const isEditing = !!editingItem;
|
||||||
|
let savedItem;
|
||||||
|
|
||||||
|
if (isEditing) {
|
||||||
|
savedItem = await updatePlannable(editingItem.id, formData);
|
||||||
|
setPlannables(plannables.map(item =>
|
||||||
|
item.id === editingItem.id ? savedItem : item
|
||||||
|
));
|
||||||
|
showSuccess('Item updated successfully');
|
||||||
|
} else {
|
||||||
|
savedItem = await createPlannable(tripId, formData);
|
||||||
|
setPlannables([...plannables, savedItem]);
|
||||||
|
showSuccess('Item added successfully');
|
||||||
|
}
|
||||||
|
|
||||||
|
setShowForm(false);
|
||||||
|
setEditingItem(null);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error saving item:', err);
|
||||||
|
showError('Failed to save item. Please try again.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFormCancel = () => {
|
||||||
|
setShowForm(false);
|
||||||
|
setEditingItem(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Memoize expensive grouping computation to prevent recalculation on every render
|
||||||
|
const { unplannedItems, plannedItemsBySlot } = useMemo(() => {
|
||||||
|
const unplanned = plannables.filter(item => !item.calendar_slot_id);
|
||||||
|
const planned = {};
|
||||||
|
|
||||||
|
plannables.forEach(item => {
|
||||||
|
if (item.calendar_slot_id) {
|
||||||
|
if (!planned[item.calendar_slot_id]) {
|
||||||
|
planned[item.calendar_slot_id] = [];
|
||||||
|
}
|
||||||
|
planned[item.calendar_slot_id].push(item);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return { unplannedItems: unplanned, plannedItemsBySlot: planned };
|
||||||
|
}, [plannables]);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="plannables-loading">
|
||||||
|
<div className="spinner-small"></div>
|
||||||
|
<p>Loading items...</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="plannables-list">
|
||||||
|
<div className="plannables-header">
|
||||||
|
<h2>Itinerary Items</h2>
|
||||||
|
<button className="btn-add-item" onClick={handleAddItem}>
|
||||||
|
+ Add Item
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="plannables-error">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="plannables-sections">
|
||||||
|
{/* Unplanned Items Section */}
|
||||||
|
<div className="plannables-section">
|
||||||
|
<h3 className="section-title">
|
||||||
|
📋 Unplanned Items
|
||||||
|
{unplannedItems.length > 0 && (
|
||||||
|
<span className="item-count">{unplannedItems.length}</span>
|
||||||
|
)}
|
||||||
|
</h3>
|
||||||
|
<div className="section-items">
|
||||||
|
{unplannedItems.length === 0 ? (
|
||||||
|
<p className="empty-message">No unplanned items</p>
|
||||||
|
) : (
|
||||||
|
unplannedItems.map(item => (
|
||||||
|
<PlannableItem
|
||||||
|
key={item.id}
|
||||||
|
item={item}
|
||||||
|
onEdit={handleEditItem}
|
||||||
|
onDelete={handleDeleteItem}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Calendar Slots Sections */}
|
||||||
|
{calendarSlots.map(slot => {
|
||||||
|
const items = plannedItemsBySlot[slot.id] || [];
|
||||||
|
const slotDate = new Date(slot.slot_date);
|
||||||
|
const dayName = slotDate.toLocaleDateString('en-US', { weekday: 'long' });
|
||||||
|
const dateStr = slotDate.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={slot.id} className="plannables-section">
|
||||||
|
<h3 className="section-title">
|
||||||
|
📅 {slot.name}
|
||||||
|
<span className="section-date">{dayName}, {dateStr}</span>
|
||||||
|
{items.length > 0 && (
|
||||||
|
<span className="item-count">{items.length}</span>
|
||||||
|
)}
|
||||||
|
</h3>
|
||||||
|
<div className="section-items">
|
||||||
|
{items.length === 0 ? (
|
||||||
|
<p className="empty-message">No items planned for this day</p>
|
||||||
|
) : (
|
||||||
|
items.map(item => (
|
||||||
|
<PlannableItem
|
||||||
|
key={item.id}
|
||||||
|
item={item}
|
||||||
|
onEdit={handleEditItem}
|
||||||
|
onDelete={handleDeleteItem}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showForm && (
|
||||||
|
<PlannableForm
|
||||||
|
item={editingItem}
|
||||||
|
tripId={tripId}
|
||||||
|
calendarSlots={calendarSlots}
|
||||||
|
onSubmit={handleFormSubmit}
|
||||||
|
onCancel={handleFormCancel}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
isOpen={confirmDialog.isOpen}
|
||||||
|
title={confirmDialog.title}
|
||||||
|
message={confirmDialog.message}
|
||||||
|
confirmText="Delete"
|
||||||
|
cancelText="Cancel"
|
||||||
|
variant="danger"
|
||||||
|
onConfirm={confirmDialog.onConfirm}
|
||||||
|
onCancel={() => setConfirmDialog({ isOpen: false, title: '', message: '', onConfirm: null })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PlannablesList;
|
||||||
87
frontend/src/contexts/AuthContext.jsx
Normal file
87
frontend/src/contexts/AuthContext.jsx
Normal file
|
|
@ -0,0 +1,87 @@
|
||||||
|
import { createContext, useContext, useState, useEffect } from 'react';
|
||||||
|
import api from '../utils/api';
|
||||||
|
|
||||||
|
const AuthContext = createContext();
|
||||||
|
|
||||||
|
export const useAuth = () => {
|
||||||
|
const context = useContext(AuthContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useAuth must be used within an AuthProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AuthProvider = ({ children }) => {
|
||||||
|
const [user, setUser] = useState(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const initializeAuth = () => {
|
||||||
|
const token = localStorage.getItem('token');
|
||||||
|
const userData = localStorage.getItem('user');
|
||||||
|
|
||||||
|
if (token && userData) {
|
||||||
|
try {
|
||||||
|
const parsedUser = JSON.parse(userData);
|
||||||
|
setUser(parsedUser);
|
||||||
|
setIsAuthenticated(true);
|
||||||
|
|
||||||
|
// Token will be automatically added by API interceptor
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error parsing user data:', error);
|
||||||
|
logout();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setIsLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
initializeAuth();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const login = (userData) => {
|
||||||
|
const { user: userInfo, access_token } = userData;
|
||||||
|
|
||||||
|
setUser(userInfo);
|
||||||
|
setIsAuthenticated(true);
|
||||||
|
|
||||||
|
localStorage.setItem('token', access_token);
|
||||||
|
localStorage.setItem('user', JSON.stringify(userInfo));
|
||||||
|
};
|
||||||
|
|
||||||
|
const logout = async () => {
|
||||||
|
try {
|
||||||
|
const token = localStorage.getItem('token');
|
||||||
|
if (token) {
|
||||||
|
await api.post('/logout');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Logout error:', error);
|
||||||
|
} finally {
|
||||||
|
setUser(null);
|
||||||
|
setIsAuthenticated(false);
|
||||||
|
|
||||||
|
localStorage.removeItem('token');
|
||||||
|
localStorage.removeItem('user');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const register = (userData) => {
|
||||||
|
login(userData);
|
||||||
|
};
|
||||||
|
|
||||||
|
const value = {
|
||||||
|
user,
|
||||||
|
isLoading,
|
||||||
|
isAuthenticated,
|
||||||
|
login,
|
||||||
|
logout,
|
||||||
|
register
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthContext.Provider value={value}>
|
||||||
|
{children}
|
||||||
|
</AuthContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
35
frontend/src/hooks/useApiCall.js
Normal file
35
frontend/src/hooks/useApiCall.js
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
import { useCallback } from 'react';
|
||||||
|
import { useAuthToken } from './useAuthToken';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to create memoized API call function with authentication
|
||||||
|
* Centralizes API error handling and authentication headers
|
||||||
|
*/
|
||||||
|
export const useApiCall = () => {
|
||||||
|
const token = useAuthToken();
|
||||||
|
|
||||||
|
return useCallback(async (url, options = {}) => {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${token}`,
|
||||||
|
'Accept': 'application/json',
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...options.headers,
|
||||||
|
},
|
||||||
|
...options,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json().catch(() => ({}));
|
||||||
|
const errorMessage = errorData.message || `API call failed: ${response.status} ${response.statusText}`;
|
||||||
|
throw new Error(errorMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle 204 No Content responses
|
||||||
|
if (response.status === 204) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json();
|
||||||
|
}, [token]);
|
||||||
|
};
|
||||||
9
frontend/src/hooks/useAuthToken.js
Normal file
9
frontend/src/hooks/useAuthToken.js
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to get cached authentication token
|
||||||
|
* Avoids repeated localStorage access
|
||||||
|
*/
|
||||||
|
export const useAuthToken = () => {
|
||||||
|
return useMemo(() => localStorage.getItem('token'), []);
|
||||||
|
};
|
||||||
84
frontend/src/hooks/usePlannables.js
Normal file
84
frontend/src/hooks/usePlannables.js
Normal file
|
|
@ -0,0 +1,84 @@
|
||||||
|
import { useState, useCallback } from 'react';
|
||||||
|
import { useApiCall } from './useApiCall';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook for plannable items API operations
|
||||||
|
* Centralizes CRUD operations for plannable items
|
||||||
|
*/
|
||||||
|
export const usePlannables = () => {
|
||||||
|
const apiCall = useApiCall();
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const fetchPlannables = useCallback(async (tripId) => {
|
||||||
|
const response = await apiCall(`${import.meta.env.VITE_API_URL}/api/trips/${tripId}/plannables`);
|
||||||
|
return response.data || [];
|
||||||
|
}, [apiCall]);
|
||||||
|
|
||||||
|
const fetchCalendarSlots = useCallback(async (tripId) => {
|
||||||
|
const response = await apiCall(`${import.meta.env.VITE_API_URL}/api/trips/${tripId}/calendar-slots`);
|
||||||
|
return response.data || [];
|
||||||
|
}, [apiCall]);
|
||||||
|
|
||||||
|
const createPlannable = useCallback(async (tripId, data) => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await apiCall(`${import.meta.env.VITE_API_URL}/api/trips/${tripId}/plannables`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(data)
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [apiCall]);
|
||||||
|
|
||||||
|
const updatePlannable = useCallback(async (plannableId, data) => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await apiCall(`${import.meta.env.VITE_API_URL}/api/plannables/${plannableId}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(data)
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [apiCall]);
|
||||||
|
|
||||||
|
const deletePlannable = useCallback(async (plannableId) => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
await apiCall(`${import.meta.env.VITE_API_URL}/api/plannables/${plannableId}`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [apiCall]);
|
||||||
|
|
||||||
|
const fetchBothData = useCallback(async (tripId) => {
|
||||||
|
const [plannablesResult, slotsResult] = await Promise.allSettled([
|
||||||
|
fetchPlannables(tripId),
|
||||||
|
fetchCalendarSlots(tripId)
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
plannables: plannablesResult.status === 'fulfilled' ? plannablesResult.value : [],
|
||||||
|
calendarSlots: slotsResult.status === 'fulfilled' ? slotsResult.value : [],
|
||||||
|
errors: {
|
||||||
|
plannables: plannablesResult.status === 'rejected' ? plannablesResult.reason : null,
|
||||||
|
calendarSlots: slotsResult.status === 'rejected' ? slotsResult.reason : null
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [fetchPlannables, fetchCalendarSlots]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
fetchPlannables,
|
||||||
|
fetchCalendarSlots,
|
||||||
|
fetchBothData,
|
||||||
|
createPlannable,
|
||||||
|
updatePlannable,
|
||||||
|
deletePlannable,
|
||||||
|
loading
|
||||||
|
};
|
||||||
|
};
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue