From e633805ed3f79a2b9dcd6bfb8902a3daab6d0866 Mon Sep 17 00:00:00 2001 From: Meghdad Date: Sat, 16 May 2026 22:02:32 +0330 Subject: [PATCH] Implemented the authenticated admin contact system. --- .../AdminSupportConversationController.php | 152 +++++++ .../SupportAttachmentController.php | 23 ++ .../SupportConversationController.php | 161 ++++++++ .../StoreSupportConversationRequest.php | 29 ++ .../Requests/StoreSupportMessageRequest.php | 25 ++ ...UpdateSupportConversationStatusRequest.php | 25 ++ app/Models/SupportAttachment.php | 21 + app/Models/SupportConversation.php | 92 +++++ app/Models/SupportMessage.php | 32 ++ app/Models/User.php | 10 + .../Support/SupportMessageCreator.php | 62 +++ .../factories/SupportAttachmentFactory.php | 30 ++ .../factories/SupportConversationFactory.php | 29 ++ database/factories/SupportMessageFactory.php | 28 ++ ...807_create_support_conversations_table.php | 32 ++ ...6_180810_create_support_messages_table.php | 30 ++ ...80816_create_support_attachments_table.php | 33 ++ resources/js/components/app-header.tsx | 21 +- resources/js/components/app-sidebar.tsx | 14 +- resources/js/pages/admin/support/index.tsx | 140 +++++++ resources/js/pages/admin/support/show.tsx | 378 ++++++++++++++++++ resources/js/pages/support/index.tsx | 253 ++++++++++++ resources/js/pages/support/show.tsx | 264 ++++++++++++ resources/js/pages/welcome.tsx | 42 +- resources/js/types/index.ts | 1 + resources/js/types/support.ts | 57 +++ routes/web.php | 21 + tests/Feature/SupportConversationTest.php | 209 ++++++++++ 28 files changed, 2200 insertions(+), 14 deletions(-) create mode 100644 app/Http/Controllers/AdminSupportConversationController.php create mode 100644 app/Http/Controllers/SupportAttachmentController.php create mode 100644 app/Http/Controllers/SupportConversationController.php create mode 100644 app/Http/Requests/StoreSupportConversationRequest.php create mode 100644 app/Http/Requests/StoreSupportMessageRequest.php create mode 100644 app/Http/Requests/UpdateSupportConversationStatusRequest.php create mode 100644 app/Models/SupportAttachment.php create mode 100644 app/Models/SupportConversation.php create mode 100644 app/Models/SupportMessage.php create mode 100644 app/Services/Support/SupportMessageCreator.php create mode 100644 database/factories/SupportAttachmentFactory.php create mode 100644 database/factories/SupportConversationFactory.php create mode 100644 database/factories/SupportMessageFactory.php create mode 100644 database/migrations/2026_05_16_180807_create_support_conversations_table.php create mode 100644 database/migrations/2026_05_16_180810_create_support_messages_table.php create mode 100644 database/migrations/2026_05_16_180816_create_support_attachments_table.php create mode 100644 resources/js/pages/admin/support/index.tsx create mode 100644 resources/js/pages/admin/support/show.tsx create mode 100644 resources/js/pages/support/index.tsx create mode 100644 resources/js/pages/support/show.tsx create mode 100644 resources/js/types/support.ts create mode 100644 tests/Feature/SupportConversationTest.php diff --git a/app/Http/Controllers/AdminSupportConversationController.php b/app/Http/Controllers/AdminSupportConversationController.php new file mode 100644 index 0000000..4ef97b0 --- /dev/null +++ b/app/Http/Controllers/AdminSupportConversationController.php @@ -0,0 +1,152 @@ + [ + 'open' => SupportConversation::query()->where('status', SupportConversation::STATUS_OPEN)->count(), + 'closed' => SupportConversation::query()->where('status', SupportConversation::STATUS_CLOSED)->count(), + ], + 'conversations' => SupportConversation::query() + ->with(['latestMessage.user', 'user']) + ->withCount('messages') + ->latest('last_message_at') + ->latest() + ->limit(75) + ->get() + ->map(fn (SupportConversation $conversation) => $this->conversationSummary($conversation)), + ]); + } + + public function show(SupportConversation $conversation): Response + { + $conversation->load([ + 'messages' => fn ($query) => $query->oldest()->with(['attachments', 'user']), + 'user.channel', + ]); + + return Inertia::render('admin/support/show', [ + 'conversation' => $this->conversationPayload($conversation), + ]); + } + + public function reply(StoreSupportMessageRequest $request, SupportConversation $conversation, SupportMessageCreator $messages): RedirectResponse + { + abort_unless($conversation->isOpen(), 422, 'Reopen this conversation before replying.'); + + $validated = $request->validated(); + + $messages->create( + $conversation, + $request->user(), + $validated['body'], + $request->file('attachments', []) ?: [], + ); + + return back()->with('success', 'Reply sent.'); + } + + public function update(UpdateSupportConversationStatusRequest $request, SupportConversation $conversation): RedirectResponse + { + $conversation->forceFill([ + 'status' => $request->validated('status'), + ])->save(); + + return back()->with( + 'success', + $conversation->isOpen() ? 'Conversation reopened.' : 'Conversation closed.', + ); + } + + /** + * @return array + */ + private function conversationPayload(SupportConversation $conversation): array + { + return [ + ...$this->conversationSummary($conversation), + 'user' => [ + 'id' => $conversation->user->id, + 'name' => $conversation->user->name, + 'email' => $conversation->user->email, + 'can_create_channel' => $conversation->user->can_create_channel, + 'suspended_at' => $conversation->user->suspended_at?->toIso8601String(), + 'channel' => $conversation->user->channel ? [ + 'id' => $conversation->user->channel->id, + 'slug' => $conversation->user->channel->slug, + 'display_name' => $conversation->user->channel->display_name, + ] : null, + ], + 'messages' => $conversation->messages->map(fn (SupportMessage $message) => $this->messagePayload($message)), + ]; + } + + /** + * @return array + */ + private function conversationSummary(SupportConversation $conversation): array + { + return [ + 'id' => $conversation->id, + 'category' => $conversation->category, + 'category_label' => SupportConversation::categoryLabels()[$conversation->category] ?? 'Other', + 'subject' => $conversation->subject, + 'status' => $conversation->status, + 'last_message_at' => $conversation->last_message_at?->toIso8601String(), + 'messages_count' => $conversation->messages_count ?? $conversation->messages()->count(), + 'user' => [ + 'id' => $conversation->user->id, + 'name' => $conversation->user->name, + 'email' => $conversation->user->email, + ], + 'latest_message' => $conversation->latestMessage ? $this->messagePreviewPayload($conversation->latestMessage) : null, + ]; + } + + /** + * @return array + */ + private function messagePayload(SupportMessage $message): array + { + return [ + ...$this->messagePreviewPayload($message), + 'attachments' => $message->attachments->map(fn (SupportAttachment $attachment) => [ + 'id' => $attachment->id, + 'original_name' => $attachment->original_name, + 'mime_type' => $attachment->mime_type, + 'size' => $attachment->size, + ]), + ]; + } + + /** + * @return array + */ + private function messagePreviewPayload(SupportMessage $message): array + { + return [ + 'id' => $message->id, + 'body' => $message->body, + 'created_at' => $message->created_at?->toIso8601String(), + 'author' => [ + 'id' => $message->user?->id, + 'name' => $message->user?->name ?? 'Deleted user', + 'is_admin' => (bool) $message->user?->is_admin, + ], + ]; + } +} diff --git a/app/Http/Controllers/SupportAttachmentController.php b/app/Http/Controllers/SupportAttachmentController.php new file mode 100644 index 0000000..f89ef38 --- /dev/null +++ b/app/Http/Controllers/SupportAttachmentController.php @@ -0,0 +1,23 @@ +loadMissing('message.conversation'); + + abort_unless( + $request->user()?->is_admin || $attachment->message->conversation->user_id === $request->user()?->id, + 403, + ); + + return Storage::disk($attachment->disk)->download($attachment->path, $attachment->original_name); + } +} diff --git a/app/Http/Controllers/SupportConversationController.php b/app/Http/Controllers/SupportConversationController.php new file mode 100644 index 0000000..511ebaf --- /dev/null +++ b/app/Http/Controllers/SupportConversationController.php @@ -0,0 +1,161 @@ + $this->categories(), + 'conversations' => $request->user() + ->supportConversations() + ->with(['latestMessage.user']) + ->withCount('messages') + ->latest('last_message_at') + ->latest() + ->limit(50) + ->get() + ->map(fn (SupportConversation $conversation) => $this->conversationSummary($conversation)), + ]); + } + + public function store(StoreSupportConversationRequest $request, SupportMessageCreator $messages): RedirectResponse + { + $validated = $request->validated(); + + $conversation = $messages->createConversation( + $request->user(), + $validated['category'], + $validated['subject'], + $validated['body'], + $request->file('attachments', []) ?: [], + ); + + return redirect() + ->route('support.show', $conversation) + ->with('success', 'Message sent to admin.'); + } + + public function show(Request $request, SupportConversation $conversation): Response + { + $this->authorizeUserConversation($request, $conversation); + + $conversation->load([ + 'messages' => fn ($query) => $query->oldest()->with(['attachments', 'user']), + 'user', + ]); + + return Inertia::render('support/show', [ + 'conversation' => $this->conversationPayload($conversation), + ]); + } + + public function reply(StoreSupportMessageRequest $request, SupportConversation $conversation, SupportMessageCreator $messages): RedirectResponse + { + $this->authorizeUserConversation($request, $conversation); + abort_unless($conversation->isOpen(), 422, 'This conversation is closed.'); + + $validated = $request->validated(); + + $messages->create( + $conversation, + $request->user(), + $validated['body'], + $request->file('attachments', []) ?: [], + ); + + return back()->with('success', 'Reply sent.'); + } + + private function authorizeUserConversation(Request $request, SupportConversation $conversation): void + { + abort_unless($conversation->user_id === $request->user()->id, 403); + } + + /** + * @return array + */ + private function categories(): array + { + return collect(SupportConversation::categoryLabels()) + ->map(fn (string $label, string $value): array => [ + 'value' => $value, + 'label' => $label, + ]) + ->values() + ->all(); + } + + /** + * @return array + */ + private function conversationPayload(SupportConversation $conversation): array + { + return [ + ...$this->conversationSummary($conversation), + 'messages' => $conversation->messages->map(fn (SupportMessage $message) => $this->messagePayload($message)), + ]; + } + + /** + * @return array + */ + private function conversationSummary(SupportConversation $conversation): array + { + return [ + 'id' => $conversation->id, + 'category' => $conversation->category, + 'category_label' => SupportConversation::categoryLabels()[$conversation->category] ?? 'Other', + 'subject' => $conversation->subject, + 'status' => $conversation->status, + 'last_message_at' => $conversation->last_message_at?->toIso8601String(), + 'messages_count' => $conversation->messages_count ?? $conversation->messages()->count(), + 'latest_message' => $conversation->latestMessage ? $this->messagePreviewPayload($conversation->latestMessage) : null, + ]; + } + + /** + * @return array + */ + private function messagePayload(SupportMessage $message): array + { + return [ + ...$this->messagePreviewPayload($message), + 'attachments' => $message->attachments->map(fn (SupportAttachment $attachment) => [ + 'id' => $attachment->id, + 'original_name' => $attachment->original_name, + 'mime_type' => $attachment->mime_type, + 'size' => $attachment->size, + ]), + ]; + } + + /** + * @return array + */ + private function messagePreviewPayload(SupportMessage $message): array + { + return [ + 'id' => $message->id, + 'body' => $message->body, + 'created_at' => $message->created_at?->toIso8601String(), + 'author' => [ + 'id' => $message->user?->id, + 'name' => $message->user?->name ?? 'Deleted user', + 'is_admin' => (bool) $message->user?->is_admin, + ], + ]; + } +} diff --git a/app/Http/Requests/StoreSupportConversationRequest.php b/app/Http/Requests/StoreSupportConversationRequest.php new file mode 100644 index 0000000..bb1038e --- /dev/null +++ b/app/Http/Requests/StoreSupportConversationRequest.php @@ -0,0 +1,29 @@ +user() !== null; + } + + /** + * @return array> + */ + public function rules(): array + { + return [ + 'category' => ['required', 'string', Rule::in(SupportConversation::categories())], + 'subject' => ['required', 'string', 'max:120'], + 'body' => ['required', 'string', 'max:2000'], + 'attachments' => ['nullable', 'array', 'max:3'], + 'attachments.*' => ['file', 'max:10240', 'mimes:jpg,jpeg,png,webp,gif,pdf,txt,log,json,zip'], + ]; + } +} diff --git a/app/Http/Requests/StoreSupportMessageRequest.php b/app/Http/Requests/StoreSupportMessageRequest.php new file mode 100644 index 0000000..b419abb --- /dev/null +++ b/app/Http/Requests/StoreSupportMessageRequest.php @@ -0,0 +1,25 @@ +user() !== null; + } + + /** + * @return array> + */ + public function rules(): array + { + return [ + 'body' => ['required', 'string', 'max:2000'], + 'attachments' => ['nullable', 'array', 'max:3'], + 'attachments.*' => ['file', 'max:10240', 'mimes:jpg,jpeg,png,webp,gif,pdf,txt,log,json,zip'], + ]; + } +} diff --git a/app/Http/Requests/UpdateSupportConversationStatusRequest.php b/app/Http/Requests/UpdateSupportConversationStatusRequest.php new file mode 100644 index 0000000..7be1028 --- /dev/null +++ b/app/Http/Requests/UpdateSupportConversationStatusRequest.php @@ -0,0 +1,25 @@ +user()?->is_admin; + } + + /** + * @return array> + */ + public function rules(): array + { + return [ + 'status' => ['required', 'string', Rule::in(SupportConversation::statuses())], + ]; + } +} diff --git a/app/Models/SupportAttachment.php b/app/Models/SupportAttachment.php new file mode 100644 index 0000000..95def99 --- /dev/null +++ b/app/Models/SupportAttachment.php @@ -0,0 +1,21 @@ + */ + use HasFactory; + + public function message(): BelongsTo + { + return $this->belongsTo(SupportMessage::class, 'support_message_id'); + } +} diff --git a/app/Models/SupportConversation.php b/app/Models/SupportConversation.php new file mode 100644 index 0000000..54ead81 --- /dev/null +++ b/app/Models/SupportConversation.php @@ -0,0 +1,92 @@ + */ + use HasFactory; + + public const CATEGORY_BUG = 'bug'; + + public const CATEGORY_SUGGESTION = 'suggestion'; + + public const CATEGORY_CHANNEL_REQUEST = 'channel_request'; + + public const CATEGORY_OTHER = 'other'; + + public const STATUS_OPEN = 'open'; + + public const STATUS_CLOSED = 'closed'; + + /** + * @return array + */ + public static function categoryLabels(): array + { + return [ + self::CATEGORY_BUG => 'Bug report', + self::CATEGORY_SUGGESTION => 'Suggestion', + self::CATEGORY_CHANNEL_REQUEST => 'Channel request', + self::CATEGORY_OTHER => 'Other', + ]; + } + + /** + * @return array + */ + public static function categories(): array + { + return array_keys(self::categoryLabels()); + } + + /** + * @return array + */ + public static function statuses(): array + { + return [ + self::STATUS_OPEN, + self::STATUS_CLOSED, + ]; + } + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'last_message_at' => 'datetime', + ]; + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + public function messages(): HasMany + { + return $this->hasMany(SupportMessage::class); + } + + public function latestMessage(): HasOne + { + return $this->hasOne(SupportMessage::class)->latestOfMany(); + } + + public function isOpen(): bool + { + return $this->status === self::STATUS_OPEN; + } +} diff --git a/app/Models/SupportMessage.php b/app/Models/SupportMessage.php new file mode 100644 index 0000000..2af7f69 --- /dev/null +++ b/app/Models/SupportMessage.php @@ -0,0 +1,32 @@ + */ + use HasFactory; + + public function conversation(): BelongsTo + { + return $this->belongsTo(SupportConversation::class, 'support_conversation_id'); + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + public function attachments(): HasMany + { + return $this->hasMany(SupportAttachment::class); + } +} diff --git a/app/Models/User.php b/app/Models/User.php index 74be875..9237731 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -57,6 +57,16 @@ class User extends Authenticatable return $this->hasMany(Follow::class); } + public function supportConversations(): HasMany + { + return $this->hasMany(SupportConversation::class); + } + + public function supportMessages(): HasMany + { + return $this->hasMany(SupportMessage::class); + } + public function isSuspended(): bool { return $this->suspended_at !== null; diff --git a/app/Services/Support/SupportMessageCreator.php b/app/Services/Support/SupportMessageCreator.php new file mode 100644 index 0000000..a28c88f --- /dev/null +++ b/app/Services/Support/SupportMessageCreator.php @@ -0,0 +1,62 @@ + $attachments + */ + public function createConversation(User $author, string $category, string $subject, string $body, array $attachments = []): SupportConversation + { + return DB::transaction(function () use ($attachments, $author, $body, $category, $subject): SupportConversation { + $conversation = $author->supportConversations()->create([ + 'category' => $category, + 'subject' => trim($subject), + 'status' => SupportConversation::STATUS_OPEN, + ]); + + $this->create($conversation, $author, $body, $attachments); + + return $conversation; + }); + } + + /** + * @param array $attachments + */ + public function create(SupportConversation $conversation, User $author, string $body, array $attachments = []): SupportMessage + { + return DB::transaction(function () use ($attachments, $author, $body, $conversation): SupportMessage { + $message = $conversation->messages()->create([ + 'user_id' => $author->id, + 'body' => trim($body), + ]); + + foreach ($attachments as $attachment) { + $path = $attachment->store("support/{$conversation->id}/{$message->id}", 'local'); + + $message->attachments()->create([ + 'disk' => 'local', + 'path' => $path, + 'original_name' => Str::limit($attachment->getClientOriginalName(), 255, ''), + 'mime_type' => $attachment->getMimeType() ?: 'application/octet-stream', + 'size' => $attachment->getSize() ?: 0, + ]); + } + + $conversation->forceFill([ + 'last_message_at' => now(), + ])->save(); + + return $message; + }); + } +} diff --git a/database/factories/SupportAttachmentFactory.php b/database/factories/SupportAttachmentFactory.php new file mode 100644 index 0000000..19b1faf --- /dev/null +++ b/database/factories/SupportAttachmentFactory.php @@ -0,0 +1,30 @@ + + */ +class SupportAttachmentFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'support_message_id' => SupportMessage::factory(), + 'disk' => 'local', + 'path' => 'support/testing/debug.log', + 'original_name' => 'debug.log', + 'mime_type' => 'text/plain', + 'size' => 128, + ]; + } +} diff --git a/database/factories/SupportConversationFactory.php b/database/factories/SupportConversationFactory.php new file mode 100644 index 0000000..7a65095 --- /dev/null +++ b/database/factories/SupportConversationFactory.php @@ -0,0 +1,29 @@ + + */ +class SupportConversationFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'user_id' => User::factory(), + 'category' => fake()->randomElement(SupportConversation::categories()), + 'subject' => fake()->sentence(4), + 'status' => SupportConversation::STATUS_OPEN, + 'last_message_at' => now(), + ]; + } +} diff --git a/database/factories/SupportMessageFactory.php b/database/factories/SupportMessageFactory.php new file mode 100644 index 0000000..62778f9 --- /dev/null +++ b/database/factories/SupportMessageFactory.php @@ -0,0 +1,28 @@ + + */ +class SupportMessageFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'support_conversation_id' => SupportConversation::factory(), + 'user_id' => User::factory(), + 'body' => fake()->paragraph(), + ]; + } +} diff --git a/database/migrations/2026_05_16_180807_create_support_conversations_table.php b/database/migrations/2026_05_16_180807_create_support_conversations_table.php new file mode 100644 index 0000000..91016a2 --- /dev/null +++ b/database/migrations/2026_05_16_180807_create_support_conversations_table.php @@ -0,0 +1,32 @@ +id(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->string('category')->index(); + $table->string('subject', 120); + $table->string('status')->default('open')->index(); + $table->timestamp('last_message_at')->nullable()->index(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('support_conversations'); + } +}; diff --git a/database/migrations/2026_05_16_180810_create_support_messages_table.php b/database/migrations/2026_05_16_180810_create_support_messages_table.php new file mode 100644 index 0000000..d2158fe --- /dev/null +++ b/database/migrations/2026_05_16_180810_create_support_messages_table.php @@ -0,0 +1,30 @@ +id(); + $table->foreignId('support_conversation_id')->constrained()->cascadeOnDelete(); + $table->foreignId('user_id')->nullable()->constrained()->nullOnDelete(); + $table->text('body'); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('support_messages'); + } +}; diff --git a/database/migrations/2026_05_16_180816_create_support_attachments_table.php b/database/migrations/2026_05_16_180816_create_support_attachments_table.php new file mode 100644 index 0000000..e47296c --- /dev/null +++ b/database/migrations/2026_05_16_180816_create_support_attachments_table.php @@ -0,0 +1,33 @@ +id(); + $table->foreignId('support_message_id')->constrained()->cascadeOnDelete(); + $table->string('disk')->default('local'); + $table->string('path'); + $table->string('original_name'); + $table->string('mime_type'); + $table->unsignedBigInteger('size'); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('support_attachments'); + } +}; diff --git a/resources/js/components/app-header.tsx b/resources/js/components/app-header.tsx index c158128..86ec274 100644 --- a/resources/js/components/app-header.tsx +++ b/resources/js/components/app-header.tsx @@ -1,5 +1,12 @@ import { Link, usePage } from '@inertiajs/react'; -import { LayoutGrid, Menu, Radio, Search, Shield } from 'lucide-react'; +import { + LayoutGrid, + LifeBuoy, + Menu, + Radio, + Search, + Shield, +} from 'lucide-react'; import AppLogo from '@/components/app-logo'; import AppLogoIcon from '@/components/app-logo-icon'; import { Breadcrumbs } from '@/components/breadcrumbs'; @@ -34,6 +41,8 @@ import { useInitials } from '@/hooks/use-initials'; import { cn } from '@/lib/utils'; import { dashboard, home } from '@/routes'; import { dashboard as adminDashboard } from '@/routes/admin'; +import { index as adminSupportIndex } from '@/routes/admin/support'; +import { index as supportIndex } from '@/routes/support'; import type { BreadcrumbItem, NavItem } from '@/types'; type Props = { @@ -58,6 +67,11 @@ export function AppHeader({ breadcrumbs = [] }: Props) { href: dashboard(), icon: LayoutGrid, }, + { + title: 'Contact', + href: supportIndex(), + icon: LifeBuoy, + }, ]; if (auth.user?.is_admin) { @@ -66,6 +80,11 @@ export function AppHeader({ breadcrumbs = [] }: Props) { href: adminDashboard(), icon: Shield, }); + mainNavItems.push({ + title: 'Support', + href: adminSupportIndex(), + icon: LifeBuoy, + }); } return ( diff --git a/resources/js/components/app-sidebar.tsx b/resources/js/components/app-sidebar.tsx index e568319..ffee8b5 100644 --- a/resources/js/components/app-sidebar.tsx +++ b/resources/js/components/app-sidebar.tsx @@ -1,5 +1,5 @@ import { Link, usePage } from '@inertiajs/react'; -import { LayoutGrid, Radio, Shield } from 'lucide-react'; +import { LayoutGrid, LifeBuoy, Radio, Shield } from 'lucide-react'; import AppLogo from '@/components/app-logo'; import { NavMain } from '@/components/nav-main'; import { NavUser } from '@/components/nav-user'; @@ -14,6 +14,8 @@ import { } from '@/components/ui/sidebar'; import { dashboard, home } from '@/routes'; import { dashboard as adminDashboard } from '@/routes/admin'; +import { index as adminSupportIndex } from '@/routes/admin/support'; +import { index as supportIndex } from '@/routes/support'; import type { NavItem } from '@/types'; export function AppSidebar() { @@ -29,6 +31,11 @@ export function AppSidebar() { href: dashboard(), icon: LayoutGrid, }, + { + title: 'Contact', + href: supportIndex(), + icon: LifeBuoy, + }, ]; if (auth.user?.is_admin) { @@ -37,6 +44,11 @@ export function AppSidebar() { href: adminDashboard(), icon: Shield, }); + mainNavItems.push({ + title: 'Support', + href: adminSupportIndex(), + icon: LifeBuoy, + }); } return ( diff --git a/resources/js/pages/admin/support/index.tsx b/resources/js/pages/admin/support/index.tsx new file mode 100644 index 0000000..3573314 --- /dev/null +++ b/resources/js/pages/admin/support/index.tsx @@ -0,0 +1,140 @@ +import { Head, Link } from '@inertiajs/react'; +import { Inbox, MessageSquare, Shield, User } from 'lucide-react'; +import { cn } from '@/lib/utils'; +import { + index as adminSupportIndex, + show as showAdminSupport, +} from '@/routes/admin/support'; +import type { SupportConversationSummary } from '@/types'; + +type Props = { + stats: { + open: number; + closed: number; + }; + conversations: SupportConversationSummary[]; +}; + +export default function AdminSupportIndex({ stats, conversations }: Props) { + return ( + <> + +
+
+
+ + + +
+

+ Support inbox +

+

+ Contact threads from users and channel request + notes. +

+
+
+
+ + +
+
+ +
+
+
+ +

Conversations

+
+ + {conversations.length} rows + +
+
+ {conversations.map((conversation) => ( + +
+
+ + + {conversation.category_label} + +
+
+ {conversation.subject} +
+
+ + + {conversation.user?.name} -{' '} + {conversation.user?.email} + +
+ {conversation.latest_message && ( +

+ { + conversation.latest_message + .author.name + } + : {conversation.latest_message.body} +

+ )} +
+
+ + {conversation.messages_count} +
+ + ))} + + {conversations.length === 0 && ( +
+ No support conversations found. +
+ )} +
+
+
+ + ); +} + +AdminSupportIndex.layout = { + breadcrumbs: [ + { + title: 'Support inbox', + href: adminSupportIndex(), + }, + ], +}; + +function Stat({ label, value }: { label: string; value: number }) { + return ( +
+ {label}{' '} + {value} +
+ ); +} + +function StatusBadge({ status }: { status: 'open' | 'closed' }) { + return ( + + {status === 'open' ? 'OPEN' : 'CLOSED'} + + ); +} diff --git a/resources/js/pages/admin/support/show.tsx b/resources/js/pages/admin/support/show.tsx new file mode 100644 index 0000000..eb5ecf4 --- /dev/null +++ b/resources/js/pages/admin/support/show.tsx @@ -0,0 +1,378 @@ +import { Head, Link, router, useForm } from '@inertiajs/react'; +import { + ArrowLeft, + FileDown, + FileUp, + KeyRound, + Lock, + Send, + Unlock, + User, +} from 'lucide-react'; +import type { FormEvent, ReactNode } from 'react'; +import { useRef } from 'react'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { cn } from '@/lib/utils'; +import { dashboard as adminDashboard } from '@/routes/admin'; +import { + index as adminSupportIndex, + update as updateConversation, +} from '@/routes/admin/support'; +import { store as storeAdminMessage } from '@/routes/admin/support/messages'; +import { show as showChannel } from '@/routes/channels'; +import { show as showAttachment } from '@/routes/support/attachments'; +import type { + SupportAttachment, + SupportConversation, + SupportMessage, +} from '@/types'; + +type Props = { + conversation: SupportConversation; +}; + +export default function AdminSupportShow({ conversation }: Props) { + const fileInputRef = useRef(null); + const form = useForm({ + body: '', + attachments: [] as File[], + }); + const isClosed = conversation.status === 'closed'; + + function submit(event: FormEvent) { + event.preventDefault(); + + form.post(storeAdminMessage.url(conversation.id), { + forceFormData: true, + preserveScroll: true, + onSuccess: () => { + form.reset(); + + if (fileInputRef.current) { + fileInputRef.current.value = ''; + } + }, + }); + } + + function setStatus(status: 'open' | 'closed') { + router.patch( + updateConversation(conversation.id), + { status }, + { preserveScroll: true }, + ); + } + + function setAttachments(files: FileList | null) { + form.setData('attachments', Array.from(files ?? []).slice(0, 3)); + } + + return ( + <> + +
+
+
+ +
+
+
+ + + {conversation.category_label} + +
+

+ {conversation.subject} +

+
+ +
+
+ +
+ {conversation.messages.map((message) => ( + + ))} +
+ +
+ {isClosed ? ( +
+ + Reopen the conversation to reply. +
+ ) : ( +
+ +