diff --git a/app/Http/Controllers/Account/AuthController.php b/app/Http/Controllers/Account/AuthController.php new file mode 100644 index 00000000..68054b36 --- /dev/null +++ b/app/Http/Controllers/Account/AuthController.php @@ -0,0 +1,69 @@ +logout(); + session()->regenerateToken(); + + return redirect()->route('account.login'); + } + + /** + * Process the login request. + * + * @TODO Implement additional brute-force protection with custom blocked IPs model. + * + * @param LoginRequest $request + * @throws \Illuminate\Validation\ValidationException + * @return \Illuminate\Http\RedirectResponse + */ + public function processLogin(LoginRequest $request) + { + $credentials = $request->only('email', 'password'); + $key = 'login-attempt:' . $request->ip(); + $attemptsPerHour = 5; + + if (\RateLimiter::tooManyAttempts($key, $attemptsPerHour)) { + $blockedUntil = Carbon::now() + ->addSeconds(\RateLimiter::availableIn($key)) + ->diffInMinutes(Carbon::now()); + + return back() + ->withInput($request->only(['email', 'remember'])) + ->withErrors([ + 'email' => 'Too many login attempts. Please try again in ' + . $blockedUntil . ' minutes.', + ]); + } + + if (auth()->attempt($credentials, $request->boolean('remember'))) { + session()->regenerate(); + + \RateLimiter::clear($key); + + return redirect()->intended('/account'); + } + + \RateLimiter::increment($key, 3600); + + return back() + ->withInput($request->only('email')) + ->withErrors([ + 'email' => 'The provided credentials do not match our records.', + ]); + } +} diff --git a/app/Http/Controllers/Account/Support/TicketController.php b/app/Http/Controllers/Account/Support/TicketController.php new file mode 100644 index 00000000..3a911dbf --- /dev/null +++ b/app/Http/Controllers/Account/Support/TicketController.php @@ -0,0 +1,45 @@ +authorize('closeTicket', $supportTicket); + + $supportTicket->update([ + 'status' => Status::CLOSED, + ]); + + return redirect() + ->route('support.tickets.show', $supportTicket) + ->with('success', __('account.support_ticket.close_ticket.success')); + } + + public function index() + { + $supportTickets = SupportTicket::whereUserId(auth()->user()->id) + ->orderBy('status', 'desc') + ->orderBy('created_at', 'desc') + ->paginate(static::$paginationLimit); + + return view('support.tickets.index', compact('supportTickets')); + } + + public function show(SupportTicket $supportTicket) + { + $this->authorize('view', $supportTicket); + + $supportTicket->load('user'); + + return view('support.tickets.show', compact('supportTicket')); + } +} diff --git a/app/Http/Requests/LoginRequest.php b/app/Http/Requests/LoginRequest.php new file mode 100644 index 00000000..58c936e9 --- /dev/null +++ b/app/Http/Requests/LoginRequest.php @@ -0,0 +1,29 @@ +|string> + */ + public function rules(): array + { + return [ + 'email' => 'required|email', + 'password' => 'required|string', + ]; + } +} diff --git a/app/Models/SupportTicket.php b/app/Models/SupportTicket.php new file mode 100644 index 00000000..a2f1458e --- /dev/null +++ b/app/Models/SupportTicket.php @@ -0,0 +1,55 @@ + Status::class, + ]; + + protected static function booted() + { + static::creating(function ($ticket) { + if (is_null($ticket->mask)) { + // @TODO Generate a unique mask for the ticket + $ticket->mask = uniqid('ticket_'); + } + }); + } + + public function getRouteKeyName(): string + { + return 'mask'; + } + + public function replies(): HasMany + { + return $this->hasMany(Reply::class) + ->orderBy('created_at', 'desc'); + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } +} diff --git a/app/Models/SupportTicket/Reply.php b/app/Models/SupportTicket/Reply.php new file mode 100644 index 00000000..ae82f430 --- /dev/null +++ b/app/Models/SupportTicket/Reply.php @@ -0,0 +1,50 @@ + 'array', + 'note' => 'boolean', + ]; + + public function isFromAdmin(): Attribute + { + return Attribute::get(fn () => $this->user->is_admin) + ->shouldCache(); + } + + public function isFromUser(): Attribute + { + return Attribute::get(fn () => $this->user_id === auth()->user()->id) + ->shouldCache(); + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + public function supportTicket(): BelongsTo + { + return $this->belongsTo(SupportTicket::class); + } +} diff --git a/app/Models/User.php b/app/Models/User.php index cffc680c..653229e1 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -4,6 +4,7 @@ // use Illuminate\Contracts\Auth\MustVerifyEmail; use Illuminate\Database\Eloquent\Factories\HasFactory; +use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; use Laravel\Cashier\Billable; @@ -22,6 +23,12 @@ class User extends Authenticatable protected $casts = [ 'email_verified_at' => 'datetime', + 'is_admin' => 'boolean', 'password' => 'hashed', ]; + + public function supportTickets(): HasMany + { + return $this->hasMany(SupportTicket::class); + } } diff --git a/app/Policies/SupportTicketPolicy.php b/app/Policies/SupportTicketPolicy.php new file mode 100644 index 00000000..ae53e3c4 --- /dev/null +++ b/app/Policies/SupportTicketPolicy.php @@ -0,0 +1,74 @@ +user_id === $user->id; + } + + /** + * Determine whether the user can view any models. + */ + public function viewAny(User $user): bool + { + return false; + } + + /** + * Determine whether the user can view the model. + */ + public function view(User $user, SupportTicket $supportTicket): Response + { + return $user->id === $supportTicket->user_id + ? Response::allow() + : Response::denyAsNotFound('Ticket not found.'); + } + + /** + * Determine whether the user can create models. + */ + public function create(User $user): bool + { + return true; + } + + /** + * Determine whether the user can update the model. + */ + public function update(User $user, SupportTicket $supportTicket): bool + { + return $user->id === $supportTicket->user_id; + } + + /** + * Determine whether the user can delete the model. + */ + public function delete(User $user, SupportTicket $supportTicket): bool + { + // Deletion not allowed. + return false; + } + + /** + * Determine whether the user can restore the model. + */ + public function restore(User $user, SupportTicket $supportTicket): bool + { + return false; + } + + /** + * Determine whether the user can permanently delete the model. + */ + public function forceDelete(User $user, SupportTicket $supportTicket): bool + { + return false; + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index f58138ee..02a39730 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -3,6 +3,9 @@ namespace App\Providers; use App\Support\GitHub; +use Illuminate\Cache\RateLimiting\Limit; +use Illuminate\Http\Request; +use Illuminate\Support\Facades\RateLimiter; use Illuminate\Support\Facades\View; use Illuminate\Support\ServiceProvider; @@ -24,7 +27,7 @@ public function boot(): void $this->registerSharedViewVariables(); } - private function registerSharedViewVariables(): void + private function registerSharedViewVariables(): static { View::share('electronGitHubVersion', app()->environment('production') ? GitHub::electron()->latestVersion() @@ -34,5 +37,7 @@ private function registerSharedViewVariables(): void View::share('bskyLink', 'https://bsky.app/profile/nativephp.bsky.social'); View::share('openCollectiveLink', 'https://opencollective.com/nativephp'); View::share('githubLink', 'https://github.com/NativePHP'); + + return $this; } } diff --git a/app/Providers/RouteServiceProvider.php b/app/Providers/RouteServiceProvider.php index 1cf5f15c..07ca748c 100644 --- a/app/Providers/RouteServiceProvider.php +++ b/app/Providers/RouteServiceProvider.php @@ -17,7 +17,7 @@ class RouteServiceProvider extends ServiceProvider * * @var string */ - public const HOME = '/home'; + public const HOME = '/account'; /** * Define your route model bindings, pattern filters, and other route configuration. diff --git a/app/SupportTicket/Status.php b/app/SupportTicket/Status.php new file mode 100644 index 00000000..7d9297b1 --- /dev/null +++ b/app/SupportTicket/Status.php @@ -0,0 +1,17 @@ +value); + } +} diff --git a/database/factories/SupportTicket/ReplyFactory.php b/database/factories/SupportTicket/ReplyFactory.php new file mode 100644 index 00000000..3873abcc --- /dev/null +++ b/database/factories/SupportTicket/ReplyFactory.php @@ -0,0 +1,28 @@ + $this->faker->paragraphs(2, true), + 'attachments' => null, + 'note' => false, + 'created_at' => Carbon::now(), + 'updated_at' => Carbon::now(), + + 'support_ticket_id' => SupportTicket::factory(), + 'user_id' => User::factory(), + ]; + } +} diff --git a/database/factories/SupportTicketFactory.php b/database/factories/SupportTicketFactory.php new file mode 100644 index 00000000..ad153a9f --- /dev/null +++ b/database/factories/SupportTicketFactory.php @@ -0,0 +1,27 @@ + 'NATIVE-' . $this->faker->numberBetween(1000, 9999), + 'subject' => $this->faker->sentence(), + 'message' => $this->faker->paragraph(), + 'status' => 'open', + 'created_at' => Carbon::now(), + 'updated_at' => Carbon::now(), + + 'user_id' => User::factory(), + ]; + } +} diff --git a/database/migrations/2025_04_28_135326_create_support_tickets_table.php b/database/migrations/2025_04_28_135326_create_support_tickets_table.php new file mode 100644 index 00000000..bc647fb8 --- /dev/null +++ b/database/migrations/2025_04_28_135326_create_support_tickets_table.php @@ -0,0 +1,27 @@ +id(); + $table->foreignIdFor(User::class); + $table->string('mask'); + $table->string('subject'); + $table->text('message'); + $table->string('status'); + $table->timestamps(); + $table->softDeletes(); + }); + } + + public function down(): void + { + Schema::dropIfExists('support_tickets'); + } +}; diff --git a/database/migrations/2025_04_28_160102_create_replies_table.php b/database/migrations/2025_04_28_160102_create_replies_table.php new file mode 100644 index 00000000..f12e6186 --- /dev/null +++ b/database/migrations/2025_04_28_160102_create_replies_table.php @@ -0,0 +1,28 @@ +id(); + $table->foreignIdFor(SupportTicket::class); + $table->foreignIdFor(User::class ); + $table->text('message'); + $table->json('attachments')->nullable(); + $table->boolean('note'); + $table->timestamps(); + $table->softDeletes(); + }); + } + + public function down(): void + { + Schema::dropIfExists('replies'); + } +}; diff --git a/database/migrations/2025_04_29_141211_update_users_table_add_admin_flag.php b/database/migrations/2025_04_29_141211_update_users_table_add_admin_flag.php new file mode 100644 index 00000000..2ffbc38c --- /dev/null +++ b/database/migrations/2025_04_29_141211_update_users_table_add_admin_flag.php @@ -0,0 +1,30 @@ +boolean('is_admin') + ->after('remember_token') + ->default(false); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + $table->dropColumn('is_admin'); + }); + } +}; diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index a9f4519f..3a9a2752 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -4,6 +4,7 @@ // use Illuminate\Database\Console\Seeds\WithoutModelEvents; use Illuminate\Database\Seeder; +use Illuminate\Support\Facades\Hash; class DatabaseSeeder extends Seeder { @@ -12,11 +13,14 @@ class DatabaseSeeder extends Seeder */ public function run(): void { - // \App\Models\User::factory(10)->create(); + \App\Models\User::factory()->create([ + 'name' => 'Test User', + 'email' => 'test@example.com', + 'password' => Hash::make('password'), + ]); - // \App\Models\User::factory()->create([ - // 'name' => 'Test User', - // 'email' => 'test@example.com', - // ]); + $this->call([ + SupportTicketSeeder::class, + ]); } } diff --git a/database/seeders/SupportTicketSeeder.php b/database/seeders/SupportTicketSeeder.php new file mode 100644 index 00000000..34edb2c3 --- /dev/null +++ b/database/seeders/SupportTicketSeeder.php @@ -0,0 +1,28 @@ +count(10) + ->has( + SupportTicket\Reply::factory() + ->state(['user_id' => 1]) + ->count(5) + ) + ->create([ + 'user_id' => 1, + 'status' => 'open', + ]); + } +} diff --git a/lang/en/account.php b/lang/en/account.php new file mode 100644 index 00000000..889ffad0 --- /dev/null +++ b/lang/en/account.php @@ -0,0 +1,13 @@ + [ + 'status' => [ + 'open' => 'Open', + 'in_progress' => 'In Progress', + 'on_hold' => 'On Hold', + 'responded' => 'Responded', + 'closed' => 'Closed', + ], + ], +]; diff --git a/lang/en/auth.php b/lang/en/auth.php new file mode 100644 index 00000000..6598e2c0 --- /dev/null +++ b/lang/en/auth.php @@ -0,0 +1,20 @@ + 'These credentials do not match our records.', + 'password' => 'The provided password is incorrect.', + 'throttle' => 'Too many login attempts. Please try again in :seconds seconds.', + +]; diff --git a/lang/en/pagination.php b/lang/en/pagination.php new file mode 100644 index 00000000..d4814118 --- /dev/null +++ b/lang/en/pagination.php @@ -0,0 +1,19 @@ + '« Previous', + 'next' => 'Next »', + +]; diff --git a/lang/en/passwords.php b/lang/en/passwords.php new file mode 100644 index 00000000..f1223bd7 --- /dev/null +++ b/lang/en/passwords.php @@ -0,0 +1,22 @@ + 'Your password has been reset.', + 'sent' => 'We have emailed your password reset link.', + 'throttled' => 'Please wait before retrying.', + 'token' => 'This password reset token is invalid.', + 'user' => "We can't find a user with that email address.", + +]; diff --git a/lang/en/validation.php b/lang/en/validation.php new file mode 100644 index 00000000..8dbe37f1 --- /dev/null +++ b/lang/en/validation.php @@ -0,0 +1,191 @@ + 'The :attribute field must be accepted.', + 'accepted_if' => 'The :attribute field must be accepted when :other is :value.', + 'active_url' => 'The :attribute field must be a valid URL.', + 'after' => 'The :attribute field must be a date after :date.', + 'after_or_equal' => 'The :attribute field must be a date after or equal to :date.', + 'alpha' => 'The :attribute field must only contain letters.', + 'alpha_dash' => 'The :attribute field must only contain letters, numbers, dashes, and underscores.', + 'alpha_num' => 'The :attribute field must only contain letters and numbers.', + 'array' => 'The :attribute field must be an array.', + 'ascii' => 'The :attribute field must only contain single-byte alphanumeric characters and symbols.', + 'before' => 'The :attribute field must be a date before :date.', + 'before_or_equal' => 'The :attribute field must be a date before or equal to :date.', + 'between' => [ + 'array' => 'The :attribute field must have between :min and :max items.', + 'file' => 'The :attribute field must be between :min and :max kilobytes.', + 'numeric' => 'The :attribute field must be between :min and :max.', + 'string' => 'The :attribute field must be between :min and :max characters.', + ], + 'boolean' => 'The :attribute field must be true or false.', + 'can' => 'The :attribute field contains an unauthorized value.', + 'confirmed' => 'The :attribute field confirmation does not match.', + 'current_password' => 'The password is incorrect.', + 'date' => 'The :attribute field must be a valid date.', + 'date_equals' => 'The :attribute field must be a date equal to :date.', + 'date_format' => 'The :attribute field must match the format :format.', + 'decimal' => 'The :attribute field must have :decimal decimal places.', + 'declined' => 'The :attribute field must be declined.', + 'declined_if' => 'The :attribute field must be declined when :other is :value.', + 'different' => 'The :attribute field and :other must be different.', + 'digits' => 'The :attribute field must be :digits digits.', + 'digits_between' => 'The :attribute field must be between :min and :max digits.', + 'dimensions' => 'The :attribute field has invalid image dimensions.', + 'distinct' => 'The :attribute field has a duplicate value.', + 'doesnt_end_with' => 'The :attribute field must not end with one of the following: :values.', + 'doesnt_start_with' => 'The :attribute field must not start with one of the following: :values.', + 'email' => 'The :attribute field must be a valid email address.', + 'ends_with' => 'The :attribute field must end with one of the following: :values.', + 'enum' => 'The selected :attribute is invalid.', + 'exists' => 'The selected :attribute is invalid.', + 'extensions' => 'The :attribute field must have one of the following extensions: :values.', + 'file' => 'The :attribute field must be a file.', + 'filled' => 'The :attribute field must have a value.', + 'gt' => [ + 'array' => 'The :attribute field must have more than :value items.', + 'file' => 'The :attribute field must be greater than :value kilobytes.', + 'numeric' => 'The :attribute field must be greater than :value.', + 'string' => 'The :attribute field must be greater than :value characters.', + ], + 'gte' => [ + 'array' => 'The :attribute field must have :value items or more.', + 'file' => 'The :attribute field must be greater than or equal to :value kilobytes.', + 'numeric' => 'The :attribute field must be greater than or equal to :value.', + 'string' => 'The :attribute field must be greater than or equal to :value characters.', + ], + 'hex_color' => 'The :attribute field must be a valid hexadecimal color.', + 'image' => 'The :attribute field must be an image.', + 'in' => 'The selected :attribute is invalid.', + 'in_array' => 'The :attribute field must exist in :other.', + 'integer' => 'The :attribute field must be an integer.', + 'ip' => 'The :attribute field must be a valid IP address.', + 'ipv4' => 'The :attribute field must be a valid IPv4 address.', + 'ipv6' => 'The :attribute field must be a valid IPv6 address.', + 'json' => 'The :attribute field must be a valid JSON string.', + 'lowercase' => 'The :attribute field must be lowercase.', + 'lt' => [ + 'array' => 'The :attribute field must have less than :value items.', + 'file' => 'The :attribute field must be less than :value kilobytes.', + 'numeric' => 'The :attribute field must be less than :value.', + 'string' => 'The :attribute field must be less than :value characters.', + ], + 'lte' => [ + 'array' => 'The :attribute field must not have more than :value items.', + 'file' => 'The :attribute field must be less than or equal to :value kilobytes.', + 'numeric' => 'The :attribute field must be less than or equal to :value.', + 'string' => 'The :attribute field must be less than or equal to :value characters.', + ], + 'mac_address' => 'The :attribute field must be a valid MAC address.', + 'max' => [ + 'array' => 'The :attribute field must not have more than :max items.', + 'file' => 'The :attribute field must not be greater than :max kilobytes.', + 'numeric' => 'The :attribute field must not be greater than :max.', + 'string' => 'The :attribute field must not be greater than :max characters.', + ], + 'max_digits' => 'The :attribute field must not have more than :max digits.', + 'mimes' => 'The :attribute field must be a file of type: :values.', + 'mimetypes' => 'The :attribute field must be a file of type: :values.', + 'min' => [ + 'array' => 'The :attribute field must have at least :min items.', + 'file' => 'The :attribute field must be at least :min kilobytes.', + 'numeric' => 'The :attribute field must be at least :min.', + 'string' => 'The :attribute field must be at least :min characters.', + ], + 'min_digits' => 'The :attribute field must have at least :min digits.', + 'missing' => 'The :attribute field must be missing.', + 'missing_if' => 'The :attribute field must be missing when :other is :value.', + 'missing_unless' => 'The :attribute field must be missing unless :other is :value.', + 'missing_with' => 'The :attribute field must be missing when :values is present.', + 'missing_with_all' => 'The :attribute field must be missing when :values are present.', + 'multiple_of' => 'The :attribute field must be a multiple of :value.', + 'not_in' => 'The selected :attribute is invalid.', + 'not_regex' => 'The :attribute field format is invalid.', + 'numeric' => 'The :attribute field must be a number.', + 'password' => [ + 'letters' => 'The :attribute field must contain at least one letter.', + 'mixed' => 'The :attribute field must contain at least one uppercase and one lowercase letter.', + 'numbers' => 'The :attribute field must contain at least one number.', + 'symbols' => 'The :attribute field must contain at least one symbol.', + 'uncompromised' => 'The given :attribute has appeared in a data leak. Please choose a different :attribute.', + ], + 'present' => 'The :attribute field must be present.', + 'present_if' => 'The :attribute field must be present when :other is :value.', + 'present_unless' => 'The :attribute field must be present unless :other is :value.', + 'present_with' => 'The :attribute field must be present when :values is present.', + 'present_with_all' => 'The :attribute field must be present when :values are present.', + 'prohibited' => 'The :attribute field is prohibited.', + 'prohibited_if' => 'The :attribute field is prohibited when :other is :value.', + 'prohibited_unless' => 'The :attribute field is prohibited unless :other is in :values.', + 'prohibits' => 'The :attribute field prohibits :other from being present.', + 'regex' => 'The :attribute field format is invalid.', + 'required' => 'The :attribute field is required.', + 'required_array_keys' => 'The :attribute field must contain entries for: :values.', + 'required_if' => 'The :attribute field is required when :other is :value.', + 'required_if_accepted' => 'The :attribute field is required when :other is accepted.', + 'required_unless' => 'The :attribute field is required unless :other is in :values.', + 'required_with' => 'The :attribute field is required when :values is present.', + 'required_with_all' => 'The :attribute field is required when :values are present.', + 'required_without' => 'The :attribute field is required when :values is not present.', + 'required_without_all' => 'The :attribute field is required when none of :values are present.', + 'same' => 'The :attribute field must match :other.', + 'size' => [ + 'array' => 'The :attribute field must contain :size items.', + 'file' => 'The :attribute field must be :size kilobytes.', + 'numeric' => 'The :attribute field must be :size.', + 'string' => 'The :attribute field must be :size characters.', + ], + 'starts_with' => 'The :attribute field must start with one of the following: :values.', + 'string' => 'The :attribute field must be a string.', + 'timezone' => 'The :attribute field must be a valid timezone.', + 'unique' => 'The :attribute has already been taken.', + 'uploaded' => 'The :attribute failed to upload.', + 'uppercase' => 'The :attribute field must be uppercase.', + 'url' => 'The :attribute field must be a valid URL.', + 'ulid' => 'The :attribute field must be a valid ULID.', + 'uuid' => 'The :attribute field must be a valid UUID.', + + /* + |-------------------------------------------------------------------------- + | Custom Validation Language Lines + |-------------------------------------------------------------------------- + | + | Here you may specify custom validation messages for attributes using the + | convention "attribute.rule" to name the lines. This makes it quick to + | specify a specific custom language line for a given attribute rule. + | + */ + + 'custom' => [ + 'attribute-name' => [ + 'rule-name' => 'custom-message', + ], + ], + + /* + |-------------------------------------------------------------------------- + | Custom Validation Attributes + |-------------------------------------------------------------------------- + | + | The following language lines are used to swap our attribute placeholder + | with something more reader friendly such as "E-Mail Address" instead + | of "email". This simply helps us make our message more expressive. + | + */ + + 'attributes' => [], + +]; diff --git a/resources/views/account/auth/login.blade.php b/resources/views/account/auth/login.blade.php new file mode 100644 index 00000000..aa8ea0fc --- /dev/null +++ b/resources/views/account/auth/login.blade.php @@ -0,0 +1,88 @@ + +
+
+ +
+
+

+ Sign in to your account +

+

+ Or + + create a new account + +

+
+
+ @csrf + + @error('email') +
+
+ + + +

{{ $message }}

+
+
+ @enderror +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+ + +
+ +
+ +
+
+
+
+
diff --git a/resources/views/account/index.blade.php b/resources/views/account/index.blade.php new file mode 100644 index 00000000..bbe18dfe --- /dev/null +++ b/resources/views/account/index.blade.php @@ -0,0 +1,52 @@ + + {{-- Support Grid Section --}} +
+ {{-- Header --}} +
+

Account

+

+ Manage your NativePHP Account.
+ Not {{ auth()->user()->name }}? Logout. +

+
+ + {{-- Support Grid --}} + + + {{-- Additional Support Information --}} +
+

Need more help?

+

+ Check out our documentation for comprehensive guides and tutorials to help you get the most out of NativePHP. +

+
+
+
diff --git a/resources/views/support/index.blade.php b/resources/views/support/index.blade.php new file mode 100644 index 00000000..1018e1b1 --- /dev/null +++ b/resources/views/support/index.blade.php @@ -0,0 +1,51 @@ + + {{-- Support Grid Section --}} +
+ {{-- Header --}} +
+

Support

+

+ Get help with NativePHP through our various support channels. +

+
+ + {{-- Additional Support Information --}} +
+

Need more help?

+

+ Check out our documentation for comprehensive guides and tutorials to help you get the most out of NativePHP. +

+
+ + {{-- Support Grid --}} + +
+
diff --git a/resources/views/support/tickets/index.blade.php b/resources/views/support/tickets/index.blade.php new file mode 100644 index 00000000..9e8ad4bf --- /dev/null +++ b/resources/views/support/tickets/index.blade.php @@ -0,0 +1,122 @@ + +
+ {{-- Header --}} +
+

Support Tickets

+

+ Manage your support tickets.
+

+
+ + {{-- Support ticket table --}} + +
+ + + + + + + + + + + + @forelse($supportTickets as $ticket) + + + + + + + @empty + + + + @endforelse + + + + +
+ @forelse($supportTickets as $ticket) +
+
+ +
+ {{ $ticket->subject }} +
+ + +
+ Status: + + {{ $ticket->status->translated() }} + +
+ + +
+ Ticket ID: + #{{ $ticket->mask }} +
+ + + +
+
+ @empty +
+ No tickets found. +
+ @endforelse +
+ + @if ($supportTickets->hasPages()) +
+ {{ $supportTickets->links() }} +
+ @endif +
+ {{-- Additional Support Information --}} +
+

Need more help?

+

+ Check out our documentation for comprehensive guides and tutorials to help you get the most out of NativePHP. +

+
+
+
diff --git a/resources/views/support/tickets/show.blade.php b/resources/views/support/tickets/show.blade.php new file mode 100644 index 00000000..f028f42f --- /dev/null +++ b/resources/views/support/tickets/show.blade.php @@ -0,0 +1,219 @@ + +
+ {{-- Desktop Buttons - Hidden on Mobile --}} + +
+
+
+
+

#{{ $supportTicket->mask }} » {{ $supportTicket->subject }}

+
+

+ Ticket ID: #{{ $supportTicket->mask }}
+ Status: {{ $supportTicket->status->translated() }}
+ Created At: {{ $supportTicket->created_at->format('d M Y, H:i') }}
+ Updated At: {{ $supportTicket->updated_at->format('d M Y, H:i') }} +

+
+
+ + {{-- Ticket Messages --}} +
+
+

Messages

+ @foreach($supportTicket->replies as $reply) +
+
+
+

+ {{ $reply->user->name }} + @if($reply->is_from_user) + (You) + @elseif($reply->is_from_admin) + (Staff) + @endif +

+

{{ $reply->message }}

+
+
+
+ {{ $reply->created_at->format('d M Y, H:i') }} +
+
+ @endforeach +
+
+ + {{-- Additional Support Information --}} +
+

Need more help?

+

+ Check out our documentation for comprehensive guides and tutorials to help you get the most out of NativePHP. +

+
+
+ + {{-- Mobile Footer - Visible only on Mobile --}} +
+ + + + + Back + + + +
+ + {{-- Add padding at the bottom to prevent content from being hidden behind the mobile footer --}} +
+ + {{-- Close ticket form --}} +
+ @csrf +
+ + {{-- Reply Modal --}} + + + {{-- Add Alpine.js x-cloak style to hide elements with x-cloak before Alpine initializes --}} + +
+
diff --git a/routes/web.php b/routes/web.php index 0471a388..7b18c85d 100644 --- a/routes/web.php +++ b/routes/web.php @@ -1,5 +1,7 @@ name('docs')->where('page', '.*'); Route::get('/order/{checkoutSessionId}', App\Livewire\OrderSuccess::class)->name('order.success'); + +// Support +Route::prefix('/support') + ->middleware('auth:web') + ->group(function () { + Route::get('/', function () { + return view('support.index'); + }) + ->withoutMiddleware(['auth:web']) + ->name('support.index'); + + Route::prefix('/tickets') + ->group(function () { + Route::get('/', [TicketController::class, 'index'])->name('support.tickets'); + + Route::get('/{supportTicket}', [TicketController::class, 'show']) + ->name('support.tickets.show'); + + Route::post('/{supportTicket}/close', [TicketController::class, 'closeTicket']) + ->name('support.tickets.close'); + }); + }); + +// Account +Route::prefix('/account') + ->middleware(['auth:web']) + ->group(function () { + Route::get('/', function () { + return view('account.index'); + })->name('account.index'); + + Route::get('/login', [AuthController::class, 'login']) + ->middleware('guest') + ->withoutMiddleware(['auth:web']) + ->name('login'); + + Route::post('/login', [AuthController::class, 'processLogin']) + ->middleware(['guest']) + ->withoutMiddleware(['auth:web']) + ->name('login.process'); + + Route::post('/logout', [AuthController::class, 'logout'])->name('logout'); + + });