Implemented the authenticated admin contact system.
This commit is contained in:
152
app/Http/Controllers/AdminSupportConversationController.php
Normal file
152
app/Http/Controllers/AdminSupportConversationController.php
Normal file
@@ -0,0 +1,152 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Http\Requests\StoreSupportMessageRequest;
|
||||
use App\Http\Requests\UpdateSupportConversationStatusRequest;
|
||||
use App\Models\SupportAttachment;
|
||||
use App\Models\SupportConversation;
|
||||
use App\Models\SupportMessage;
|
||||
use App\Services\Support\SupportMessageCreator;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
class AdminSupportConversationController extends Controller
|
||||
{
|
||||
public function index(): Response
|
||||
{
|
||||
return Inertia::render('admin/support/index', [
|
||||
'stats' => [
|
||||
'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<string, mixed>
|
||||
*/
|
||||
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<string, mixed>
|
||||
*/
|
||||
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<string, mixed>
|
||||
*/
|
||||
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<string, mixed>
|
||||
*/
|
||||
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,
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
23
app/Http/Controllers/SupportAttachmentController.php
Normal file
23
app/Http/Controllers/SupportAttachmentController.php
Normal file
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\SupportAttachment;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
|
||||
class SupportAttachmentController extends Controller
|
||||
{
|
||||
public function __invoke(Request $request, SupportAttachment $attachment): StreamedResponse
|
||||
{
|
||||
$attachment->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);
|
||||
}
|
||||
}
|
||||
161
app/Http/Controllers/SupportConversationController.php
Normal file
161
app/Http/Controllers/SupportConversationController.php
Normal file
@@ -0,0 +1,161 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Http\Requests\StoreSupportConversationRequest;
|
||||
use App\Http\Requests\StoreSupportMessageRequest;
|
||||
use App\Models\SupportAttachment;
|
||||
use App\Models\SupportConversation;
|
||||
use App\Models\SupportMessage;
|
||||
use App\Services\Support\SupportMessageCreator;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
class SupportConversationController extends Controller
|
||||
{
|
||||
public function index(Request $request): Response
|
||||
{
|
||||
return Inertia::render('support/index', [
|
||||
'categories' => $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<int, array{value: string, label: string}>
|
||||
*/
|
||||
private function categories(): array
|
||||
{
|
||||
return collect(SupportConversation::categoryLabels())
|
||||
->map(fn (string $label, string $value): array => [
|
||||
'value' => $value,
|
||||
'label' => $label,
|
||||
])
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function conversationPayload(SupportConversation $conversation): array
|
||||
{
|
||||
return [
|
||||
...$this->conversationSummary($conversation),
|
||||
'messages' => $conversation->messages->map(fn (SupportMessage $message) => $this->messagePayload($message)),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
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<string, mixed>
|
||||
*/
|
||||
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<string, mixed>
|
||||
*/
|
||||
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,
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
29
app/Http/Requests/StoreSupportConversationRequest.php
Normal file
29
app/Http/Requests/StoreSupportConversationRequest.php
Normal file
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use App\Models\SupportConversation;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class StoreSupportConversationRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return $this->user() !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, array<int, mixed>>
|
||||
*/
|
||||
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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
25
app/Http/Requests/StoreSupportMessageRequest.php
Normal file
25
app/Http/Requests/StoreSupportMessageRequest.php
Normal file
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class StoreSupportMessageRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return $this->user() !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, array<int, string>>
|
||||
*/
|
||||
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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
25
app/Http/Requests/UpdateSupportConversationStatusRequest.php
Normal file
25
app/Http/Requests/UpdateSupportConversationStatusRequest.php
Normal file
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use App\Models\SupportConversation;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class UpdateSupportConversationStatusRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return (bool) $this->user()?->is_admin;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, array<int, mixed>>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'status' => ['required', 'string', Rule::in(SupportConversation::statuses())],
|
||||
];
|
||||
}
|
||||
}
|
||||
21
app/Models/SupportAttachment.php
Normal file
21
app/Models/SupportAttachment.php
Normal file
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Database\Factories\SupportAttachmentFactory;
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
#[Fillable(['support_message_id', 'disk', 'path', 'original_name', 'mime_type', 'size'])]
|
||||
class SupportAttachment extends Model
|
||||
{
|
||||
/** @use HasFactory<SupportAttachmentFactory> */
|
||||
use HasFactory;
|
||||
|
||||
public function message(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(SupportMessage::class, 'support_message_id');
|
||||
}
|
||||
}
|
||||
92
app/Models/SupportConversation.php
Normal file
92
app/Models/SupportConversation.php
Normal file
@@ -0,0 +1,92 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Database\Factories\SupportConversationFactory;
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasOne;
|
||||
|
||||
#[Fillable(['user_id', 'category', 'subject', 'status', 'last_message_at'])]
|
||||
class SupportConversation extends Model
|
||||
{
|
||||
/** @use HasFactory<SupportConversationFactory> */
|
||||
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<string, string>
|
||||
*/
|
||||
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<int, string>
|
||||
*/
|
||||
public static function categories(): array
|
||||
{
|
||||
return array_keys(self::categoryLabels());
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public static function statuses(): array
|
||||
{
|
||||
return [
|
||||
self::STATUS_OPEN,
|
||||
self::STATUS_CLOSED,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
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;
|
||||
}
|
||||
}
|
||||
32
app/Models/SupportMessage.php
Normal file
32
app/Models/SupportMessage.php
Normal file
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Database\Factories\SupportMessageFactory;
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
#[Fillable(['support_conversation_id', 'user_id', 'body'])]
|
||||
class SupportMessage extends Model
|
||||
{
|
||||
/** @use HasFactory<SupportMessageFactory> */
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
62
app/Services/Support/SupportMessageCreator.php
Normal file
62
app/Services/Support/SupportMessageCreator.php
Normal file
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Support;
|
||||
|
||||
use App\Models\SupportConversation;
|
||||
use App\Models\SupportMessage;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class SupportMessageCreator
|
||||
{
|
||||
/**
|
||||
* @param array<int, UploadedFile> $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<int, UploadedFile> $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;
|
||||
});
|
||||
}
|
||||
}
|
||||
30
database/factories/SupportAttachmentFactory.php
Normal file
30
database/factories/SupportAttachmentFactory.php
Normal file
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\SupportAttachment;
|
||||
use App\Models\SupportMessage;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/**
|
||||
* @extends Factory<SupportAttachment>
|
||||
*/
|
||||
class SupportAttachmentFactory extends Factory
|
||||
{
|
||||
/**
|
||||
* Define the model's default state.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
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,
|
||||
];
|
||||
}
|
||||
}
|
||||
29
database/factories/SupportConversationFactory.php
Normal file
29
database/factories/SupportConversationFactory.php
Normal file
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\SupportConversation;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/**
|
||||
* @extends Factory<SupportConversation>
|
||||
*/
|
||||
class SupportConversationFactory extends Factory
|
||||
{
|
||||
/**
|
||||
* Define the model's default state.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
28
database/factories/SupportMessageFactory.php
Normal file
28
database/factories/SupportMessageFactory.php
Normal file
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\SupportConversation;
|
||||
use App\Models\SupportMessage;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/**
|
||||
* @extends Factory<SupportMessage>
|
||||
*/
|
||||
class SupportMessageFactory extends Factory
|
||||
{
|
||||
/**
|
||||
* Define the model's default state.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'support_conversation_id' => SupportConversation::factory(),
|
||||
'user_id' => User::factory(),
|
||||
'body' => fake()->paragraph(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('support_conversations', function (Blueprint $table) {
|
||||
$table->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');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('support_messages', function (Blueprint $table) {
|
||||
$table->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');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('support_attachments', function (Blueprint $table) {
|
||||
$table->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');
|
||||
}
|
||||
};
|
||||
@@ -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 (
|
||||
|
||||
@@ -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 (
|
||||
|
||||
140
resources/js/pages/admin/support/index.tsx
Normal file
140
resources/js/pages/admin/support/index.tsx
Normal file
@@ -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 (
|
||||
<>
|
||||
<Head title="Support inbox" />
|
||||
<div className="grid gap-6 p-4 md:p-6">
|
||||
<div className="flex flex-col gap-4 rounded-md border bg-card p-5 md:flex-row md:items-center md:justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="flex size-10 items-center justify-center rounded-md bg-primary text-primary-foreground">
|
||||
<Inbox className="size-5" />
|
||||
</span>
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold">
|
||||
Support inbox
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Contact threads from users and channel request
|
||||
notes.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 text-sm">
|
||||
<Stat label="Open" value={stats.open} />
|
||||
<Stat label="Closed" value={stats.closed} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section className="rounded-md border bg-card p-5 shadow-sm">
|
||||
<div className="mb-4 flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Shield className="size-5 text-primary" />
|
||||
<h2 className="font-semibold">Conversations</h2>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{conversations.length} rows
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
{conversations.map((conversation) => (
|
||||
<Link
|
||||
key={conversation.id}
|
||||
href={showAdminSupport(conversation.id)}
|
||||
className="grid gap-3 rounded-md border bg-background p-3 transition hover:border-primary/50 md:grid-cols-[minmax(0,1fr)_auto]"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<StatusBadge
|
||||
status={conversation.status}
|
||||
/>
|
||||
<span className="rounded-md border px-2 py-0.5 text-xs text-muted-foreground">
|
||||
{conversation.category_label}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-2 truncate font-medium">
|
||||
{conversation.subject}
|
||||
</div>
|
||||
<div className="mt-1 flex min-w-0 items-center gap-2 text-sm text-muted-foreground">
|
||||
<User className="size-4 shrink-0" />
|
||||
<span className="truncate">
|
||||
{conversation.user?.name} -{' '}
|
||||
{conversation.user?.email}
|
||||
</span>
|
||||
</div>
|
||||
{conversation.latest_message && (
|
||||
<p className="mt-2 line-clamp-2 text-sm text-muted-foreground">
|
||||
{
|
||||
conversation.latest_message
|
||||
.author.name
|
||||
}
|
||||
: {conversation.latest_message.body}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground md:justify-end">
|
||||
<MessageSquare className="size-4" />
|
||||
{conversation.messages_count}
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
|
||||
{conversations.length === 0 && (
|
||||
<div className="rounded-md border border-dashed bg-background p-6 text-sm text-muted-foreground">
|
||||
No support conversations found.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
AdminSupportIndex.layout = {
|
||||
breadcrumbs: [
|
||||
{
|
||||
title: 'Support inbox',
|
||||
href: adminSupportIndex(),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
function Stat({ label, value }: { label: string; value: number }) {
|
||||
return (
|
||||
<div className="rounded-md border bg-background px-3 py-2">
|
||||
<span className="text-muted-foreground">{label}</span>{' '}
|
||||
<span className="font-semibold">{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status: 'open' | 'closed' }) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'rounded-md border px-2 py-0.5 text-xs font-medium',
|
||||
status === 'open'
|
||||
? 'border-success/40 bg-success/10 text-success'
|
||||
: 'text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
{status === 'open' ? 'OPEN' : 'CLOSED'}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
378
resources/js/pages/admin/support/show.tsx
Normal file
378
resources/js/pages/admin/support/show.tsx
Normal file
@@ -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<HTMLInputElement>(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 (
|
||||
<>
|
||||
<Head title={conversation.subject} />
|
||||
<div className="grid gap-6 p-4 md:p-6 xl:grid-cols-[minmax(0,1fr)_340px]">
|
||||
<div className="grid min-w-0 gap-6">
|
||||
<div className="rounded-md border bg-card p-5 shadow-sm">
|
||||
<Button
|
||||
asChild
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="mb-4"
|
||||
>
|
||||
<Link href={adminSupportIndex()}>
|
||||
<ArrowLeft className="size-4" />
|
||||
Support inbox
|
||||
</Link>
|
||||
</Button>
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
|
||||
<div className="min-w-0">
|
||||
<div className="mb-2 flex flex-wrap items-center gap-2">
|
||||
<StatusBadge status={conversation.status} />
|
||||
<span className="rounded-md border px-2 py-0.5 text-xs text-muted-foreground">
|
||||
{conversation.category_label}
|
||||
</span>
|
||||
</div>
|
||||
<h1 className="text-2xl font-semibold break-words">
|
||||
{conversation.subject}
|
||||
</h1>
|
||||
</div>
|
||||
<Button
|
||||
variant={isClosed ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
setStatus(isClosed ? 'open' : 'closed')
|
||||
}
|
||||
>
|
||||
{isClosed ? (
|
||||
<Unlock className="size-4" />
|
||||
) : (
|
||||
<Lock className="size-4" />
|
||||
)}
|
||||
{isClosed ? 'Reopen' : 'Close'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section className="grid gap-3">
|
||||
{conversation.messages.map((message) => (
|
||||
<MessageBubble key={message.id} message={message} />
|
||||
))}
|
||||
</section>
|
||||
|
||||
<section className="rounded-md border bg-card p-5 shadow-sm">
|
||||
{isClosed ? (
|
||||
<div className="flex items-start gap-3 rounded-md border border-dashed bg-background p-4 text-sm text-muted-foreground">
|
||||
<Lock className="mt-0.5 size-4 shrink-0" />
|
||||
<span>Reopen the conversation to reply.</span>
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={submit} className="grid gap-4">
|
||||
<Field label="Reply" error={form.errors.body}>
|
||||
<textarea
|
||||
value={form.data.body}
|
||||
onChange={(event) =>
|
||||
form.setData(
|
||||
'body',
|
||||
event.target.value,
|
||||
)
|
||||
}
|
||||
className="min-h-32 rounded-md border bg-background px-3 py-2 text-sm shadow-xs outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50"
|
||||
maxLength={2000}
|
||||
/>
|
||||
</Field>
|
||||
<Field
|
||||
label="Attachments"
|
||||
error={form.errors.attachments}
|
||||
>
|
||||
<Input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
onChange={(event) =>
|
||||
setAttachments(event.target.files)
|
||||
}
|
||||
/>
|
||||
{form.data.attachments.length > 0 && (
|
||||
<div className="grid gap-1 text-xs text-muted-foreground">
|
||||
{form.data.attachments.map(
|
||||
(file) => (
|
||||
<span
|
||||
key={`${file.name}-${file.size}`}
|
||||
className="flex min-w-0 items-center gap-2"
|
||||
>
|
||||
<FileUp className="size-3.5 shrink-0" />
|
||||
<span className="truncate">
|
||||
{file.name}
|
||||
</span>
|
||||
</span>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{form.progress && (
|
||||
<progress
|
||||
value={form.progress.percentage}
|
||||
max="100"
|
||||
className="h-1.5 w-full"
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={form.processing}
|
||||
className="w-fit"
|
||||
>
|
||||
<Send className="size-4" />
|
||||
Send reply
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<aside className="grid content-start gap-6">
|
||||
<section className="rounded-md border bg-card p-5 shadow-sm">
|
||||
<div className="mb-4 flex items-center gap-2">
|
||||
<User className="size-5 text-primary" />
|
||||
<h2 className="font-semibold">User</h2>
|
||||
</div>
|
||||
<div className="grid gap-3 text-sm">
|
||||
<div>
|
||||
<div className="font-medium">
|
||||
{conversation.user?.name}
|
||||
</div>
|
||||
<div className="text-muted-foreground">
|
||||
{conversation.user?.email}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<span
|
||||
className={cn(
|
||||
'rounded-md border px-2 py-0.5 text-xs font-medium',
|
||||
conversation.user?.can_create_channel
|
||||
? 'border-success/40 bg-success/10 text-success'
|
||||
: 'text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
{conversation.user?.can_create_channel
|
||||
? 'CAN CREATE CHANNEL'
|
||||
: 'NEEDS CHANNEL GRANT'}
|
||||
</span>
|
||||
{conversation.user?.suspended_at && (
|
||||
<span className="rounded-md border border-destructive/40 bg-destructive/10 px-2 py-0.5 text-xs font-medium text-destructive">
|
||||
SUSPENDED
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{conversation.user?.channel && (
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link
|
||||
href={showChannel(
|
||||
conversation.user.channel.slug,
|
||||
)}
|
||||
>
|
||||
Open channel
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{conversation.category === 'channel_request' && (
|
||||
<section className="rounded-md border bg-card p-5 shadow-sm">
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<KeyRound className="size-5 text-primary" />
|
||||
<h2 className="font-semibold">
|
||||
Channel request
|
||||
</h2>
|
||||
</div>
|
||||
<p className="mb-4 text-sm text-muted-foreground">
|
||||
Creator access grants stay in the moderation
|
||||
console.
|
||||
</p>
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link href={adminDashboard()}>
|
||||
Open moderation
|
||||
</Link>
|
||||
</Button>
|
||||
</section>
|
||||
)}
|
||||
</aside>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
AdminSupportShow.layout = {
|
||||
breadcrumbs: [
|
||||
{
|
||||
title: 'Support inbox',
|
||||
href: adminSupportIndex(),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
function MessageBubble({ message }: { message: SupportMessage }) {
|
||||
const isAdmin = message.author.is_admin;
|
||||
|
||||
return (
|
||||
<article
|
||||
className={cn(
|
||||
'grid max-w-3xl gap-3 rounded-md border bg-card p-4 shadow-sm',
|
||||
isAdmin && 'justify-self-end border-primary/30',
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2 text-sm">
|
||||
<span className="font-medium">{message.author.name}</span>
|
||||
<span className="rounded-md border px-2 py-0.5 text-xs text-muted-foreground">
|
||||
{isAdmin ? 'ADMIN' : 'USER'}
|
||||
</span>
|
||||
{message.created_at && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{formatDateTime(message.created_at)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm leading-6 break-words whitespace-pre-wrap">
|
||||
{message.body}
|
||||
</p>
|
||||
{message.attachments && message.attachments.length > 0 && (
|
||||
<AttachmentList attachments={message.attachments} />
|
||||
)}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
function AttachmentList({ attachments }: { attachments: SupportAttachment[] }) {
|
||||
return (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{attachments.map((attachment) => (
|
||||
<a
|
||||
key={attachment.id}
|
||||
href={showAttachment.url(attachment.id)}
|
||||
className="inline-flex max-w-full items-center gap-2 rounded-md border bg-background px-2 py-1 text-xs hover:bg-accent hover:text-accent-foreground"
|
||||
>
|
||||
<FileDown className="size-3.5 shrink-0" />
|
||||
<span className="truncate">{attachment.original_name}</span>
|
||||
<span className="shrink-0 text-muted-foreground">
|
||||
{formatBytes(attachment.size)}
|
||||
</span>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({
|
||||
label,
|
||||
error,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
error?: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<label className="grid gap-2">
|
||||
<Label>{label}</Label>
|
||||
{children}
|
||||
{error && <span className="text-sm text-destructive">{error}</span>}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status: 'open' | 'closed' }) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'rounded-md border px-2 py-0.5 text-xs font-medium',
|
||||
status === 'open'
|
||||
? 'border-success/40 bg-success/10 text-success'
|
||||
: 'text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
{status === 'open' ? 'OPEN' : 'CLOSED'}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function formatDateTime(value: string): string {
|
||||
return new Date(value).toLocaleString([], {
|
||||
dateStyle: 'medium',
|
||||
timeStyle: 'short',
|
||||
});
|
||||
}
|
||||
|
||||
function formatBytes(value: number): string {
|
||||
if (value < 1024) {
|
||||
return `${value} B`;
|
||||
}
|
||||
|
||||
if (value < 1024 * 1024) {
|
||||
return `${Math.round(value / 102.4) / 10} KB`;
|
||||
}
|
||||
|
||||
return `${Math.round(value / 1024 / 102.4) / 10} MB`;
|
||||
}
|
||||
253
resources/js/pages/support/index.tsx
Normal file
253
resources/js/pages/support/index.tsx
Normal file
@@ -0,0 +1,253 @@
|
||||
import { Head, Link, useForm } from '@inertiajs/react';
|
||||
import { FileUp, LifeBuoy, MessageSquare, Send } 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 {
|
||||
index as supportIndex,
|
||||
show as showConversation,
|
||||
store as storeConversation,
|
||||
} from '@/routes/support';
|
||||
import type {
|
||||
SupportCategoryOption,
|
||||
SupportConversationSummary,
|
||||
} from '@/types';
|
||||
|
||||
type Props = {
|
||||
categories: SupportCategoryOption[];
|
||||
conversations: SupportConversationSummary[];
|
||||
};
|
||||
|
||||
export default function SupportIndex({ categories, conversations }: Props) {
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const form = useForm({
|
||||
category: categories[0]?.value ?? 'bug',
|
||||
subject: '',
|
||||
body: '',
|
||||
attachments: [] as File[],
|
||||
});
|
||||
|
||||
function submit(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
|
||||
form.post(storeConversation.url(), {
|
||||
forceFormData: true,
|
||||
preserveScroll: true,
|
||||
onSuccess: () => {
|
||||
form.reset();
|
||||
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function setAttachments(files: FileList | null) {
|
||||
form.setData('attachments', Array.from(files ?? []).slice(0, 3));
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head title="Contact admin" />
|
||||
<div className="grid gap-6 p-4 md:p-6 xl:grid-cols-[minmax(0,1fr)_420px]">
|
||||
<section className="grid content-start gap-4">
|
||||
<div className="flex flex-col gap-4 rounded-md border bg-card p-5 md:flex-row md:items-center md:justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="flex size-10 items-center justify-center rounded-md bg-primary text-primary-foreground">
|
||||
<LifeBuoy className="size-5" />
|
||||
</span>
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold">
|
||||
Contact admin
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Bug reports, suggestions, channel requests,
|
||||
and account questions.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-md border bg-background px-3 py-2 text-sm">
|
||||
{conversations.length} threads
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
{conversations.map((conversation) => (
|
||||
<Link
|
||||
key={conversation.id}
|
||||
href={showConversation(conversation.id)}
|
||||
className="grid gap-3 rounded-md border bg-card p-4 shadow-sm transition hover:border-primary/50 md:grid-cols-[minmax(0,1fr)_auto]"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<StatusBadge
|
||||
status={conversation.status}
|
||||
/>
|
||||
<span className="rounded-md border px-2 py-0.5 text-xs text-muted-foreground">
|
||||
{conversation.category_label}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-2 truncate font-medium">
|
||||
{conversation.subject}
|
||||
</div>
|
||||
{conversation.latest_message && (
|
||||
<p className="mt-1 line-clamp-2 text-sm text-muted-foreground">
|
||||
{
|
||||
conversation.latest_message
|
||||
.author.name
|
||||
}
|
||||
: {conversation.latest_message.body}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground md:justify-end">
|
||||
<MessageSquare className="size-4" />
|
||||
{conversation.messages_count}
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
|
||||
{conversations.length === 0 && (
|
||||
<div className="grid min-h-64 place-items-center rounded-md border border-dashed bg-card p-6 text-center text-sm text-muted-foreground">
|
||||
No conversations yet.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-md border bg-card p-5 shadow-sm">
|
||||
<div className="mb-5">
|
||||
<h2 className="font-semibold">New conversation</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Attach up to 3 files, 10 MB each.
|
||||
</p>
|
||||
</div>
|
||||
<form onSubmit={submit} className="grid gap-4">
|
||||
<Field label="Category" error={form.errors.category}>
|
||||
<select
|
||||
value={form.data.category}
|
||||
onChange={(event) =>
|
||||
form.setData('category', event.target.value)
|
||||
}
|
||||
className="h-9 rounded-md border bg-background px-3 text-sm shadow-xs outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50"
|
||||
>
|
||||
{categories.map((category) => (
|
||||
<option
|
||||
key={category.value}
|
||||
value={category.value}
|
||||
>
|
||||
{category.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="Subject" error={form.errors.subject}>
|
||||
<Input
|
||||
value={form.data.subject}
|
||||
onChange={(event) =>
|
||||
form.setData('subject', event.target.value)
|
||||
}
|
||||
maxLength={120}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Message" error={form.errors.body}>
|
||||
<textarea
|
||||
value={form.data.body}
|
||||
onChange={(event) =>
|
||||
form.setData('body', event.target.value)
|
||||
}
|
||||
className="min-h-36 rounded-md border bg-background px-3 py-2 text-sm shadow-xs outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50"
|
||||
maxLength={2000}
|
||||
/>
|
||||
</Field>
|
||||
<Field
|
||||
label="Attachments"
|
||||
error={form.errors.attachments}
|
||||
>
|
||||
<Input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
onChange={(event) =>
|
||||
setAttachments(event.target.files)
|
||||
}
|
||||
/>
|
||||
{form.data.attachments.length > 0 && (
|
||||
<div className="grid gap-1 text-xs text-muted-foreground">
|
||||
{form.data.attachments.map((file) => (
|
||||
<span
|
||||
key={`${file.name}-${file.size}`}
|
||||
className="flex min-w-0 items-center gap-2"
|
||||
>
|
||||
<FileUp className="size-3.5 shrink-0" />
|
||||
<span className="truncate">
|
||||
{file.name}
|
||||
</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{form.progress && (
|
||||
<progress
|
||||
value={form.progress.percentage}
|
||||
max="100"
|
||||
className="h-1.5 w-full"
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
<Button type="submit" disabled={form.processing}>
|
||||
<Send className="size-4" />
|
||||
Send
|
||||
</Button>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
SupportIndex.layout = {
|
||||
breadcrumbs: [
|
||||
{
|
||||
title: 'Contact admin',
|
||||
href: supportIndex(),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
function Field({
|
||||
label,
|
||||
error,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
error?: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<label className="grid gap-2">
|
||||
<Label>{label}</Label>
|
||||
{children}
|
||||
{error && <span className="text-sm text-destructive">{error}</span>}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status: 'open' | 'closed' }) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'rounded-md border px-2 py-0.5 text-xs font-medium',
|
||||
status === 'open'
|
||||
? 'border-success/40 bg-success/10 text-success'
|
||||
: 'text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
{status === 'open' ? 'OPEN' : 'CLOSED'}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
264
resources/js/pages/support/show.tsx
Normal file
264
resources/js/pages/support/show.tsx
Normal file
@@ -0,0 +1,264 @@
|
||||
import { Head, Link, useForm } from '@inertiajs/react';
|
||||
import { ArrowLeft, FileDown, FileUp, Lock, Send } 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 { index as supportIndex } from '@/routes/support';
|
||||
import { show as showAttachment } from '@/routes/support/attachments';
|
||||
import { store as storeMessage } from '@/routes/support/messages';
|
||||
import type {
|
||||
SupportAttachment,
|
||||
SupportConversation,
|
||||
SupportMessage,
|
||||
} from '@/types';
|
||||
|
||||
type Props = {
|
||||
conversation: SupportConversation;
|
||||
};
|
||||
|
||||
export default function SupportShow({ conversation }: Props) {
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const form = useForm({
|
||||
body: '',
|
||||
attachments: [] as File[],
|
||||
});
|
||||
const isClosed = conversation.status === 'closed';
|
||||
|
||||
function submit(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
|
||||
form.post(storeMessage.url(conversation.id), {
|
||||
forceFormData: true,
|
||||
preserveScroll: true,
|
||||
onSuccess: () => {
|
||||
form.reset();
|
||||
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function setAttachments(files: FileList | null) {
|
||||
form.setData('attachments', Array.from(files ?? []).slice(0, 3));
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head title={conversation.subject} />
|
||||
<div className="grid gap-6 p-4 md:p-6">
|
||||
<div className="rounded-md border bg-card p-5 shadow-sm">
|
||||
<Button asChild variant="ghost" size="sm" className="mb-4">
|
||||
<Link href={supportIndex()}>
|
||||
<ArrowLeft className="size-4" />
|
||||
Contact admin
|
||||
</Link>
|
||||
</Button>
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
|
||||
<div className="min-w-0">
|
||||
<div className="mb-2 flex flex-wrap items-center gap-2">
|
||||
<StatusBadge status={conversation.status} />
|
||||
<span className="rounded-md border px-2 py-0.5 text-xs text-muted-foreground">
|
||||
{conversation.category_label}
|
||||
</span>
|
||||
</div>
|
||||
<h1 className="text-2xl font-semibold break-words">
|
||||
{conversation.subject}
|
||||
</h1>
|
||||
</div>
|
||||
<div className="rounded-md border bg-background px-3 py-2 text-sm text-muted-foreground">
|
||||
{conversation.messages_count} messages
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section className="grid gap-3">
|
||||
{conversation.messages.map((message) => (
|
||||
<MessageBubble key={message.id} message={message} />
|
||||
))}
|
||||
</section>
|
||||
|
||||
<section className="rounded-md border bg-card p-5 shadow-sm">
|
||||
{isClosed ? (
|
||||
<div className="flex items-start gap-3 rounded-md border border-dashed bg-background p-4 text-sm text-muted-foreground">
|
||||
<Lock className="mt-0.5 size-4 shrink-0" />
|
||||
<span>This conversation is closed.</span>
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={submit} className="grid gap-4">
|
||||
<Field label="Reply" error={form.errors.body}>
|
||||
<textarea
|
||||
value={form.data.body}
|
||||
onChange={(event) =>
|
||||
form.setData('body', event.target.value)
|
||||
}
|
||||
className="min-h-32 rounded-md border bg-background px-3 py-2 text-sm shadow-xs outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50"
|
||||
maxLength={2000}
|
||||
/>
|
||||
</Field>
|
||||
<Field
|
||||
label="Attachments"
|
||||
error={form.errors.attachments}
|
||||
>
|
||||
<Input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
onChange={(event) =>
|
||||
setAttachments(event.target.files)
|
||||
}
|
||||
/>
|
||||
{form.data.attachments.length > 0 && (
|
||||
<div className="grid gap-1 text-xs text-muted-foreground">
|
||||
{form.data.attachments.map((file) => (
|
||||
<span
|
||||
key={`${file.name}-${file.size}`}
|
||||
className="flex min-w-0 items-center gap-2"
|
||||
>
|
||||
<FileUp className="size-3.5 shrink-0" />
|
||||
<span className="truncate">
|
||||
{file.name}
|
||||
</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{form.progress && (
|
||||
<progress
|
||||
value={form.progress.percentage}
|
||||
max="100"
|
||||
className="h-1.5 w-full"
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={form.processing}
|
||||
className="w-fit"
|
||||
>
|
||||
<Send className="size-4" />
|
||||
Send reply
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
SupportShow.layout = {
|
||||
breadcrumbs: [
|
||||
{
|
||||
title: 'Contact admin',
|
||||
href: supportIndex(),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
function MessageBubble({ message }: { message: SupportMessage }) {
|
||||
const isAdmin = message.author.is_admin;
|
||||
|
||||
return (
|
||||
<article
|
||||
className={cn(
|
||||
'grid max-w-3xl gap-3 rounded-md border bg-card p-4 shadow-sm',
|
||||
!isAdmin && 'justify-self-end border-primary/30',
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2 text-sm">
|
||||
<span className="font-medium">{message.author.name}</span>
|
||||
<span className="rounded-md border px-2 py-0.5 text-xs text-muted-foreground">
|
||||
{isAdmin ? 'ADMIN' : 'YOU'}
|
||||
</span>
|
||||
{message.created_at && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{formatDateTime(message.created_at)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm leading-6 break-words whitespace-pre-wrap">
|
||||
{message.body}
|
||||
</p>
|
||||
{message.attachments && message.attachments.length > 0 && (
|
||||
<AttachmentList attachments={message.attachments} />
|
||||
)}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
function AttachmentList({ attachments }: { attachments: SupportAttachment[] }) {
|
||||
return (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{attachments.map((attachment) => (
|
||||
<a
|
||||
key={attachment.id}
|
||||
href={showAttachment.url(attachment.id)}
|
||||
className="inline-flex max-w-full items-center gap-2 rounded-md border bg-background px-2 py-1 text-xs hover:bg-accent hover:text-accent-foreground"
|
||||
>
|
||||
<FileDown className="size-3.5 shrink-0" />
|
||||
<span className="truncate">{attachment.original_name}</span>
|
||||
<span className="shrink-0 text-muted-foreground">
|
||||
{formatBytes(attachment.size)}
|
||||
</span>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({
|
||||
label,
|
||||
error,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
error?: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<label className="grid gap-2">
|
||||
<Label>{label}</Label>
|
||||
{children}
|
||||
{error && <span className="text-sm text-destructive">{error}</span>}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status: 'open' | 'closed' }) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'rounded-md border px-2 py-0.5 text-xs font-medium',
|
||||
status === 'open'
|
||||
? 'border-success/40 bg-success/10 text-success'
|
||||
: 'text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
{status === 'open' ? 'OPEN' : 'CLOSED'}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function formatDateTime(value: string): string {
|
||||
return new Date(value).toLocaleString([], {
|
||||
dateStyle: 'medium',
|
||||
timeStyle: 'short',
|
||||
});
|
||||
}
|
||||
|
||||
function formatBytes(value: number): string {
|
||||
if (value < 1024) {
|
||||
return `${value} B`;
|
||||
}
|
||||
|
||||
if (value < 1024 * 1024) {
|
||||
return `${Math.round(value / 102.4) / 10} KB`;
|
||||
}
|
||||
|
||||
return `${Math.round(value / 1024 / 102.4) / 10} MB`;
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { Head, Link, usePage } from '@inertiajs/react';
|
||||
import {
|
||||
ArrowRight,
|
||||
Compass,
|
||||
LifeBuoy,
|
||||
LayoutDashboard,
|
||||
Radio,
|
||||
Search,
|
||||
@@ -15,6 +16,7 @@ import { Input } from '@/components/ui/input';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { dashboard, home, login, register } from '@/routes';
|
||||
import { show as showChannel } from '@/routes/channels';
|
||||
import { index as supportIndex } from '@/routes/support';
|
||||
import type { Category, ChannelCard } from '@/types';
|
||||
|
||||
type Props = {
|
||||
@@ -75,23 +77,39 @@ export default function Welcome({
|
||||
Live
|
||||
</Link>
|
||||
{auth.user && (
|
||||
<Link
|
||||
href={dashboard()}
|
||||
className="rounded-md px-3 py-2 hover:bg-accent hover:text-accent-foreground"
|
||||
>
|
||||
Studio
|
||||
</Link>
|
||||
<>
|
||||
<Link
|
||||
href={dashboard()}
|
||||
className="rounded-md px-3 py-2 hover:bg-accent hover:text-accent-foreground"
|
||||
>
|
||||
Studio
|
||||
</Link>
|
||||
<Link
|
||||
href={supportIndex()}
|
||||
className="rounded-md px-3 py-2 hover:bg-accent hover:text-accent-foreground"
|
||||
>
|
||||
Contact
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
</nav>
|
||||
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
{auth.user ? (
|
||||
<Button asChild size="sm">
|
||||
<Link href={dashboard()}>
|
||||
<LayoutDashboard className="size-4" />
|
||||
Studio
|
||||
</Link>
|
||||
</Button>
|
||||
<>
|
||||
<Button asChild size="sm">
|
||||
<Link href={dashboard()}>
|
||||
<LayoutDashboard className="size-4" />
|
||||
Studio
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link href={supportIndex()}>
|
||||
<LifeBuoy className="size-4" />
|
||||
Contact
|
||||
</Link>
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Button asChild variant="ghost" size="sm">
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export type * from './auth';
|
||||
export type * from './navigation';
|
||||
export type * from './streaming';
|
||||
export type * from './support';
|
||||
export type * from './ui';
|
||||
|
||||
57
resources/js/types/support.ts
Normal file
57
resources/js/types/support.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
export type SupportCategoryOption = {
|
||||
value: string;
|
||||
label: string;
|
||||
};
|
||||
|
||||
export type SupportStatus = 'open' | 'closed';
|
||||
|
||||
export type SupportAuthor = {
|
||||
id: number | null;
|
||||
name: string;
|
||||
is_admin: boolean;
|
||||
};
|
||||
|
||||
export type SupportAttachment = {
|
||||
id: number;
|
||||
original_name: string;
|
||||
mime_type: string;
|
||||
size: number;
|
||||
};
|
||||
|
||||
export type SupportMessage = {
|
||||
id: number;
|
||||
body: string;
|
||||
created_at: string | null;
|
||||
author: SupportAuthor;
|
||||
attachments?: SupportAttachment[];
|
||||
};
|
||||
|
||||
export type SupportUser = {
|
||||
id: number;
|
||||
name: string;
|
||||
email: string;
|
||||
can_create_channel?: boolean;
|
||||
suspended_at?: string | null;
|
||||
channel?: {
|
||||
id: number;
|
||||
slug: string;
|
||||
display_name: string;
|
||||
} | null;
|
||||
};
|
||||
|
||||
export type SupportConversationSummary = {
|
||||
id: number;
|
||||
category: string;
|
||||
category_label: string;
|
||||
subject: string;
|
||||
status: SupportStatus;
|
||||
last_message_at: string | null;
|
||||
messages_count: number;
|
||||
latest_message: SupportMessage | null;
|
||||
user?: SupportUser;
|
||||
};
|
||||
|
||||
export type SupportConversation = SupportConversationSummary & {
|
||||
messages: SupportMessage[];
|
||||
user?: SupportUser;
|
||||
};
|
||||
@@ -3,12 +3,15 @@
|
||||
use App\Http\Controllers\AdminChannelCreationController;
|
||||
use App\Http\Controllers\AdminDashboardController;
|
||||
use App\Http\Controllers\AdminModerationController;
|
||||
use App\Http\Controllers\AdminSupportConversationController;
|
||||
use App\Http\Controllers\ChannelController;
|
||||
use App\Http\Controllers\ChatMessageController;
|
||||
use App\Http\Controllers\CreatorDashboardController;
|
||||
use App\Http\Controllers\FollowController;
|
||||
use App\Http\Controllers\HomeController;
|
||||
use App\Http\Controllers\MediaMtxController;
|
||||
use App\Http\Controllers\SupportAttachmentController;
|
||||
use App\Http\Controllers\SupportConversationController;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
Route::get('/', HomeController::class)->name('home');
|
||||
@@ -37,6 +40,12 @@ Route::middleware(['auth', 'verified'])->group(function () {
|
||||
|
||||
Route::middleware('admin')->prefix('admin')->name('admin.')->group(function () {
|
||||
Route::get('/', AdminDashboardController::class)->name('dashboard');
|
||||
Route::get('support', [AdminSupportConversationController::class, 'index'])->name('support.index');
|
||||
Route::get('support/{conversation}', [AdminSupportConversationController::class, 'show'])->name('support.show');
|
||||
Route::post('support/{conversation}/messages', [AdminSupportConversationController::class, 'reply'])
|
||||
->middleware('throttle:20,1')
|
||||
->name('support.messages.store');
|
||||
Route::patch('support/{conversation}', [AdminSupportConversationController::class, 'update'])->name('support.update');
|
||||
Route::patch('channel-creation', [AdminChannelCreationController::class, 'updateDefault'])->name('channel-creation.update');
|
||||
Route::post('channels/{channel:slug}/suspend', [AdminModerationController::class, 'suspendChannel'])->name('channels.suspend');
|
||||
Route::post('channels/{channel:slug}/restore', [AdminModerationController::class, 'restoreChannel'])->name('channels.restore');
|
||||
@@ -47,4 +56,16 @@ Route::middleware(['auth', 'verified'])->group(function () {
|
||||
});
|
||||
});
|
||||
|
||||
Route::middleware('auth')->group(function () {
|
||||
Route::get('contact', [SupportConversationController::class, 'index'])->name('support.index');
|
||||
Route::post('contact', [SupportConversationController::class, 'store'])
|
||||
->middleware('throttle:10,1')
|
||||
->name('support.store');
|
||||
Route::get('contact/attachments/{attachment}', SupportAttachmentController::class)->name('support.attachments.show');
|
||||
Route::get('contact/{conversation}', [SupportConversationController::class, 'show'])->name('support.show');
|
||||
Route::post('contact/{conversation}/messages', [SupportConversationController::class, 'reply'])
|
||||
->middleware('throttle:20,1')
|
||||
->name('support.messages.store');
|
||||
});
|
||||
|
||||
require __DIR__.'/settings.php';
|
||||
|
||||
209
tests/Feature/SupportConversationTest.php
Normal file
209
tests/Feature/SupportConversationTest.php
Normal file
@@ -0,0 +1,209 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\SupportAttachment;
|
||||
use App\Models\SupportConversation;
|
||||
use App\Models\SupportMessage;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Inertia\Testing\AssertableInertia as Assert;
|
||||
use Tests\TestCase;
|
||||
|
||||
class SupportConversationTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_guests_are_redirected_from_contact_pages(): void
|
||||
{
|
||||
$conversation = SupportConversation::factory()->create();
|
||||
|
||||
$this->get(route('support.index'))->assertRedirect(route('login'));
|
||||
$this->post(route('support.store'))->assertRedirect(route('login'));
|
||||
$this->get(route('support.show', $conversation))->assertRedirect(route('login'));
|
||||
}
|
||||
|
||||
public function test_authenticated_user_can_create_support_conversation_with_attachment(): void
|
||||
{
|
||||
Storage::fake('local');
|
||||
|
||||
$user = User::factory()->create(['can_create_channel' => false]);
|
||||
$attachment = UploadedFile::fake()->create('channel-request.pdf', 12, 'application/pdf');
|
||||
|
||||
$this->actingAs($user)
|
||||
->post(route('support.store'), [
|
||||
'category' => SupportConversation::CATEGORY_CHANNEL_REQUEST,
|
||||
'subject' => 'Please approve my creator channel',
|
||||
'body' => 'I want to start streaming tutorials.',
|
||||
'attachments' => [$attachment],
|
||||
])
|
||||
->assertRedirect()
|
||||
->assertSessionHasNoErrors();
|
||||
|
||||
$conversation = SupportConversation::query()->firstOrFail();
|
||||
$message = SupportMessage::query()->firstOrFail();
|
||||
$storedAttachment = SupportAttachment::query()->firstOrFail();
|
||||
|
||||
$this->assertSame($user->id, $conversation->user_id);
|
||||
$this->assertSame(SupportConversation::CATEGORY_CHANNEL_REQUEST, $conversation->category);
|
||||
$this->assertSame('I want to start streaming tutorials.', $message->body);
|
||||
$this->assertFalse($user->refresh()->can_create_channel);
|
||||
$this->assertSame('channel-request.pdf', $storedAttachment->original_name);
|
||||
Storage::disk('local')->assertExists($storedAttachment->path);
|
||||
}
|
||||
|
||||
public function test_support_conversation_validation_errors_are_returned(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$attachment = UploadedFile::fake()->create('script.exe', 1, 'application/x-msdownload');
|
||||
|
||||
$this->actingAs($user)
|
||||
->post(route('support.store'), [
|
||||
'category' => 'billing',
|
||||
'subject' => '',
|
||||
'body' => '',
|
||||
'attachments' => [$attachment],
|
||||
])
|
||||
->assertSessionHasErrors(['category', 'subject', 'body', 'attachments.0']);
|
||||
}
|
||||
|
||||
public function test_user_can_view_and_reply_to_own_conversation(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$conversation = SupportConversation::factory()->for($user)->create([
|
||||
'subject' => 'Playback problem',
|
||||
]);
|
||||
SupportMessage::factory()->for($conversation, 'conversation')->for($user)->create([
|
||||
'body' => 'Video stalls after one minute.',
|
||||
]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->get(route('support.show', $conversation))
|
||||
->assertOk()
|
||||
->assertInertia(fn (Assert $page) => $page
|
||||
->component('support/show')
|
||||
->where('conversation.subject', 'Playback problem')
|
||||
->where('conversation.messages.0.body', 'Video stalls after one minute.'),
|
||||
);
|
||||
|
||||
$this->actingAs($user)
|
||||
->post(route('support.messages.store', $conversation), [
|
||||
'body' => 'It also happens in another browser.',
|
||||
])
|
||||
->assertRedirect()
|
||||
->assertSessionHasNoErrors();
|
||||
|
||||
$this->assertDatabaseHas('support_messages', [
|
||||
'support_conversation_id' => $conversation->id,
|
||||
'user_id' => $user->id,
|
||||
'body' => 'It also happens in another browser.',
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_user_cannot_access_another_users_conversation(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$conversation = SupportConversation::factory()->create();
|
||||
|
||||
$this->actingAs($user)
|
||||
->get(route('support.show', $conversation))
|
||||
->assertForbidden();
|
||||
|
||||
$this->actingAs($user)
|
||||
->post(route('support.messages.store', $conversation), [
|
||||
'body' => 'Trying to reply.',
|
||||
])
|
||||
->assertForbidden();
|
||||
}
|
||||
|
||||
public function test_admin_can_view_reply_and_close_conversation(): void
|
||||
{
|
||||
$admin = User::factory()->create(['is_admin' => true]);
|
||||
$user = User::factory()->create(['email' => 'viewer@example.com']);
|
||||
$conversation = SupportConversation::factory()->for($user)->create([
|
||||
'subject' => 'Bug report',
|
||||
]);
|
||||
SupportMessage::factory()->for($conversation, 'conversation')->for($user)->create([
|
||||
'body' => 'The chat panel overlaps the video.',
|
||||
]);
|
||||
|
||||
$this->actingAs($admin)
|
||||
->get(route('admin.support.index'))
|
||||
->assertOk()
|
||||
->assertInertia(fn (Assert $page) => $page
|
||||
->component('admin/support/index')
|
||||
->where('conversations.0.user.email', 'viewer@example.com'),
|
||||
);
|
||||
|
||||
$this->actingAs($admin)
|
||||
->get(route('admin.support.show', $conversation))
|
||||
->assertOk()
|
||||
->assertInertia(fn (Assert $page) => $page
|
||||
->component('admin/support/show')
|
||||
->where('conversation.messages.0.body', 'The chat panel overlaps the video.'),
|
||||
);
|
||||
|
||||
$this->actingAs($admin)
|
||||
->post(route('admin.support.messages.store', $conversation), [
|
||||
'body' => 'Thanks, we are reviewing it.',
|
||||
])
|
||||
->assertRedirect()
|
||||
->assertSessionHasNoErrors();
|
||||
|
||||
$this->actingAs($admin)
|
||||
->patch(route('admin.support.update', $conversation), [
|
||||
'status' => SupportConversation::STATUS_CLOSED,
|
||||
])
|
||||
->assertRedirect()
|
||||
->assertSessionHasNoErrors();
|
||||
|
||||
$this->assertDatabaseHas('support_messages', [
|
||||
'support_conversation_id' => $conversation->id,
|
||||
'user_id' => $admin->id,
|
||||
'body' => 'Thanks, we are reviewing it.',
|
||||
]);
|
||||
$this->assertSame(SupportConversation::STATUS_CLOSED, $conversation->refresh()->status);
|
||||
}
|
||||
|
||||
public function test_non_admin_cannot_access_admin_support_inbox(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
|
||||
$this->actingAs($user)
|
||||
->get(route('admin.support.index'))
|
||||
->assertForbidden();
|
||||
}
|
||||
|
||||
public function test_private_attachment_download_requires_conversation_access(): void
|
||||
{
|
||||
Storage::fake('local');
|
||||
|
||||
$owner = User::factory()->create();
|
||||
$otherUser = User::factory()->create();
|
||||
$admin = User::factory()->create(['is_admin' => true]);
|
||||
$conversation = SupportConversation::factory()->for($owner)->create();
|
||||
$message = SupportMessage::factory()->for($conversation, 'conversation')->for($owner)->create();
|
||||
$attachment = SupportAttachment::factory()->for($message, 'message')->create([
|
||||
'path' => 'support/testing/debug.log',
|
||||
'original_name' => 'debug.log',
|
||||
]);
|
||||
|
||||
Storage::disk('local')->put($attachment->path, 'log contents');
|
||||
|
||||
$this->actingAs($owner)
|
||||
->get(route('support.attachments.show', $attachment))
|
||||
->assertOk()
|
||||
->assertDownload('debug.log');
|
||||
|
||||
$this->actingAs($otherUser)
|
||||
->get(route('support.attachments.show', $attachment))
|
||||
->assertForbidden();
|
||||
|
||||
$this->actingAs($admin)
|
||||
->get(route('support.attachments.show', $attachment))
|
||||
->assertOk()
|
||||
->assertDownload('debug.log');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user