From 85eeba982f365c3b622cda8f25d9b3556db0da40 Mon Sep 17 00:00:00 2001 From: josh Date: Fri, 10 Jul 2026 21:59:35 +1000 Subject: [PATCH] Added Support and Analytics for Release --- .../Controllers/SupportFormController.php | 35 ++ app/Models/Airline.php | 3 + app/Models/Notification.php | 3 + app/Models/User.php | 6 +- app/Models/UserFlight.php | 11 + app/Notifications/SupportRequestSubmitted.php | 47 +++ app/Observers/UserObserver.php | 24 ++ app/Providers/AppServiceProvider.php | 3 - app/Settings/SettingsRegistry.php | 15 +- config/app.php | 1 + .../FlightsGoneBy/DepartureBoard.vue | 28 +- .../FlightsGoneBy/DepartureBoardTableRow.vue | 59 ++++ .../FlightsGoneBy/Feed/FeedItem.vue | 1 + .../Components/FlightsGoneBy/MainFooter.vue | 60 +++- .../Components/FlightsGoneBy/MainHeader.vue | 114 +++++-- .../js/Components/FlightsGoneBy/NavLink.vue | 11 + resources/js/Pages/Contact/Support.vue | 228 +++++++++++++ resources/js/Pages/Feed.vue | 11 +- .../Achievements/aircraft.all_boeing_737.vue | 6 +- .../js/Pages/Profile/UserAchievement.vue | 4 +- resources/js/Types/types.d.ts | 1 + resources/views/app.blade.php | 1 + .../views/vendor/mail/html/themes/default.css | 308 ++---------------- routes/web.php | 13 +- 24 files changed, 673 insertions(+), 320 deletions(-) create mode 100644 app/Http/Controllers/SupportFormController.php create mode 100644 app/Notifications/SupportRequestSubmitted.php create mode 100644 app/Observers/UserObserver.php create mode 100644 resources/js/Components/FlightsGoneBy/NavLink.vue create mode 100644 resources/js/Pages/Contact/Support.vue diff --git a/app/Http/Controllers/SupportFormController.php b/app/Http/Controllers/SupportFormController.php new file mode 100644 index 0000000..45f3a4d --- /dev/null +++ b/app/Http/Controllers/SupportFormController.php @@ -0,0 +1,35 @@ +validate([ + 'reason' => ['required', 'string', 'in:bug_report,general_feedback,missing_data'], + 'message' => ['required', 'string', 'max:5000'], + ]); + + Notification::route('mail', config('mail.support_address', 'hello@flightsgoneby.com')) + ->notify(new SupportRequestSubmitted( + name: $request->user()->name, + email: $request->user()->email, + reason: $validated['reason'], + message: $validated['message'], + )); + + return back()->with('success', 'Your message has been sent.'); + } +} diff --git a/app/Models/Airline.php b/app/Models/Airline.php index 80c5394..6bf5197 100644 --- a/app/Models/Airline.php +++ b/app/Models/Airline.php @@ -2,12 +2,15 @@ namespace App\Models; +use App\Observers\AirlineObserver; +use Illuminate\Database\Eloquent\Attributes\ObservedBy; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasMany; +#[ObservedBy(AirlineObserver::class)] class Airline extends Model { protected $table = 'airlines'; diff --git a/app/Models/Notification.php b/app/Models/Notification.php index 4ad6353..f6c831a 100644 --- a/app/Models/Notification.php +++ b/app/Models/Notification.php @@ -2,6 +2,8 @@ namespace App\Models; +use App\Observers\NotificationObserver; +use Illuminate\Database\Eloquent\Attributes\ObservedBy; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; @@ -26,6 +28,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; * @method static Builder active() * @method static Builder forAchievements() */ +#[ObservedBy(NotificationObserver::class)] class Notification extends Model { protected $fillable = [ diff --git a/app/Models/User.php b/app/Models/User.php index 3dde6cf..ebd7748 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -2,10 +2,13 @@ namespace App\Models; +use App\Observers\UserObserver; use App\Settings\SettingsRegistry; use Database\Factories\UserFactory; +use Illuminate\Contracts\Auth\MustVerifyEmail; use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Attributes\Hidden; +use Illuminate\Database\Eloquent\Attributes\ObservedBy; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Relations\BelongsToMany; @@ -24,7 +27,8 @@ use App\Notifications\ResetPasswordNotification; #[Fillable(['name', 'email', 'password', 'distance_unit', 'settings'])] #[Hidden(['password', 'remember_token'])] -class User extends Authenticatable +#[ObservedBy(UserObserver::class)] +class User extends Authenticatable implements MustVerifyEmail { /** @use HasFactory */ diff --git a/app/Models/UserFlight.php b/app/Models/UserFlight.php index db5e514..e67a0ac 100644 --- a/app/Models/UserFlight.php +++ b/app/Models/UserFlight.php @@ -3,6 +3,8 @@ namespace App\Models; use App\Http\Controllers\Api\AirlineApiController; +use App\Observers\FlightObserver; +use Illuminate\Database\Eloquent\Attributes\ObservedBy; use Illuminate\Database\Eloquent\Casts\Attribute; use Carbon\Carbon; use Illuminate\Database\Eloquent\Model; @@ -10,6 +12,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Support\Collection; use Illuminate\Support\Facades\Storage; +#[ObservedBy(FlightObserver::class)] class UserFlight extends Model { protected $table = 'user_flights'; @@ -56,6 +59,7 @@ class UserFlight extends Model 'scope', 'range', 'region_range', + 'class_seat_combined' ]; protected static function booted(): void @@ -95,6 +99,13 @@ class UserFlight extends Model ); } + protected function classSeatCombined(): Attribute + { + return Attribute::make( + get: fn() => $this->flightClass?->name + ); + } + protected function departureTimeDisplay(): Attribute { return Attribute::make( diff --git a/app/Notifications/SupportRequestSubmitted.php b/app/Notifications/SupportRequestSubmitted.php new file mode 100644 index 0000000..d4fac7d --- /dev/null +++ b/app/Notifications/SupportRequestSubmitted.php @@ -0,0 +1,47 @@ +theme('flightsgoneby') + ->subject('New Support Request: ' . $this->reasonLabel()) + ->replyTo($this->email, $this->name) + ->greeting('New Support Request') + ->line('Reason: ' . $this->reasonLabel()) + ->line('From: ' . $this->name . ' (' . $this->email . ')') + ->line($this->message); + } + + protected function reasonLabel(): string + { + return match ($this->reason) { + 'bug_report' => 'Bug Report', + 'general_feedback' => 'General Feedback', + 'missing_data' => 'Missing Data', + default => $this->reason, + }; + } +} diff --git a/app/Observers/UserObserver.php b/app/Observers/UserObserver.php new file mode 100644 index 0000000..123e80b --- /dev/null +++ b/app/Observers/UserObserver.php @@ -0,0 +1,24 @@ +where('id', '!=', $user->id) + ->get(['id']) + ->each(fn (User $admin) => Notification::create([ + 'user_id' => $admin->id, + 'title' => 'New user registered', + 'body' => "{$user->name} just signed up.", + 'url' => null, + 'is_achievement' => false, + 'achievement_id' => null, + ])); + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 9ec3f9f..7da7b90 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -34,9 +34,6 @@ class AppServiceProvider extends ServiceProvider Mail::alwaysTo(config('app.test_email')); } Vite::prefetch(concurrency: 3); - UserFlight::observe(FlightObserver::class); - Airline::observe(AirlineObserver::class); - Notification::observe(NotificationObserver::class); RateLimiter::for('api', function (Request $request) { return $request->user('sanctum') ? Limit::perMinute(60)->by($request->user('sanctum')->id) diff --git a/app/Settings/SettingsRegistry.php b/app/Settings/SettingsRegistry.php index fd995d3..413ba8a 100644 --- a/app/Settings/SettingsRegistry.php +++ b/app/Settings/SettingsRegistry.php @@ -110,7 +110,10 @@ class SettingsRegistry 'label' => 'Which columns to show on the Departure Board', 'default' => ['airline', 'flight_number', 'departure_airport', 'arrival_airport', 'departure_date', 'departure_time', 'arrival_time', 'duration', 'distance', 'aircraft', 'registration', 'class_seat_combined'], 'options' => [ - ['value' => 'airline', 'label' => 'Airline'], + ['value' => 'airline', 'label' => 'Airline Logo w/ Info Popup'], + ['value' => 'airline.name', 'label' => 'Airline Name'], + ['value' => 'airline.iata_code', 'label' => 'Airline IATA Code'], + ['value' => 'airline.icao_code', 'label' => 'Airline ICAO Code'], ['value' => 'flight_number', 'label' => 'Flight Number'], ['value' => 'departure_airport', 'label' => 'From'], ['value' => 'arrival_airport', 'label' => 'To'], @@ -119,8 +122,16 @@ class SettingsRegistry ['value' => 'arrival_time', 'label' => 'Arrival Time'], ['value' => 'duration', 'label' => 'Duration'], ['value' => 'distance', 'label' => 'Distance'], - ['value' => 'aircraft', 'label' => 'Aircraft'], + ['value' => 'aircraft', 'label' => 'Aircraft Designator w/ Info Popup'], ['value' => 'registration', 'label' => 'Aircraft Registration'], + ['value' => 'aircraft.manufacturer_code', 'label' => 'Aircraft Manufacturer'], + ['value' => 'aircraft.model_full_name', 'label' => 'Aircraft Model'], + ['value' => 'flight_class', 'label' => 'Class'], + ['value' => 'seat_type_id', 'label' => 'Seat Type'], + ['value' => 'seat_number', 'label' => 'Seat Number'], + ['value' => 'flight_reason_id', 'label' => 'Reason'], + + ['value' => 'aircraft.display_name_short', 'label' => 'Aircraft Full Name'], ['value' => 'class_seat_combined', 'label' => 'Class/Seat Type/Seat Number Combined'], ], ], diff --git a/config/app.php b/config/app.php index b0b2ca8..7d69ae0 100644 --- a/config/app.php +++ b/config/app.php @@ -42,6 +42,7 @@ return [ 'debug' => (bool) env('APP_DEBUG', false), 'verify_ssl' => env('VERIFY_SSL', true), 'test_email' => env('TEST_EMAIL', ''), + 'contact_email' => env('CONTACT_EMAIL', ''), /* |-------------------------------------------------------------------------- | Application URL diff --git a/resources/js/Components/FlightsGoneBy/DepartureBoard.vue b/resources/js/Components/FlightsGoneBy/DepartureBoard.vue index 624b035..ed405ee 100644 --- a/resources/js/Components/FlightsGoneBy/DepartureBoard.vue +++ b/resources/js/Components/FlightsGoneBy/DepartureBoard.vue @@ -22,8 +22,11 @@ const columnsToShow = computed(() => page.auth.user?.resolved_settings?.departur const showColumn = (column: string) => columnsToShow.value.includes(column) const allHeaders = [ - { title: '', key: 'airline', sortable: true }, + { title: '', key: 'airline', sortable: true, resizable: false}, { title: 'FLIGHT', key: 'flight_number', sortable: true }, + { title: 'AIRLINE', key: 'airline.name', sortable: true}, + { title: 'IATA', key: 'airline.iata_code', sortable: true}, + { title: 'ICAO', key: 'airline.icao_code', sortable: true}, { title: 'FROM', key: 'departure_airport', sortable: true }, { title: 'TO', key: 'arrival_airport', sortable: true }, { title: 'DATE', key: 'departure_date', sortable: true }, @@ -31,9 +34,16 @@ const allHeaders = [ { title: 'ARRIVE', key: 'arrival_time', sortable: false }, { title: 'DURATION', key: 'duration', sortable: true }, { title: 'DISTANCE', key: 'distance', sortable: true }, + { title: 'MANUFACTURER', key: 'aircraft.manufacturer_code', sortable: true }, + { title: 'MODEL', key: 'aircraft.model_full_name', sortable: true }, + { title: 'AIRCRAFT', key: 'aircraft.display_name_short', sortable: true }, { title: 'AIRCRAFT', key: 'aircraft', sortable: true }, { title: 'REG', key: 'registration', sortable: true }, + { title: 'CLASS', key: 'flight_class', sortable: true }, { title: 'CLASS', key: 'class_seat_combined', sortable: true }, + { title: 'SEAT TYPE', key: 'seat_type_id', sortable: true }, + { title: 'SEAT NO.', key: 'seat_number', sortable: true }, + { title: 'REASON', key: 'flight_reason_id', sortable: true }, { title: '', key: 'actions', sortable: false }, ] @@ -43,16 +53,18 @@ const headers = computed(() => const CLASS_ORDER: Record = { 'Unspecified': 0, - 'Economy': 1, - 'Premium Economy': 2, - 'Business': 3, - 'First': 4, - 'Private': 5, - 'Crew' : 6, + 'General Aviation': 1, + 'Economy': 2, + 'Premium Economy': 3, + 'Business': 4, + 'First': 5, + 'Private': 6, + 'Crew' : 7, } const customKeySort = { - class_seat_combined: (a: Flight['flight_class'], b: Flight['flight_class']) => (CLASS_ORDER[a?.name ?? ''] ?? -1) - (CLASS_ORDER[b?.name ?? ''] ?? -1), + flight_class: (a: Flight['flight_class'], b: Flight['flight_class']) => (CLASS_ORDER[a?.name ?? ''] ?? -1) - (CLASS_ORDER[b?.name ?? ''] ?? -1), + class_seat_combined: (a: Flight['class_seat_combined'], b: Flight['class_seat_combined']) => (CLASS_ORDER[a] ?? -1) - (CLASS_ORDER[b] ?? -1), airline: (a: Flight['airline'], b: Flight['airline']) => (a?.iata_code ?? '').localeCompare(b?.iata_code ?? ''), aircraft: (a: Flight['aircraft'], b: Flight['aircraft']) => (a?.designator ?? '').localeCompare(b?.designator ?? ''), duration: (a: any, b: any) => (a ?? 0) - (b ?? 0), diff --git a/resources/js/Components/FlightsGoneBy/DepartureBoardTableRow.vue b/resources/js/Components/FlightsGoneBy/DepartureBoardTableRow.vue index 313593a..8d50456 100644 --- a/resources/js/Components/FlightsGoneBy/DepartureBoardTableRow.vue +++ b/resources/js/Components/FlightsGoneBy/DepartureBoardTableRow.vue @@ -25,6 +25,21 @@ const props = defineProps<{ const page = usePage() +const reasonBadgeVariant = (reason: string) => { + switch (reason) { + case 'No Particular Reason': + return 'economy' + case 'Pleasure': + return 'generic' + case 'Business': + return 'business' + case 'Crew': + return 'crew' + case 'Visiting Friends / Relatives': + return 'general_aviation' + } +} + const showColumn = (column: string) => props.columnsToShow.includes(column) @@ -50,6 +65,19 @@ const showColumn = (column: string) => props.columnsToShow.includes(column) + + {{ flight.airline?.name }} + + + + {{ flight.airline?.iata_code }} + + + + {{ flight.airline?.icao_code }} + + + {{ flight.departure_airport.display_code }}
@@ -89,6 +117,21 @@ const showColumn = (column: string) => props.columnsToShow.includes(column) + + {{ flight.aircraft?.manufacturer_code }} + + + + {{ flight.aircraft?.model_full_name }} + + + + + {{ flight.aircraft?.display_name_short }} + + + + {{ flight.aircraft?.designator }} @@ -99,6 +142,22 @@ const showColumn = (column: string) => props.columnsToShow.includes(column) {{ flight.aircraft_registration }} + + + + + + {{ flight.seat_type?.name }} + + + + {{ flight.seat_number }} + + + + {{ flight.flight_reason?.name.replace('Visiting Friends / Relatives', 'VFR') }} + + diff --git a/resources/js/Components/FlightsGoneBy/Feed/FeedItem.vue b/resources/js/Components/FlightsGoneBy/Feed/FeedItem.vue index ad5f6e1..b63fbe5 100644 --- a/resources/js/Components/FlightsGoneBy/Feed/FeedItem.vue +++ b/resources/js/Components/FlightsGoneBy/Feed/FeedItem.vue @@ -89,6 +89,7 @@ function timeAgo(dateStr: string): string { overflow: hidden; } + .card-top { display: flex; align-items: center; diff --git a/resources/js/Components/FlightsGoneBy/MainFooter.vue b/resources/js/Components/FlightsGoneBy/MainFooter.vue index 4383aa9..b84775a 100644 --- a/resources/js/Components/FlightsGoneBy/MainFooter.vue +++ b/resources/js/Components/FlightsGoneBy/MainFooter.vue @@ -1,10 +1,22 @@ @@ -15,6 +27,50 @@ footer { min-height: 32px; display: flex; align-items: center; - justify-content: center; + justify-content: space-between; + padding: 0 1.5rem; + border-top: 1px solid rgba(255, 255, 255, 0.06); +} + +.footer-copyright { + font-family: 'Share Tech Mono', monospace; + font-size: 0.68rem; + letter-spacing: 0.12em; + text-transform: uppercase; + color: #556; +} + +.footer-links { + display: flex; + align-items: center; + gap: 1rem; +} + +.footer-link { + font-family: 'Share Tech Mono', monospace; + font-size: 0.68rem; + letter-spacing: 0.12em; + text-transform: uppercase; + color: #778; + text-decoration: none; + transition: color 0.15s ease; +} + +.footer-link:hover { + color: #ffc107; +} + +@media (max-width: 600px) { + footer { + padding: 0 1rem; + } + + .footer-copyright { + font-size: 0.6rem; + } + + .footer-link { + font-size: 0.6rem; + } } diff --git a/resources/js/Components/FlightsGoneBy/MainHeader.vue b/resources/js/Components/FlightsGoneBy/MainHeader.vue index 1d8fa21..3ad0c77 100644 --- a/resources/js/Components/FlightsGoneBy/MainHeader.vue +++ b/resources/js/Components/FlightsGoneBy/MainHeader.vue @@ -35,17 +35,36 @@ onUnmounted(() => document.removeEventListener('click', handleClickOutside))