diff --git a/app/Http/Controllers/AchievementController.php b/app/Http/Controllers/AchievementController.php new file mode 100644 index 0000000..325ecb2 --- /dev/null +++ b/app/Http/Controllers/AchievementController.php @@ -0,0 +1,33 @@ +get() + ->groupBy(fn(Achievement $a) => $a->category->name) + ->map(fn($group) => $group->sortBy('id')->values()); + + $userAchievements = $user->achievements() + ->with('achievement') + ->orderBy('achievement_id') + ->get() + ->keyBy('achievement_id'); + + return Inertia::render('UserAchievements', [ + 'user' => $user, + 'canEdit' => auth()->id() === $user->id, + 'isFollowing' => auth()->check() && auth()->user()->isFollowing($user), + 'achievements' => $achievements, + 'userAchievements' => $userAchievements, + ]); + } +} diff --git a/app/Http/Controllers/FlightController.php b/app/Http/Controllers/FlightController.php index 82d67f7..e98d0ae 100644 --- a/app/Http/Controllers/FlightController.php +++ b/app/Http/Controllers/FlightController.php @@ -85,7 +85,6 @@ class FlightController extends Controller UserAction::create([ 'user_id' => $flight->user_id, - 'user_flight_id' => $flight->id, 'data' => [ 'changes' => $changes, 'original' => $original, @@ -174,29 +173,14 @@ class FlightController extends Controller 'user_id' => $newFlight->user_id, 'type' => $newFlight->departure_date->isFuture() ? 'flight_booked' : 'flight_logged', 'data' => [ - 'flight' => $this->flightSnapshot($newFlight->id), + 'flight' => $newFlight->snapshot($newFlight->id), ], ]); return redirect()->route('profile.departure-board', [Auth::user()->name, $newFlight->id]); } - private function flightSnapshot(int $id): array - { - return UserFlight::with([ - 'departureAirport', - 'departureAirport.region.country', - 'arrivalAirport', - 'arrivalAirport.region.country', - 'aircraft', - 'airline', - 'airline.country', - 'flightClass', - 'seatType', - 'flightReason', - 'crewType', - ])->find($id)->toArray(); - } + public function update(Request $request, UserFlight $flight) @@ -212,11 +196,11 @@ class FlightController extends Controller } $dirty = $flight->getDirty(); - $original = $this->flightSnapshot($flight->id); + $original = $flight->snapshot($flight->id); $flight->save(); - $updated = $this->flightSnapshot($flight->id); + $updated = $flight->snapshot($flight->id); $this->recordChanges($flight, $dirty, $original, $updated); return redirect()->route('profile.departure-board', [Auth::user()->name, $flight->id]); diff --git a/app/Http/Controllers/FlightImportController.php b/app/Http/Controllers/FlightImportController.php index 788e45f..d1e36fb 100644 --- a/app/Http/Controllers/FlightImportController.php +++ b/app/Http/Controllers/FlightImportController.php @@ -9,6 +9,7 @@ use App\Models\FlightClass; use App\Models\FlightReason; use App\Models\ImportedFlight; use App\Models\SeatType; +use App\Models\UserAction; use App\Models\UserFlight; use Carbon\Carbon; use Illuminate\Http\Request; @@ -128,10 +129,11 @@ class FlightImportController extends Controller }) ->orderByDesc('active') ->limit(10) - ->get(['id', 'name', 'IATA_code', 'ICAO_code']) + ->get(['id', 'name', 'IATA_code', 'ICAO_code', 'internal_name']) ->map(fn($airline) => [ 'value' => $airline->id, 'title' => $airline->display_name, + 'logo_url' => $airline->logo_url, ]) ->values() ->toArray(); @@ -243,7 +245,7 @@ class FlightImportController extends Controller $arrival = $durationArrival; } - UserFlight::create([ + $newFlight = UserFlight::create([ 'user_id' => $user->id, 'departure_date' => $departure, 'arrival_date' => $arrival, @@ -260,6 +262,14 @@ class FlightImportController extends Controller 'note' => $validated['note'] ?? null, ]); + UserAction::create([ + 'user_id' => $newFlight->user_id, + 'type' => $newFlight->departure_date->isFuture() ? 'flight_booked' : 'flight_imported', + 'data' => [ + 'flight' => $newFlight->snapshot($newFlight->id), + ], + ]); + ImportedFlight::destroy($validated['imported_flight_id']); return to_route('reconcile'); } diff --git a/app/Http/Controllers/FlightProfileController.php b/app/Http/Controllers/FlightProfileController.php index 3536296..001facc 100644 --- a/app/Http/Controllers/FlightProfileController.php +++ b/app/Http/Controllers/FlightProfileController.php @@ -11,13 +11,12 @@ use Inertia\Inertia; class FlightProfileController extends Controller { public function profileData(User $user, string $view, ?int $selectedFlightId = null) : array { - $flights = $user->FlightController()->flights(); return [ 'user' => $user, 'canEdit' => auth()->check() && auth()->id() === $user->id, - 'flights' => UserFlightResource::collection($flights)->resolve(), 'initialView' => $view, 'selectedFlightId' => $selectedFlightId, + 'flight_api_url' => '/data/user/'.$user->name.'/flights', 'isFollowing' => auth()->check() && auth()->user()->isFollowing($user), ]; } diff --git a/app/Http/Controllers/NotificationController.php b/app/Http/Controllers/NotificationController.php new file mode 100644 index 0000000..15d4c62 --- /dev/null +++ b/app/Http/Controllers/NotificationController.php @@ -0,0 +1,17 @@ +authorize('update', $notification); + $notification->markAsRead(); + } +} diff --git a/app/Http/Controllers/UserFlightController.php b/app/Http/Controllers/UserFlightController.php index 538c665..78c9c53 100644 --- a/app/Http/Controllers/UserFlightController.php +++ b/app/Http/Controllers/UserFlightController.php @@ -23,6 +23,7 @@ class UserFlightController extends Controller 'arrivalAirport.region.country', 'arrivalAirport.region.continent', 'airline.country', + 'airline.alliance', 'aircraft', 'seatType', 'flightReason', diff --git a/app/Http/Middleware/HandleInertiaRequests.php b/app/Http/Middleware/HandleInertiaRequests.php index fd0a01c..39f6043 100644 --- a/app/Http/Middleware/HandleInertiaRequests.php +++ b/app/Http/Middleware/HandleInertiaRequests.php @@ -34,8 +34,16 @@ class HandleInertiaRequests extends Middleware 'logo_api_url' => config('app.logo_api_url'), 'auth' => [ 'user' => $request->user(), - 'isLoggedIn' => $request->user() !== null, ], + 'achievement_notifications' => fn() => $request->user() + ? $request->user() + ->notifications() + ->with('achievement') + ->where('is_achievement', true) + ->whereNull('read_at') + ->latest() + ->get() + : [], ]; } } diff --git a/app/Models/User.php b/app/Models/User.php index 8822cc7..f5691b2 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -2,18 +2,16 @@ namespace App\Models; -// use Illuminate\Contracts\Auth\MustVerifyEmail; use App\Http\Controllers\UserFlightController; use Database\Factories\UserFactory; use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Attributes\Hidden; use Illuminate\Database\Eloquent\Factories\HasFactory; -use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Foundation\Auth\User as Authenticatable; -use Illuminate\Notifications\Notifiable; - use App\Traits\HasAchievements; +use App\Models\Notification; +use Laravel\Sanctum\HasApiTokens; #[Fillable(['name', 'email', 'password'])] #[Hidden(['password', 'remember_token'])] @@ -21,7 +19,9 @@ class User extends Authenticatable { /** @use HasFactory */ - use HasFactory, Notifiable, HasAchievements; + use HasFactory, HasAchievements, HasApiTokens; + + /** * Get the attributes that should be cast. @@ -36,6 +36,11 @@ class User extends Authenticatable ]; } + public function achievements(): HasMany + { + return $this->hasMany(UserAchievement::class); + } + public function resolveRouteBinding($value, $field = null): ?User { return $this->where('name', 'ilike', $value)->firstOrFail(); @@ -69,4 +74,9 @@ class User extends Authenticatable { return $this->following()->where('followee_id', $user->id)->exists(); } + + public function notifications(): HasMany + { + return $this->hasMany(Notification::class); + } } diff --git a/app/Models/UserAction.php b/app/Models/UserAction.php index e4ed715..5592368 100644 --- a/app/Models/UserAction.php +++ b/app/Models/UserAction.php @@ -30,7 +30,7 @@ class UserAction extends Model 'flight_booked' => 'Flight Booked', 'flight_cancelled' => 'Flight Cancelled', 'flight_updated' => 'Flight Updated', - 'flight_imported' => 'Flight Imported', + 'flight_imported' => 'Flight Imported from FR24', 'flight_logged' => 'Flight Logged', 'flight_deleted' => 'Flight Deleted', default => 'Unknown Action' diff --git a/app/Models/UserFlight.php b/app/Models/UserFlight.php index 18afadf..234abe2 100644 --- a/app/Models/UserFlight.php +++ b/app/Models/UserFlight.php @@ -156,6 +156,23 @@ class UserFlight extends Model return !$this->isDomestic(); } + public static function snapshot($userFlightId){ + return UserFlight::with([ + 'departureAirport', + 'departureAirport.region.country', + 'arrivalAirport', + 'arrivalAirport.region.country', + 'aircraft', + 'airline', + 'airline.country', + 'airline.alliance', + 'flightClass', + 'seatType', + 'flightReason', + 'crewType', + ])->find($userFlightId)->toArray(); + } + public function user(): BelongsTo { return $this->belongsTo(User::class); diff --git a/app/Policies/NotificationPolicy.php b/app/Policies/NotificationPolicy.php new file mode 100644 index 0000000..6a823dc --- /dev/null +++ b/app/Policies/NotificationPolicy.php @@ -0,0 +1,66 @@ +id === $notification->user_id; + } + + /** + * Determine whether the user can delete the model. + */ + public function delete(User $user, Notification $notification): bool + { + return false; + } + + /** + * Determine whether the user can restore the model. + */ + public function restore(User $user, Notification $notification): bool + { + return false; + } + + /** + * Determine whether the user can permanently delete the model. + */ + public function forceDelete(User $user, Notification $notification): bool + { + return false; + } +} diff --git a/app/Services/Achievements/AchievementService.php b/app/Services/Achievements/AchievementService.php index 1eeb7ec..f3f03c4 100644 --- a/app/Services/Achievements/AchievementService.php +++ b/app/Services/Achievements/AchievementService.php @@ -55,6 +55,8 @@ class AchievementService 'airline.alliance', 'aircraft', 'flightClass', + 'departureAirport.region', + 'arrivalAirport.region', 'departureAirport.region.continent', 'arrivalAirport.region.continent', ])->get(); @@ -151,8 +153,8 @@ class AchievementService { Notification::create([ 'user_id' => $user->id, - 'title' => 'Achievement Unlocked!', - 'body' => "You've earned the \"{$achievement->name}\" achievement — {$achievement->short_description}", + 'title' => $achievement->name, + 'body' => $achievement->short_description, 'is_achievement' => true, 'achievement_id' => $achievement->id, 'expires_at' => null, diff --git a/app/Services/Achievements/Checkers/AirlinesAndAlliancesChecker.php b/app/Services/Achievements/Checkers/AirlinesAndAlliancesChecker.php index a45c95f..73b6e30 100644 --- a/app/Services/Achievements/Checkers/AirlinesAndAlliancesChecker.php +++ b/app/Services/Achievements/Checkers/AirlinesAndAlliancesChecker.php @@ -5,9 +5,14 @@ namespace App\Services\Achievements\Checkers; use App\Models\Alliance; use App\Models\User; use App\Models\UserFlight; +use Illuminate\Support\Collection; class AirlinesAndAlliancesChecker extends BaseChecker { + const array US_3 = ['american-airlines', 'delta', 'united-airlines']; + const array ME_3 = ['emirates', 'etihad-airways', 'qatar-airways']; + const array CN_3 = ['china-southern-airlines', 'china-eastern', 'air-china']; + public function check(): void { $flights = $this->flights(); @@ -25,5 +30,16 @@ class AirlinesAndAlliancesChecker extends BaseChecker $this->awardProgress($check('oneworld'), 'airlines_alliances.all_oneworld'); $this->awardProgress($check('star_alliance'), 'airlines_alliances.all_star_alliance'); $this->awardProgress($check('vanilla_alliance'), 'airlines_alliances.all_vanilla_alliance'); + + $flownAirlines = $flights + ->pluck('airline.internal_name') + ->filter() + ->unique(); + + $checkGroup = fn(array $group): int => $flownAirlines->intersect($group)->count(); + + $this->awardProgress($checkGroup(self::ME_3), 'airlines_alliances.me3'); + $this->awardProgress($checkGroup(self::US_3), 'airlines_alliances.us3'); + $this->awardProgress($checkGroup(self::CN_3), 'airlines_alliances.cn3'); } } diff --git a/app/Services/Achievements/Checkers/FunChallengesChecker.php b/app/Services/Achievements/Checkers/FunChallengesChecker.php index b9fe288..21ff4b7 100644 --- a/app/Services/Achievements/Checkers/FunChallengesChecker.php +++ b/app/Services/Achievements/Checkers/FunChallengesChecker.php @@ -3,27 +3,60 @@ namespace App\Services\Achievements\Checkers; use App\Models\User; +use App\Models\UserFlight; class FunChallengesChecker extends BaseChecker { + const array US_STATES = [ + 'US-AK', 'US-AL', 'US-AR', 'US-AZ', 'US-CA', 'US-CO', 'US-CT', 'US-DC', + 'US-DE', 'US-FL', 'US-GA', 'US-HI', 'US-IA', 'US-ID', 'US-IL', 'US-IN', + 'US-KS', 'US-KY', 'US-LA', 'US-MA', 'US-MD', 'US-ME', 'US-MI', 'US-MN', + 'US-MO', 'US-MS', 'US-MT', 'US-NC', 'US-ND', 'US-NE', 'US-NH', 'US-NJ', + 'US-NM', 'US-NV', 'US-NY', 'US-OH', 'US-OK', 'US-OR', 'US-PA', 'US-RI', + 'US-SC', 'US-SD', 'US-TN', 'US-TX', 'US-UT', 'US-VA', 'US-VT', 'US-WA', + 'US-WI', 'US-WV', 'US-WY', + ]; + + const array AUSTRALIAN_STATES = [ + 'AU-ACT', 'AU-NSW', 'AU-NT', 'AU-QLD', 'AU-SA', 'AU-TAS', 'AU-VIC', 'AU-WA', + ]; + + const array CHINESE_PROVINCES = [ + 'CN-11', 'CN-12', 'CN-13', 'CN-14', 'CN-15', 'CN-21', 'CN-22', 'CN-23', + 'CN-31', 'CN-32', 'CN-33', 'CN-34', 'CN-35', 'CN-36', 'CN-37', 'CN-41', + 'CN-42', 'CN-43', 'CN-44', 'CN-45', 'CN-46', 'CN-50', 'CN-51', 'CN-52', + 'CN-53', 'CN-54', 'CN-61', 'CN-62', 'CN-63', 'CN-64', 'CN-65', + + ]; + + const array BRAZILIAN_STATES = [ + 'BR-AC', 'BR-AL', 'BR-AM', 'BR-AP', 'BR-BA', 'BR-CE', 'BR-DF', 'BR-ES', + 'BR-GO', 'BR-MA', 'BR-MG', 'BR-MS', 'BR-MT', 'BR-PA', 'BR-PB', 'BR-PE', + 'BR-PI', 'BR-PR', 'BR-RJ', 'BR-RN', 'BR-RO', 'BR-RR', 'BR-RS', 'BR-SC', + 'BR-SE', 'BR-SP', 'BR-TO', + ]; + + const array CANADIAN_PROVINCES = [ + 'CA-AB', 'CA-BC', 'CA-MB', 'CA-NB', 'CA-NL', 'CA-NS', 'CA-NT', + 'CA-NU', 'CA-ON', 'CA-PE', 'CA-QC', 'CA-SK', 'CA-YT', + ]; + public function check(): void { $flights = $this->flights(); $airlineLetters = $flights - ->filter(fn($f) => $f->airline?->IATA_code !== null) - ->map(fn($f) => strtoupper($f->airline->IATA_code[0])) + ->filter(fn(UserFlight $f) => $f->airline?->IATA_code !== null) + ->map(fn(UserFlight $f) => strtoupper($f->airline->IATA_code[0])) ->filter(fn($letter) => ctype_alpha($letter)) ->unique() ->count(); $this->awardProgress($airlineLetters, 'fun_challenges.airline_alphabet'); - // --- Visit the Alphabet --- - // Collect first letters from both departure and arrival airport IATA codes $airportLetters = $flights - ->flatMap(fn($f) => [ + ->flatMap(fn(UserFlight $f) => [ $f->departureAirport?->iata_code, $f->arrivalAirport?->iata_code, ]) @@ -33,5 +66,70 @@ class FunChallengesChecker extends BaseChecker ->count(); $this->awardProgress($airportLetters, 'fun_challenges.airport_alphabet'); + + // --- US States --- + $visitedUsStates = $flights + ->flatMap(fn(UserFlight $f) => [ + $f->departureAirport?->region?->code, + $f->arrivalAirport?->region?->code, + ]) + ->filter() + ->filter(fn($code) => in_array($code, self::US_STATES)) + ->unique() + ->count(); + + $this->awardProgress($visitedUsStates, 'fun_challenges.us_states'); + + // --- Australian States --- + $visitedAustralianStates = $flights + ->flatMap(fn(UserFlight $f) => [ + $f->departureAirport?->region?->code, + $f->arrivalAirport?->region?->code, + ]) + ->filter() + ->filter(fn($code) => in_array($code, self::AUSTRALIAN_STATES)) + ->unique() + ->count(); + + $this->awardProgress($visitedAustralianStates, 'fun_challenges.australian_states'); + + // --- Chinese Provinces --- + $visitedChineseProvinces = $flights + ->flatMap(fn(UserFlight $f) => [ + $f->departureAirport?->region?->code, + $f->arrivalAirport?->region?->code, + ]) + ->filter() + ->filter(fn($code) => in_array($code, self::CHINESE_PROVINCES)) + ->unique() + ->count(); + + $this->awardProgress($visitedChineseProvinces, 'fun_challenges.chinese_provinces'); + + // --- Brazilian States --- + $visitedBrazilianStates = $flights + ->flatMap(fn(UserFlight $f) => [ + $f->departureAirport?->region?->code, + $f->arrivalAirport?->region?->code, + ]) + ->filter() + ->filter(fn($code) => in_array($code, self::BRAZILIAN_STATES)) + ->unique() + ->count(); + + $this->awardProgress($visitedBrazilianStates, 'fun_challenges.brazilian_states'); + + // --- Canadian Provinces --- + $visitedCanadianProvinces = $flights + ->flatMap(fn(UserFlight $f) => [ + $f->departureAirport?->region?->code, + $f->arrivalAirport?->region?->code, + ]) + ->filter() + ->filter(fn($code) => in_array($code, self::CANADIAN_PROVINCES)) + ->unique() + ->count(); + + $this->awardProgress($visitedCanadianProvinces, 'fun_challenges.canadian_provinces'); } } diff --git a/app/Services/Achievements/Checkers/GeneralFlyingChecker.php b/app/Services/Achievements/Checkers/GeneralFlyingChecker.php index d126a31..9db9479 100644 --- a/app/Services/Achievements/Checkers/GeneralFlyingChecker.php +++ b/app/Services/Achievements/Checkers/GeneralFlyingChecker.php @@ -71,6 +71,7 @@ class GeneralFlyingChecker extends BaseChecker // --- Progressive achievements --- $this->awardProgress($count,'general_flying.10_flights'); + $this->awardProgress($count,'general_flying.50_flights'); $this->awardProgress($count,'general_flying.100_flights'); $this->awardProgress($count,'general_flying.500_flights'); $this->awardProgress($count,'general_flying.1000_flights'); diff --git a/bootstrap/app.php b/bootstrap/app.php index 5c02a59..0e21cf2 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -7,6 +7,7 @@ use Illuminate\Foundation\Configuration\Middleware; return Application::configure(basePath: dirname(__DIR__)) ->withRouting( web: __DIR__.'/../routes/web.php', + api: __DIR__.'/../routes/api.php', commands: __DIR__.'/../routes/console.php', health: '/up', ) diff --git a/composer.json b/composer.json index 725ab98..e290b5a 100644 --- a/composer.json +++ b/composer.json @@ -10,12 +10,12 @@ "license": "MIT", "require": { "php": "^8.4", + "ext-pdo": "*", "inertiajs/inertia-laravel": "^2.0", "laravel/framework": "^13.0", "laravel/sanctum": "^4.0", "laravel/tinker": "^3.0", - "tightenco/ziggy": "^2.0", - "ext-pdo": "*" + "tightenco/ziggy": "^2.0" }, "require-dev": { "fakerphp/faker": "^1.23", diff --git a/composer.lock b/composer.lock index 4ac12e2..1feb9f9 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "1378206c681cc15470824af157a889be", + "content-hash": "0e560320885031dd36bb08bb44fe05d4", "packages": [ { "name": "brick/math", @@ -9369,7 +9369,8 @@ "prefer-stable": true, "prefer-lowest": false, "platform": { - "php": "^8.3" + "php": "^8.4", + "ext-pdo": "*" }, "platform-dev": {}, "plugin-api-version": "2.9.0" diff --git a/config/sanctum.php b/config/sanctum.php new file mode 100644 index 0000000..44527d6 --- /dev/null +++ b/config/sanctum.php @@ -0,0 +1,84 @@ + 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, + ], + +]; diff --git a/database/migrations/2026_04_26_030253_build_achievement_system.php b/database/migrations/2026_04_26_030253_build_achievement_system.php index d1e3a7c..3d67df2 100644 --- a/database/migrations/2026_04_26_030253_build_achievement_system.php +++ b/database/migrations/2026_04_26_030253_build_achievement_system.php @@ -47,14 +47,14 @@ return new class extends Migration [ 'internal_name' => 'expensive', 'name' => 'Expensive', - 'description' => 'It might be hard, but it will be easier if you have a lot of money!', + 'description' => 'Not necessarily difficult, but it might cost you a bit.', 'created_at' => now(), 'updated_at' => now(), ], [ 'internal_name' => 'near_impossible', 'name' => 'Near-Impossible', - 'description' => 'You will actively have to try to get this achievement and they may be very few ways to go about it.', + 'description' => 'You will actively have to try to get this achievement and ther may be very few ways to go about it.', 'created_at' => now(), 'updated_at' => now(), ], @@ -337,6 +337,66 @@ return new class extends Migration 'achievement_category_id' => $generalFlying, 'achievement_difficulty_id'=> $easy, ], + [ + 'internal_name' => 'general_flying.10_flights', + 'name' => 'Frequent Flyer in Training', + 'short_description' => 'Log 10 flights.', + 'long_description' => '', + 'icon' => $icon, + 'progressive' => true, + 'threshold' => 10, + 'difficulty_description' => null, + 'achievement_category_id' => $generalFlying, + 'achievement_difficulty_id'=> $easy, + ], + [ + 'internal_name' => 'general_flying.50_flights', + 'name' => 'Certified Frequent Flyer', + 'short_description' => 'Log 50 flights.', + 'long_description' => '', + 'icon' => $icon, + 'progressive' => true, + 'threshold' => 50, + 'difficulty_description' => null, + 'achievement_category_id' => $generalFlying, + 'achievement_difficulty_id'=> $moderate, + ], + [ + 'internal_name' => 'general_flying.100_flights', + 'name' => 'Century in the Sky', + 'short_description' => 'Log 100 flights.', + 'long_description' => '', + 'icon' => $icon, + 'progressive' => true, + 'threshold' => 100, + 'difficulty_description' => null, + 'achievement_category_id' => $generalFlying, + 'achievement_difficulty_id'=> $moderate, + ], + [ + 'internal_name' => 'general_flying.500_flights', + 'name' => 'The 500 Club', + 'short_description' => 'Log 500 flights.', + 'long_description' => '', + 'icon' => $icon, + 'progressive' => true, + 'threshold' => 500, + 'difficulty_description' => null, + 'achievement_category_id' => $generalFlying, + 'achievement_difficulty_id'=> $hard, + ], + [ + 'internal_name' => 'general_flying.1000_flights', + 'name' => 'Skybound for Life', + 'short_description' => 'Log 1,000 flights.', + 'long_description' => '', + 'icon' => $icon, + 'progressive' => true, + 'threshold' => 1000, + 'difficulty_description' => null, + 'achievement_category_id' => $generalFlying, + 'achievement_difficulty_id'=> $nearImposs, + ], [ 'internal_name' => 'general_flying.domestic_flight', 'name' => 'Home Turf', @@ -436,7 +496,7 @@ return new class extends Migration [ 'internal_name' => 'general_flying.domestic_two_countries', 'name' => 'Local Explorer', - 'short_description' => 'Take domestic flights in two different countries.', + 'short_description' => 'Take a domestic flight in two different countries.', 'long_description' => '', 'icon' => $icon, 'progressive' => false, @@ -445,54 +505,6 @@ return new class extends Migration 'achievement_category_id' => $generalFlying, 'achievement_difficulty_id'=> $moderate, ], - [ - 'internal_name' => 'general_flying.10_flights', - 'name' => 'Frequent Flyer in Training', - 'short_description' => 'Log 10 flights.', - 'long_description' => '', - 'icon' => $icon, - 'progressive' => true, - 'threshold' => 10, - 'difficulty_description' => null, - 'achievement_category_id' => $generalFlying, - 'achievement_difficulty_id'=> $easy, - ], - [ - 'internal_name' => 'general_flying.100_flights', - 'name' => 'Century in the Sky', - 'short_description' => 'Log 100 flights.', - 'long_description' => '', - 'icon' => $icon, - 'progressive' => true, - 'threshold' => 100, - 'difficulty_description' => null, - 'achievement_category_id' => $generalFlying, - 'achievement_difficulty_id'=> $moderate, - ], - [ - 'internal_name' => 'general_flying.500_flights', - 'name' => 'The 500 Club', - 'short_description' => 'Log 500 flights.', - 'long_description' => '', - 'icon' => $icon, - 'progressive' => true, - 'threshold' => 500, - 'difficulty_description' => null, - 'achievement_category_id' => $generalFlying, - 'achievement_difficulty_id'=> $hard, - ], - [ - 'internal_name' => 'general_flying.1000_flights', - 'name' => 'Skybound for Life', - 'short_description' => 'Log 1,000 flights.', - 'long_description' => '', - 'icon' => $icon, - 'progressive' => true, - 'threshold' => 1000, - 'difficulty_description' => null, - 'achievement_category_id' => $generalFlying, - 'achievement_difficulty_id'=> $expensive, - ], // ----------------------------------------------------------- // Countries & Continents @@ -745,6 +757,42 @@ return new class extends Migration // ----------------------------------------------------------- // Airlines & Alliances // ----------------------------------------------------------- + [ + 'internal_name' => 'airlines_alliances.us3', + 'name' => 'Stars, Stripes & Status', + 'short_description' => 'Fly the big 3 US Airlines (Delta, American and United)', + 'long_description' => '', + 'icon' => $icon, + 'progressive' => true, + 'threshold' => 3, + 'difficulty_description' => null, + 'achievement_category_id' => $airlinesAndAlliances, + 'achievement_difficulty_id'=> $moderate, + ], + [ + 'internal_name' => 'airlines_alliances.me3', + 'name' => 'Gulf Goals', + 'short_description' => 'Fly with the Middle East 3 (Etihad, Emirates and Qatar Airways)', + 'long_description' => '', + 'icon' => $icon, + 'progressive' => true, + 'threshold' => 3, + 'difficulty_description' => null, + 'achievement_category_id' => $airlinesAndAlliances, + 'achievement_difficulty_id'=> $hard, + ], + [ + 'internal_name' => 'airlines_alliances.cn3', + 'name' => 'Three Kingdoms of the Sky', + 'short_description' => 'Fly with the Chinese Big 3 Airlines (China Eastern, China Southern and Air China)', + 'long_description' => '', + 'icon' => $icon, + 'progressive' => true, + 'threshold' => 3, + 'difficulty_description' => null, + 'achievement_category_id' => $airlinesAndAlliances, + 'achievement_difficulty_id'=> $moderate, + ], [ 'internal_name' => 'airlines_alliances.all_skyteam', 'name' => 'Team Player', @@ -821,6 +869,66 @@ return new class extends Migration 'achievement_category_id' => $funChallenges, 'achievement_difficulty_id' => $nearImposs, ], + [ + 'internal_name' => 'fun_challenges.brazilian_states', + 'name' => 'The 27 Club', + 'short_description' => 'Visit all Brazilian States. Plus the Distrito Federal!', + 'long_description' => '', + 'icon' => $icon, + 'progressive' => true, + 'threshold' => 27, + 'difficulty_description' => '', + 'achievement_category_id' => $funChallenges, + 'achievement_difficulty_id' => $hard, + ], + [ + 'internal_name' => 'fun_challenges.us_states', + 'name' => 'The Big Five-O', + 'short_description' => 'Fly to all 50 US States plus the District of Colombia', + 'long_description' => '', + 'icon' => $icon, + 'progressive' => true, + 'threshold' => 51, + 'difficulty_description' => 'Hard at the best of times. Harder when nobody is flying to Delaware', + 'achievement_category_id' => $funChallenges, + 'achievement_difficulty_id' => $hard, + ], + [ + 'internal_name' => 'fun_challenges.australian_states', + 'name' => 'Yeah Nah, Been There', + 'short_description' => 'Fly to all Australian States and Territories', + 'long_description' => '', + 'icon' => $icon, + 'progressive' => true, + 'threshold' => 8, + 'difficulty_description' => 'Only counting internal territories - the NT and ACT.', + 'achievement_category_id' => $funChallenges, + 'achievement_difficulty_id' => $hard, + ], + [ + 'internal_name' => 'fun_challenges.canadian_provinces', + 'name' => 'All done, eh?', + 'short_description' => 'Fly to all 10 provinces and 3 territories of Canada', + 'long_description' => '', + 'icon' => $icon, + 'progressive' => true, + 'threshold' => 13, + 'difficulty_description' => 'Hard at the best of times. Harder when nobody is flying to Delaware', + 'achievement_category_id' => $funChallenges, + 'achievement_difficulty_id' => $hard, + ], + [ + 'internal_name' => 'fun_challenges.chinese_provinces', + 'name' => 'Province of Mind', + 'short_description' => 'Fly to all Chinese Provinces and Autonomous Zones', + 'long_description' => '', + 'icon' => $icon, + 'progressive' => true, + 'threshold' => 31, + 'difficulty_description' => 'Cities like Tianjin, Shanghai and Beijing are their own zones! Flights that are treated as domestic Chinese destinations only - excluding Macau, Hong Kong and Taiwan.', + 'achievement_category_id' => $funChallenges, + 'achievement_difficulty_id' => $hard, + ], // --------------------------------------------------------------- // Countries & Continents diff --git a/database/migrations/2026_04_26_102003_create_personal_access_tokens_table.php b/database/migrations/2026_04_26_102003_create_personal_access_tokens_table.php new file mode 100644 index 0000000..40ff706 --- /dev/null +++ b/database/migrations/2026_04_26_102003_create_personal_access_tokens_table.php @@ -0,0 +1,33 @@ +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'); + } +}; diff --git a/public/img/alliances/oneworld.svg b/public/img/alliances/oneworld.svg new file mode 100644 index 0000000..0dc6c28 --- /dev/null +++ b/public/img/alliances/oneworld.svg @@ -0,0 +1,161 @@ + + diff --git a/public/img/alliances/skyteam.svg b/public/img/alliances/skyteam.svg new file mode 100644 index 0000000..d39d2ed --- /dev/null +++ b/public/img/alliances/skyteam.svg @@ -0,0 +1,21 @@ + + + + + + + + + diff --git a/public/img/alliances/star_alliance.svg b/public/img/alliances/star_alliance.svg new file mode 100644 index 0000000..a16ac7c --- /dev/null +++ b/public/img/alliances/star_alliance.svg @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/sounds/seatBelt.wav b/public/sounds/seatBelt.wav new file mode 100644 index 0000000..a104326 Binary files /dev/null and b/public/sounds/seatBelt.wav differ diff --git a/resources/css/app.css b/resources/css/app.css index e3d5e05..367f383 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -111,6 +111,12 @@ body { color: #c49dff; } +.class-premium_economy-global { + background: rgba(75, 32, 137, 0.35); + border: 1px solid rgba(180, 130, 255, 0.25); + color: #c49dff; +} + /* ── Business: Gold ── */ .class-business-global { background: rgba(184, 134, 11, 0.15); @@ -205,3 +211,38 @@ body { color: #445566; } +.difficulty-easy-global { + background: rgba(34, 139, 34, 0.2); + border: 1px solid rgba(100, 200, 100, 0.25); + color: #7ec87e; +} + +.difficulty-moderate-global { + background: rgba(30, 100, 200, 0.2); + border: 1px solid rgba(80, 150, 255, 0.25); + color: #7eb8ff; +} + +.difficulty-hard-global { + background: rgba(200, 80, 30, 0.2); + border: 1px solid rgba(255, 130, 80, 0.25); + color: #ff9b6a; +} + +.difficulty-expensive-global { + background: rgba(180, 60, 180, 0.2); + border: 1px solid rgba(220, 120, 220, 0.25); + color: #df8fdf; +} + +.difficulty-near-impossible-global { + background: rgba(160, 20, 20, 0.25); + border: 1px solid rgba(220, 60, 60, 0.3); + color: #e87070; +} + +.difficulty-impossible-global { + background: rgba(10, 10, 10, 0.5); + border: 1px solid rgba(120, 120, 140, 0.3); + color: #888899; +} diff --git a/resources/js/Components/FlightsGoneBy/AchievementCard.vue b/resources/js/Components/FlightsGoneBy/AchievementCard.vue new file mode 100644 index 0000000..fe8a7be --- /dev/null +++ b/resources/js/Components/FlightsGoneBy/AchievementCard.vue @@ -0,0 +1,198 @@ + + + + + + diff --git a/resources/js/Components/FlightsGoneBy/AirlineLogo.vue b/resources/js/Components/FlightsGoneBy/AirlineLogo.vue index 64396f7..88c55b4 100644 --- a/resources/js/Components/FlightsGoneBy/AirlineLogo.vue +++ b/resources/js/Components/FlightsGoneBy/AirlineLogo.vue @@ -4,6 +4,7 @@ import {computed} from "vue"; import {usePage} from "@inertiajs/vue3"; import GlassTooltip from "@/Components/FlightsGoneBy/GlassTooltip.vue"; import InlineBadge from "@/Components/FlightsGoneBy/InlineBadge.vue"; +import AllianceLogo from "@/Components/FlightsGoneBy/AllianceLogo.vue"; const page = usePage().props; @@ -39,6 +40,7 @@ const size = computed(() => props.size ? props.size + 'px' : '30px');
{{ airline.IATA_code }} {{ airline.ICAO_code }} +
diff --git a/resources/js/Components/FlightsGoneBy/AllianceLogo.vue b/resources/js/Components/FlightsGoneBy/AllianceLogo.vue new file mode 100644 index 0000000..769f6e1 --- /dev/null +++ b/resources/js/Components/FlightsGoneBy/AllianceLogo.vue @@ -0,0 +1,32 @@ + + + + + diff --git a/resources/js/Components/FlightsGoneBy/DepartureBoard.vue b/resources/js/Components/FlightsGoneBy/DepartureBoard.vue index f94cd32..86dccf4 100644 --- a/resources/js/Components/FlightsGoneBy/DepartureBoard.vue +++ b/resources/js/Components/FlightsGoneBy/DepartureBoard.vue @@ -12,6 +12,7 @@ import {FlightStats} from "@/Composables/useFlightStats"; import GlassTooltip from "@/Components/FlightsGoneBy/GlassTooltip.vue"; import CrewTooltip from "@/Components/FlightsGoneBy/CrewTooltip.vue"; import {Link, router} from "@inertiajs/vue3"; +import AllianceLogo from "@/Components/FlightsGoneBy/AllianceLogo.vue"; const props = defineProps<{ flightStats: FlightStats diff --git a/resources/js/Components/FlightsGoneBy/Feed/FeedItem.vue b/resources/js/Components/FlightsGoneBy/Feed/FeedItem.vue index db0aecd..28cbd00 100644 --- a/resources/js/Components/FlightsGoneBy/Feed/FeedItem.vue +++ b/resources/js/Components/FlightsGoneBy/Feed/FeedItem.vue @@ -11,6 +11,7 @@ import {computed} from "vue"; import {Link} from "@inertiajs/vue3"; import FlightUpdatedFeedItem from "@/Components/FlightsGoneBy/Feed/FlightUpdatedFeedItem.vue"; import FlightCancelledFeedItem from "@/Components/FlightsGoneBy/Feed/FlightCancelledFeedItem.vue"; +import FlightImportedFeedItem from "@/Components/FlightsGoneBy/Feed/FlightImportedFeedItem.vue"; const props = defineProps<{ action: UserAction @@ -30,6 +31,7 @@ const badgeVariant = computed(() => { switch (props.action.type) { case 'flight_booked': return 'generic' case 'flight_logged': return 'generic' + case 'flight_imported': return 'economy' case 'flight_updated': return 'economy' case 'flight_cancelled': return 'crew' default: return 'economy' @@ -69,6 +71,7 @@ function timeAgo(dateStr: string): string {
+
diff --git a/resources/js/Components/FlightsGoneBy/Feed/FlightImportedFeedItem.vue b/resources/js/Components/FlightsGoneBy/Feed/FlightImportedFeedItem.vue new file mode 100644 index 0000000..db2b95c --- /dev/null +++ b/resources/js/Components/FlightsGoneBy/Feed/FlightImportedFeedItem.vue @@ -0,0 +1,48 @@ + + + + + diff --git a/resources/js/Components/FlightsGoneBy/InlineBadge.vue b/resources/js/Components/FlightsGoneBy/InlineBadge.vue index 246d84b..2186b53 100644 --- a/resources/js/Components/FlightsGoneBy/InlineBadge.vue +++ b/resources/js/Components/FlightsGoneBy/InlineBadge.vue @@ -1,13 +1,16 @@ diff --git a/resources/js/Components/FlightsGoneBy/ProfileHeader.vue b/resources/js/Components/FlightsGoneBy/ProfileHeader.vue index 5428e2c..1060809 100644 --- a/resources/js/Components/FlightsGoneBy/ProfileHeader.vue +++ b/resources/js/Components/FlightsGoneBy/ProfileHeader.vue @@ -5,7 +5,8 @@ import type { Flight, User, SharedProps } from "@/Types/types"; const props = defineProps<{ user: User - flights: Flight[] + flightCount?: number + achievementCount?: number isFollowing?: boolean }>() @@ -55,8 +56,8 @@ const follow = async () => {
- {{ flights.length }} - FLIGHTS + {{ flightCount ?? achievementCount }} + {{flightCount ? 'Flights' : 'Achievements'}}
@@ -149,5 +150,6 @@ const follow = async () => { font-size: 0.65rem; letter-spacing: 0.18em; color: #556; + text-transform: uppercase; } diff --git a/resources/js/Components/FlightsGoneBy/ProfileLayout.vue b/resources/js/Components/FlightsGoneBy/ProfileLayout.vue index bc6db48..f6c3cd9 100644 --- a/resources/js/Components/FlightsGoneBy/ProfileLayout.vue +++ b/resources/js/Components/FlightsGoneBy/ProfileLayout.vue @@ -1,18 +1,24 @@ @@ -24,6 +30,27 @@ defineProps<{ width: 100%; } +.loading-state { + display: flex; + justify-content: center; + align-items: center; + min-height: 50dvh; +} + +.spinner { + display: block; + width: 2.5rem; + height: 2.5rem; + border: 3px solid rgba(255, 255, 255, 0.2); + border-top-color: white; + border-radius: 50%; + animation: spin 0.7s linear infinite; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + @media (max-width: 1200px) { .board-wrapper { padding: 1em 0.25em; diff --git a/resources/js/Components/FlightsGoneBy/ProfileViewSwitcher.vue b/resources/js/Components/FlightsGoneBy/ProfileViewSwitcher.vue index 401c6c2..745e250 100644 --- a/resources/js/Components/FlightsGoneBy/ProfileViewSwitcher.vue +++ b/resources/js/Components/FlightsGoneBy/ProfileViewSwitcher.vue @@ -1,22 +1,41 @@ @@ -87,4 +114,8 @@ const emit = defineEmits<{ font-size: 0.8rem; opacity: 0.8; } + +.view-btn-label { + text-transform: uppercase; +} diff --git a/resources/js/Layouts/MainLayout.vue b/resources/js/Layouts/MainLayout.vue index 51da2b5..f75b6a8 100644 --- a/resources/js/Layouts/MainLayout.vue +++ b/resources/js/Layouts/MainLayout.vue @@ -3,17 +3,48 @@ import MainHeader from "@/Components/FlightsGoneBy/MainHeader.vue"; import MainFooter from "@/Components/FlightsGoneBy/MainFooter.vue"; import Radar from "@/Components/FlightsGoneBy/Radar.vue"; import { usePage, router } from "@inertiajs/vue3"; -import { ref } from "vue"; -import {SharedProps} from "@/Types/types"; +import { ref, watch } from "vue"; +import { SharedProps, Notification } from "@/Types/types"; +import axios from "axios"; const page = usePage().props; const transitionKey = ref(0); -router.on('success', () => { +const seenNotificationIds = ref>(new Set()) + +const achievementSound = new Audio('/sounds/seatBelt.wav') + +function handleNewNotifications(notifications: Notification[]) { + if (!notifications?.length) return + const unseen = notifications.filter(n => !seenNotificationIds.value.has(n.id)) + if (!unseen.length) return + unseen.forEach(n => seenNotificationIds.value.add(n.id)) + activeToasts.value.push(...unseen) + achievementSound.play().catch(() => {}) +} + + + +// ── Toasts ──────────────────────────────────────────────────────────────────── +const activeToasts = ref([]) + +async function dismissToast(notification: Notification) { + activeToasts.value = activeToasts.value.filter(n => n.id !== notification.id) + await axios.patch(`/notifications/${notification.id}/read`) +} + +console.log(page.achievement_notifications) +watch( + () => page.achievement_notifications, + handleNewNotifications, + { immediate: true } +) + +router.on('success', (event) => { transitionKey.value++; + handleNewNotifications(event.detail.page.props.achievement_notifications as Notification[] ?? []) }); - + diff --git a/resources/js/Pages/UserProfile.vue b/resources/js/Pages/UserProfile.vue index 11cf8d1..850f3e9 100644 --- a/resources/js/Pages/UserProfile.vue +++ b/resources/js/Pages/UserProfile.vue @@ -1,10 +1,10 @@