--- /dev/null +++ b/app/Http/Middleware/PrivateSupportResponse.php @@ -0,0 +1,23 @@ +headers->set('Cache-Control', 'private, no-store, no-cache, must-revalidate, max-age=0'); + $response->headers->set('Pragma', 'no-cache'); + $response->headers->set('Expires', '0'); + $response->headers->set('X-Robots-Tag', 'noindex, nofollow, noarchive'); + $response->headers->set('X-Content-Type-Options', 'nosniff'); + $response->headers->set('Referrer-Policy', 'same-origin'); + + return $response; + } +} --- a/app/Http/Controllers/SupportTicketController.php +++ b/app/Http/Controllers/SupportTicketController.php @@ -6,123 +6,324 @@ use App\Models\SupportTicket; use App\Models\SupportTicketAttachment; use App\Models\SupportTicketCategory; +use App\Models\SupportTicketMessage; +use App\Models\User; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Http\Request; use Illuminate\Support\Facades\Storage; use Illuminate\Validation\Rule; +use Throwable; class SupportTicketController extends Controller { public function index() { - return theme_view('support.index',[ - 'categories'=>SupportTicketCategory::where('active',true)->orderBy('sort_order')->get(), - 'faqs'=>Faq::limit(6)->get(), + return theme_view('support.index', [ + 'categories' => SupportTicketCategory::where('active', true)->orderBy('sort_order')->get(), + 'faqs' => Faq::limit(6)->get(), ]); } public function tickets(Request $request) { - $q=SupportTicket::with('category')->where('requester_type','user')->where('requester_id',auth()->id()); - if($request->filled('status')) $q->where('status',$request->status); - if($request->filled('search')){ - $s='%'.$request->search.'%'; - $q->where(fn($x)=>$x->where('ticket_number','like',$s)->orWhere('subject','like',$s)); - } - return theme_view('support.tickets',['tickets'=>$q->orderByDesc('last_reply_at')->orderByDesc('id')->paginate(15)->withQueryString()]); - } - - public function create() - { - return theme_view('support.create',['categories'=>SupportTicketCategory::where('active',true)->orderBy('sort_order')->get()]); + $user = $this->profileUser($request); + if (!$request->routeIs('user.support.*')) { + return redirect()->route('user.support.index', [ + 'username' => strtolower($user->username), + ...$request->only('status', 'search', 'page'), + ]); + } + + $groups = $this->filterGroups(); + $data = $request->validate([ + 'search' => ['nullable', 'string', 'max:180'], + 'status' => ['nullable', 'string', Rule::in(array_unique([ + 'all', ...array_keys($groups), ...array_keys(SupportTicket::statuses()), + ]))], + ]); + $status = $data['status'] ?? 'all'; + $search = trim($data['search'] ?? ''); + $query = $this->ownedQuery($user)->with('category'); + + if ($status !== 'all') { + $query->whereIn('status', $groups[$status] ?? [$status]); + } + if ($search !== '') { + // The nested OR must stay inside the requester scope. + $query->where(function (Builder $q) use ($search) { + $q->where('ticket_number', 'like', '%'.$search.'%') + ->orWhere('subject', 'like', '%'.$search.'%'); + }); + } + + $rawCounts = $this->ownedQuery($user)->select('status') + ->selectRaw('COUNT(*) AS aggregate')->groupBy('status')->pluck('aggregate', 'status'); + $counts = ['all' => (int) $rawCounts->sum()]; + foreach ($groups as $key => $values) { + $counts[$key] = (int) collect($values)->sum(fn ($value) => (int) $rawCounts->get($value, 0)); + } + + return view('themes.ayline.user.support.index', [ + 'user' => $user, + 'tickets' => $query->orderByDesc('updated_at')->orderByDesc('id')->paginate(10)->withQueryString(), + 'counts' => $counts, 'status' => $status, 'search' => $search, + ]); + } + + public function create(Request $request) + { + $user = $this->profileUser($request); + if (!$request->routeIs('user.support.*')) { + return redirect()->route('user.support.create', [ + 'username' => strtolower($user->username), + ...$request->only('category'), + ]); + } + + return view('themes.ayline.user.support.create', [ + 'user' => $user, + 'categories' => SupportTicketCategory::where('active', true)->orderBy('sort_order')->get(), + ]); } public function store(Request $request) { - $data=$request->validate([ - 'category_id'=>['required','exists:support_ticket_categories,id'], - 'subject'=>['required','string','max:180'], - 'description'=>['required','string','min:10','max:10000'], - 'priority'=>['required',Rule::in(array_keys(SupportTicket::priorities()))], - 'attachments.*'=>['nullable','file','max:10240','mimes:jpg,jpeg,png,webp,pdf,txt,doc,docx'], - ]); - - $ticket=SupportTicket::create([ - 'requester_type'=>'user','requester_id'=>auth()->id(),'category_id'=>$data['category_id'], - 'subject'=>$data['subject'],'description'=>$data['description'],'priority'=>$data['priority'], - 'status'=>SupportTicket::STATUS_NEW,'last_reply_at'=>now(), - 'sla_due_at'=>now()->addHours($data['priority']==='urgent'?8:($data['priority']==='important'?24:48)), - ]); - - $message=$ticket->messages()->create(['sender_type'=>'user','sender_id'=>auth()->id(),'message'=>$data['description']]); - $this->saveFiles($request,$ticket,$message->id); - - toastr()->success(getLocale()==='sq'?'Kërkesa u hap me sukses':'Ticket created successfully'); - return redirect()->route('support.tickets.show',$ticket->ticket_number); - } - - public function show(string $number) - { - $ticket=$this->mine($number); - $ticket->load(['category','messages.attachments','assignedAdmin','business']); - return theme_view('support.show',compact('ticket')); - } - - public function reply(Request $request,string $number) - { - $ticket=$this->mine($number); - abort_if($ticket->status===SupportTicket::STATUS_CLOSED,403); - $data=$request->validate([ - 'message'=>['required','string','max:10000'], - 'attachments.*'=>['nullable','file','max:10240','mimes:jpg,jpeg,png,webp,pdf,txt,doc,docx'] - ]); - - $message=$ticket->messages()->create(['sender_type'=>'user','sender_id'=>auth()->id(),'message'=>$data['message']]); - $ticket->update(['status'=>SupportTicket::STATUS_WAITING_STAFF,'last_reply_at'=>now()]); - $this->saveFiles($request,$ticket,$message->id); - toastr()->success(getLocale()==='sq'?'Përgjigjja u dërgua':'Reply sent'); - return back(); - } - - public function close(string $number) - { - $this->mine($number)->update(['status'=>SupportTicket::STATUS_CLOSED,'closed_at'=>now()]); - return back(); - } - - public function reopen(string $number) - { - $this->mine($number)->update(['status'=>SupportTicket::STATUS_WAITING_STAFF,'closed_at'=>null,'resolved_at'=>null,'last_reply_at'=>now()]); - return back(); - } - - public function rate(Request $request,string $number) - { - $data=$request->validate(['rating'=>['required','integer','between:1,5'],'comment'=>['nullable','string','max:1000']]); - $this->mine($number)->update(['satisfaction_rating'=>$data['rating'],'satisfaction_comment'=>$data['comment']??null]); - return back(); - } - - public function attachment(string $number,int $attachment) - { - $ticket=$this->mine($number); - $file=$ticket->attachments()->findOrFail($attachment); - abort_unless(Storage::disk('private')->exists($file->path),404); - return Storage::disk('private')->download($file->path,$file->original_name); - } - - private function mine(string $number): SupportTicket - { - return SupportTicket::where('ticket_number',$number)->where('requester_type','user')->where('requester_id',auth()->id())->firstOrFail(); - } - - private function saveFiles(Request $request,SupportTicket $ticket,?int $messageId=null): void - { - foreach($request->file('attachments',[]) as $file){ - $path=$file->store('support-tickets/'.$ticket->id,'private'); - SupportTicketAttachment::create([ - 'ticket_id'=>$ticket->id,'message_id'=>$messageId,'original_name'=>$file->getClientOriginalName(), - 'path'=>$path,'mime_type'=>$file->getMimeType(),'size'=>$file->getSize() + $user = $this->profileUser($request); + $data = $request->validate([ + 'category_id' => ['required', 'integer', Rule::exists('support_ticket_categories', 'id')->where('active', true)], + 'subject' => ['required', 'string', 'max:180', 'regex:/\S/u'], + 'description' => ['required', 'string', 'min:10', 'max:10000', 'regex:/\S/u'], + 'priority' => ['sometimes', 'required', Rule::in(array_keys(SupportTicket::priorities()))], + ...$this->uploadRules(), + ]); + + $ticket = null; + $message = null; + try { + $priority = $data['priority'] ?? SupportTicket::PRIORITY_NORMAL; + $ticket = SupportTicket::create([ + 'requester_type' => 'user', 'requester_id' => $user->id, + 'category_id' => $data['category_id'], 'subject' => trim($data['subject']), + 'description' => trim($data['description']), 'priority' => $priority, + 'status' => SupportTicket::STATUS_NEW, 'last_reply_at' => now(), + 'sla_due_at' => now()->addHours($priority === 'urgent' ? 8 : ($priority === 'important' ? 24 : 48)), ]); + $message = $this->appendMessage($request, $ticket, $user, trim($data['description'])); + } catch (Throwable $exception) { + // The supplied deployment uses MyISAM for support tables; rollback alone is insufficient. + if ($message) $this->discardMessage($message); + if ($ticket) $ticket->delete(); + report($exception); + return back()->withInput($request->except('attachments')) + ->withErrors(['support' => __('user_support.save_failed')]); + } + + return $this->ticketRedirect($user, $ticket) + ->with('support_success', __('user_support.created')); + } + + public function show(Request $request) + { + $user = $this->profileUser($request); + $ticket = $this->mine($request, $user); + + if (!$request->routeIs('user.support.*')) { + return $this->ticketRedirect($user, $ticket); + } + + $ticket->load(['category', 'business']); + $messages = $ticket->messages()->where('is_internal', false) + ->with(['attachments' => fn ($query) => $query->where('ticket_id', $ticket->id)]) + ->reorder()->orderByDesc('created_at')->orderByDesc('id') + ->paginate(30, ['*'], 'messages_page')->withQueryString(); + + return view('themes.ayline.user.support.show', [ + 'user' => $user, 'ticket' => $ticket, 'messages' => $messages, + 'legacyAttachments' => $ticket->attachments()->whereNull('message_id')->get(), + ]); + } + + public function reply(Request $request) + { + $user = $this->profileUser($request); + $ticket = $this->mine($request, $user); + abort_if($ticket->status === SupportTicket::STATUS_CLOSED, 403); + $data = $request->validate([ + 'message' => ['required', 'string', 'max:10000', 'regex:/\S/u'], + ...$this->uploadRules(), + ]); + + $message = null; + try { + $message = $this->appendMessage($request, $ticket, $user, trim($data['message'])); + $ticket->update([ + 'status' => SupportTicket::STATUS_WAITING_STAFF, 'last_reply_at' => now(), + 'resolved_at' => null, 'closed_at' => null, + ]); + } catch (Throwable $exception) { + if ($message) $this->discardMessage($message); + report($exception); + return back()->withInput($request->except('attachments')) + ->withErrors(['support' => __('user_support.save_failed')]); + } + + return $this->ticketRedirect($user, $ticket) + ->with('support_success', __('user_support.reply_sent')); + } + + public function close(Request $request) + { + $user = $this->profileUser($request); + $ticket = $this->mine($request, $user); + $ticket->update(['status' => SupportTicket::STATUS_CLOSED, 'closed_at' => now()]); + return $this->ticketRedirect($user, $ticket) + ->with('support_success', __('user_support.closed_success')); + } + + public function reopen(Request $request) + { + $user = $this->profileUser($request); + $ticket = $this->mine($request, $user); + abort_unless(in_array($ticket->status, [SupportTicket::STATUS_CLOSED, SupportTicket::STATUS_RESOLVED], true), 409); + $ticket->update([ + 'status' => SupportTicket::STATUS_WAITING_STAFF, 'closed_at' => null, + 'resolved_at' => null, 'last_reply_at' => now(), + ]); + return $this->ticketRedirect($user, $ticket) + ->with('support_success', __('user_support.reopened')); + } + + public function rate(Request $request) + { + $user = $this->profileUser($request); + $ticket = $this->mine($request, $user); + abort_unless(in_array($ticket->status, [SupportTicket::STATUS_RESOLVED, SupportTicket::STATUS_CLOSED], true), 403); + abort_if($ticket->satisfaction_rating !== null, 409); + $data = $request->validate([ + 'rating' => ['required', 'integer', 'between:1,5'], + 'comment' => ['nullable', 'string', 'max:1000'], + ]); + $ticket->update([ + 'satisfaction_rating' => $data['rating'], + 'satisfaction_comment' => $data['comment'] ?? null, + ]); + return $this->ticketRedirect($user, $ticket) + ->with('support_success', __('user_support.rated')); + } + + public function attachment(Request $request) + { + $user = $this->profileUser($request); + $ticket = $this->mine($request, $user); + $attachmentId = $request->route('attachment'); + + $file = $ticket->attachments()->where(function (Builder $query) use ($ticket) { + $query->whereNull('message_id')->orWhereIn('message_id', + SupportTicketMessage::query()->select('id') + ->where('ticket_id', $ticket->id)->where('is_internal', false) + ); + })->findOrFail($attachmentId); + + // Also reject corrupt paths; never use request-supplied paths. + $path = str_replace('\\', '/', $file->path); + abort_if(str_starts_with($path, '/') || in_array('..', explode('/', $path), true), 404); + abort_unless(Storage::disk('private')->exists($path), 404); + return Storage::disk('private')->download($path, $file->original_name, [ + 'Cache-Control' => 'private, no-store, max-age=0', + 'X-Content-Type-Options' => 'nosniff', + ]); + } + + private function profileUser(Request $request): User + { + $user = $request->user('web'); + abort_unless($user instanceof User, 403); + $username = $request->route('username'); + abort_if($username !== null && strcasecmp((string) $username, (string) $user->username) !== 0, 404); + return $user; + } + + private function ownedQuery(User $user): Builder + { + return SupportTicket::query()->where('requester_type', 'user')->where('requester_id', $user->id); + } + + private function mine(Request $request, User $user): SupportTicket + { + return $this->ownedQuery($user)->where('ticket_number', $request->route('ticketNumber'))->firstOrFail(); + } + + private function ticketRedirect(User $user, SupportTicket $ticket) + { + return redirect()->route('user.support.show', [ + 'username' => strtolower($user->username), 'ticketNumber' => $ticket->ticket_number, + ]); + } + + private function filterGroups(): array + { + return [ + 'open' => [SupportTicket::STATUS_NEW, SupportTicket::STATUS_WAITING_STAFF], + 'in_progress' => [SupportTicket::STATUS_IN_PROGRESS], + 'answered' => [SupportTicket::STATUS_WAITING_USER], + 'resolved' => [SupportTicket::STATUS_RESOLVED], + 'closed' => [SupportTicket::STATUS_CLOSED], + ]; + } + + private function uploadRules(): array + { + return [ + 'attachments' => ['sometimes', 'array', 'max:5'], + 'attachments.*' => ['required', 'file', 'max:5120', + 'mimes:jpg,jpeg,png,webp,pdf', 'extensions:jpg,jpeg,png,webp,pdf'], + ]; + } + + private function appendMessage(Request $request, SupportTicket $ticket, User $user, string $text): SupportTicketMessage + { + $message = $ticket->messages()->create([ + 'sender_type' => 'user', 'sender_id' => $user->id, 'message' => $text, 'is_internal' => false, + ]); + $paths = []; + try { + foreach ($request->file('attachments', []) as $file) { + $path = $file->store('support-tickets/'.$ticket->id, 'private'); + if (!$path) throw new \RuntimeException('Private support attachment storage failed.'); + $paths[] = $path; + $name = basename(str_replace('\\', '/', $file->getClientOriginalName())); + $name = mb_substr(preg_replace('/[\x00-\x1F\x7F]/u', '', $name) ?: 'attachment', 0, 180); + SupportTicketAttachment::create([ + 'ticket_id' => $ticket->id, 'message_id' => $message->id, + 'original_name' => $name, 'path' => $path, + 'mime_type' => $file->getMimeType(), 'size' => $file->getSize(), + ]); + } + } catch (Throwable $exception) { + $message->attachments()->delete(); + $message->delete(); + $this->deleteStoredFiles($paths); + throw $exception; + } + return $message; + } + + private function discardMessage(SupportTicketMessage $message): void + { + $paths = $message->attachments()->pluck('path')->all(); + $message->attachments()->delete(); + $message->delete(); + $this->deleteStoredFiles($paths); + } + + private function deleteStoredFiles(array $paths): void + { + if ($paths === []) return; + try { + Storage::disk('private')->delete($paths); + } catch (Throwable $cleanupError) { + // A failed disk must not prevent cleanup of the MyISAM database rows. + report($cleanupError); } } } --- /dev/null +++ b/routes/user-support.php @@ -0,0 +1,20 @@ +prefix('{username}/support')->name('support.')->group(function () { + Route::get('/', [SupportTicketController::class, 'tickets'])->name('index'); + Route::get('new', [SupportTicketController::class, 'create'])->name('create'); + Route::post('/', [SupportTicketController::class, 'store'])->middleware('throttle:6,1')->name('store'); + Route::get('{ticketNumber}', [SupportTicketController::class, 'show'])->name('show'); + Route::post('{ticketNumber}/reply', [SupportTicketController::class, 'reply'])->middleware('throttle:10,1')->name('reply'); + Route::post('{ticketNumber}/close', [SupportTicketController::class, 'close'])->middleware('throttle:10,1')->name('close'); + Route::post('{ticketNumber}/reopen', [SupportTicketController::class, 'reopen'])->middleware('throttle:10,1')->name('reopen'); + Route::post('{ticketNumber}/rate', [SupportTicketController::class, 'rate'])->middleware('throttle:10,1')->name('rate'); + Route::get('{ticketNumber}/attachments/{attachment}', [SupportTicketController::class, 'attachment']) + ->whereNumber('attachment')->middleware('throttle:60,1')->name('attachment'); + }); --- a/routes/web.php +++ b/routes/web.php @@ -92,6 +92,7 @@ Route::post('{slug}', 'GeneralController@blogComment')->name('article')->middleware('auth'); }); Route::name('user.')->prefix('user')->group(function () { + require __DIR__.'/user-support.php'; Route::get('{username}', 'UserController@profile')->name('profile'); Route::middleware(['demo', 'auth'])->group(function () { Route::name('favorites.')->prefix('{username}/favorites')->group(function () { @@ -113,15 +114,15 @@ }); Route::get('support', 'SupportTicketController@index')->name('support.index'); - Route::middleware('auth')->name('support.')->prefix('support')->group(function () { + Route::middleware(['auth', \App\Http\Middleware\PrivateSupportResponse::class])->name('support.')->prefix('support')->group(function () { Route::get('tickets', 'SupportTicketController@tickets')->name('tickets.index'); Route::get('tickets/new', 'SupportTicketController@create')->name('tickets.create'); - Route::post('tickets', 'SupportTicketController@store')->name('tickets.store'); + Route::post('tickets', 'SupportTicketController@store')->middleware('throttle:6,1')->name('tickets.store'); Route::get('tickets/{ticketNumber}', 'SupportTicketController@show')->name('tickets.show'); - Route::post('tickets/{ticketNumber}/reply', 'SupportTicketController@reply')->name('tickets.reply'); - Route::post('tickets/{ticketNumber}/close', 'SupportTicketController@close')->name('tickets.close'); - Route::post('tickets/{ticketNumber}/reopen', 'SupportTicketController@reopen')->name('tickets.reopen'); - Route::post('tickets/{ticketNumber}/rate', 'SupportTicketController@rate')->name('tickets.rate'); + Route::post('tickets/{ticketNumber}/reply', 'SupportTicketController@reply')->middleware('throttle:10,1')->name('tickets.reply'); + Route::post('tickets/{ticketNumber}/close', 'SupportTicketController@close')->middleware('throttle:10,1')->name('tickets.close'); + Route::post('tickets/{ticketNumber}/reopen', 'SupportTicketController@reopen')->middleware('throttle:10,1')->name('tickets.reopen'); + Route::post('tickets/{ticketNumber}/rate', 'SupportTicketController@rate')->middleware('throttle:10,1')->name('tickets.rate'); Route::get('tickets/{ticketNumber}/attachments/{attachment}', 'SupportTicketController@attachment')->name('tickets.attachment'); }); --- a/resources/views/themes/ayline/user/layout.blade.php +++ b/resources/views/themes/ayline/user/layout.blade.php @@ -3,9 +3,11 @@ @include('themes.ayline.includes.head') + + - +routeIs('user.support.*')) data-private-support="true" @endif> @include('themes.ayline.includes.navbar')
@@ -52,7 +54,7 @@
@auth @if (authUser()->id == $user->id) -
+
--- a/resources/views/themes/ayline/support/index.blade.php +++ b/resources/views/themes/ayline/support/index.blade.php @@ -11,6 +11,14 @@
+ + @auth +
+ + {{ __('user_support.my_tickets') }} + +
+ @endauth

{{ getLocale()==='sq' ? 'Zgjidh kategorinë' : 'Choose a help topic' }}

--- /dev/null +++ b/lang/sq/user_support.php @@ -0,0 +1,95 @@ + 'Support', + 'my_tickets' => 'Kërkesat e mia', + 'intro' => 'Kërko ndihmë, raporto probleme ose kontakto ekipin e Pesyje.', + 'private' => 'Kërkesat dhe përgjigjet shihen vetëm nga ti dhe ekipi i Pesyje.', + 'new' => 'Hap një ticket', + 'create_title' => 'Hap një ticket', + 'create_intro' => 'Na jep detajet e problemit që të mund të të ndihmojmë.', + 'all' => 'Të gjitha', + 'open' => 'Hapur', + 'in_progress' => 'Në proces', + 'answered' => 'Përgjigjur', + 'resolved' => 'Zgjidhur', + 'closed' => 'Mbyllur', + 'status_new' => 'Hapur', + 'status_in_progress' => 'Në proces', + 'status_waiting_user' => 'Përgjigjur', + 'status_waiting_staff' => 'Në pritje të stafit', + 'status_resolved' => 'Zgjidhur', + 'status_closed' => 'Mbyllur', + 'hint_new' => 'Kërkesa u dërgua dhe pret shqyrtimin nga ekipi.', + 'hint_in_progress' => 'Ekipi po shqyrton kërkesën tënde.', + 'hint_waiting_user' => 'Ke një përgjigje nga ekipi. Kontrolloje dhe na shkruaj më poshtë.', + 'hint_waiting_staff' => 'Përgjigjja jote u dërgua. Në pritje të ekipit.', + 'hint_resolved' => 'Kërkesa është shënuar si e zgjidhur. Mund ta rihapësh nëse problemi vazhdon.', + 'hint_closed' => 'Kërkesa është mbyllur. Rihape për të vazhduar bisedën.', + 'search' => 'Kërko ticket', + 'search_placeholder' => 'Kërko sipas numrit ose subjektit…', + 'search_button' => 'Kërko', + 'filters' => 'Filtro kërkesat', + 'reset' => 'Pastro filtrat', + 'empty_title' => 'Nuk ke ende kërkesa', + 'empty_text' => 'Kërkesat që hap te Support do t\'i gjesh këtu.', + 'no_results' => 'Nuk u gjetën kërkesa', + 'no_results_text' => 'Provo një fjalë tjetër ose pastro filtrat.', + 'category' => 'Kategoria', + 'choose_category' => 'Zgjidh kategorinë', + 'category_missing' => 'Pa kategori', + 'no_categories' => 'Për momentin nuk ka kategori aktive. Provo më vonë ose përdor faqen e kontaktit.', + 'subject' => 'Subjekti', + 'subject_placeholder' => 'Shkruaj shkurt problemin…', + 'description' => 'Përshkrimi', + 'description_placeholder' => 'Çfarë ndodhi dhe ku e hasët problemin?', + 'description_help' => '10–10 000 karaktere. Mos dërgo fjalëkalime, kode sigurie ose të dhëna të kartës.', + 'priority' => 'Prioriteti', + 'priority_normal' => 'Normal', + 'priority_important' => 'I rëndësishëm', + 'priority_urgent' => 'Urgjent', + 'attachments' => 'Bashkëngjitje', + 'optional' => 'opsionale', + 'choose_files' => 'Zgjidh skedarët', + 'files_help' => 'Deri në 5 skedarë, 5 MB secili. JPG, PNG, WEBP ose PDF.', + 'files_error' => 'Zgjidh maksimumi 5 skedarë JPG, PNG, WEBP ose PDF, deri në 5 MB secili.', + 'submit' => 'Dërgo ticket', + 'sending' => 'Duke dërguar…', + 'cancel' => 'Anulo', + 'back' => 'Kthehu te kërkesat', + 'details' => 'Detajet e ticket', + 'created_at' => 'Hapur më', + 'updated_at' => 'Përditësimi i fundit', + 'status' => 'Statusi', + 'conversation' => 'Historia e bisedës', + 'you' => 'Ti', + 'team' => 'Pesyje Support', + 'reply' => 'Shkruaj një përgjigje', + 'reply_placeholder' => 'Shkruaj përgjigjen tënde…', + 'send_reply' => 'Dërgo përgjigjen', + 'reply_help' => 'Përgjigjja i dërgohet ekipit të Pesyje.', + 'close' => 'Mbyll ticket-in', + 'close_confirm' => 'Ta mbyllim këtë kërkesë? Mund ta rihapësh më vonë.', + 'reopen' => 'Rihap ticket-in', + 'refresh' => 'Rifresko', + 'refresh_help' => 'Rifresko për përgjigjet dhe statusin e fundit.', + 'download' => 'Shkarko', + 'business' => 'Biznesi i lidhur', + 'help_center' => 'Qendra e ndihmës', + 'created' => 'Kërkesa u hap me sukses.', + 'reply_sent' => 'Përgjigjja u dërgua.', + 'closed_success' => 'Kërkesa u mbyll.', + 'reopened' => 'Kërkesa u rihap.', + 'rated' => 'Faleminderit për vlerësimin e mbështetjes.', + 'save_failed' => 'Kërkesa nuk u ruajt. Provo përsëri; nëse ke bashkëngjitje, përzgjidhi sërish.', + 'errors' => 'Kontrollo të dhënat më poshtë.', + 'offline' => 'Je pa internet. Lidhu për të dërguar; mesazhi nuk është dërguar.', + 'rate' => 'Vlerëso mbështetjen', + 'rating' => 'Vlerësimi', + 'choose_rating' => 'Zgjidh 1–5 yje', + 'rating_comment' => 'Komenti për mbështetjen', + 'rate_submit' => 'Dërgo vlerësimin', + 'your_rating' => 'Vlerësimi yt për mbështetjen', + 'stars' => 'yje', + 'message_pages' => 'Faqet e bisedës (faqja 1 përmban mesazhet më të fundit)', +]; --- /dev/null +++ b/lang/en/user_support.php @@ -0,0 +1,95 @@ + 'Support', + 'my_tickets' => 'My tickets', + 'intro' => 'Get help, report issues or contact the Pesyje team.', + 'private' => 'Only you and the Pesyje team can see your tickets and replies.', + 'new' => 'New ticket', + 'create_title' => 'Open a ticket', + 'create_intro' => 'Tell us about the issue so we can help.', + 'all' => 'All', + 'open' => 'Open', + 'in_progress' => 'In progress', + 'answered' => 'Answered', + 'resolved' => 'Resolved', + 'closed' => 'Closed', + 'status_new' => 'Open', + 'status_in_progress' => 'In progress', + 'status_waiting_user' => 'Answered', + 'status_waiting_staff' => 'Waiting for staff', + 'status_resolved' => 'Resolved', + 'status_closed' => 'Closed', + 'hint_new' => 'Your ticket has been submitted and is waiting for review.', + 'hint_in_progress' => 'The team is reviewing your ticket.', + 'hint_waiting_user' => 'The team has replied. Read the message and reply below.', + 'hint_waiting_staff' => 'Your reply has been sent. Waiting for the team.', + 'hint_resolved' => 'This ticket is marked as resolved. Reopen it if you still need help.', + 'hint_closed' => 'This ticket is closed. Reopen it to continue the conversation.', + 'search' => 'Search tickets', + 'search_placeholder' => 'Search by number or subject…', + 'search_button' => 'Search', + 'filters' => 'Filter tickets', + 'reset' => 'Clear filters', + 'empty_title' => 'No tickets yet', + 'empty_text' => 'Tickets you open in Support will appear here.', + 'no_results' => 'No tickets found', + 'no_results_text' => 'Try another search or clear the filters.', + 'category' => 'Category', + 'choose_category' => 'Choose a category', + 'category_missing' => 'No category', + 'no_categories' => 'There are no active categories at the moment. Try later or use the contact page.', + 'subject' => 'Subject', + 'subject_placeholder' => 'Briefly describe the issue…', + 'description' => 'Description', + 'description_placeholder' => 'What happened, and where did you see the issue?', + 'description_help' => '10–10,000 characters. Do not send passwords, security codes or card details.', + 'priority' => 'Priority', + 'priority_normal' => 'Normal', + 'priority_important' => 'Important', + 'priority_urgent' => 'Urgent', + 'attachments' => 'Attachments', + 'optional' => 'optional', + 'choose_files' => 'Choose files', + 'files_help' => 'Up to 5 files, 5 MB each. JPG, PNG, WEBP or PDF.', + 'files_error' => 'Select no more than 5 JPG, PNG, WEBP or PDF files, up to 5 MB each.', + 'submit' => 'Send ticket', + 'sending' => 'Sending…', + 'cancel' => 'Cancel', + 'back' => 'Back to tickets', + 'details' => 'Ticket details', + 'created_at' => 'Created', + 'updated_at' => 'Last update', + 'status' => 'Status', + 'conversation' => 'Conversation history', + 'you' => 'You', + 'team' => 'Pesyje Support', + 'reply' => 'Write a reply', + 'reply_placeholder' => 'Write your reply…', + 'send_reply' => 'Send reply', + 'reply_help' => 'Your reply is sent to the Pesyje team.', + 'close' => 'Close ticket', + 'close_confirm' => 'Close this ticket? You can reopen it later.', + 'reopen' => 'Reopen ticket', + 'refresh' => 'Refresh', + 'refresh_help' => 'Refresh for the latest replies and status.', + 'download' => 'Download', + 'business' => 'Related business', + 'help_center' => 'Help center', + 'created' => 'Ticket created successfully.', + 'reply_sent' => 'Reply sent.', + 'closed_success' => 'Ticket closed.', + 'reopened' => 'Ticket reopened.', + 'rated' => 'Thank you for rating the support.', + 'save_failed' => 'The request could not be saved. Try again; reselect any attachments.', + 'errors' => 'Please check the information below.', + 'offline' => 'You are offline. Reconnect before sending; your message has not been sent.', + 'rate' => 'Rate support', + 'rating' => 'Rating', + 'choose_rating' => 'Choose 1–5 stars', + 'rating_comment' => 'Support feedback', + 'rate_submit' => 'Submit rating', + 'your_rating' => 'Your support rating', + 'stars' => 'stars', + 'message_pages' => 'Conversation pages (page 1 contains the most recent messages)', +]; --- /dev/null +++ b/resources/views/themes/ayline/user/support/_feedback.blade.php @@ -0,0 +1,12 @@ +@if(session('support_success')) +
+ + {{ session('support_success') }} +
+@endif +@if($errors->any()) + +@endif --- /dev/null +++ b/resources/views/themes/ayline/user/support/_files.blade.php @@ -0,0 +1,13 @@ +
+ +
+ +
+ +

{{ __('user_support.files_help') }}

+

+
+
+
--- /dev/null +++ b/resources/views/themes/ayline/user/support/_status.blade.php @@ -0,0 +1,3 @@ + + {{ __('user_support.status_'.$ticket->status) }} + --- /dev/null +++ b/resources/views/themes/ayline/user/support/index.blade.php @@ -0,0 +1,66 @@ +@extends('themes.ayline.user.layout') +@section('no_index', true) +@section('hide_alerts', true) +@section('breadcrumbs', Breadcrumbs::render('user.profile', $user)) + +@section('title', __('user_support.my_tickets')) +@section('header_title', __('user_support.title')) +@section('content') +
+ @include('themes.ayline.user.support._feedback') +
+

{{ __('user_support.intro') }}

{{ __('user_support.private') }}

+ + {{ __('user_support.new') }} + +
+ + + +
{{ $tickets->links() }}
+ +
+@endsection --- /dev/null +++ b/resources/views/themes/ayline/user/support/create.blade.php @@ -0,0 +1,62 @@ +@extends('themes.ayline.user.layout') +@section('no_index', true) +@section('hide_alerts', true) +@section('breadcrumbs', Breadcrumbs::render('user.profile', $user)) + +@section('title', __('user_support.create_title')) +@section('header_title', __('user_support.create_title')) +@section('content') +@php + $oldText = static function ($key, $default = '') { + $value = old($key, $default); + return is_string($value) || is_numeric($value) ? (string) $value : ''; + }; +@endphp +
+ {{ __('user_support.back') }} +

{{ __('user_support.create_intro') }}

+ @include('themes.ayline.user.support._feedback') + @if($categories->isEmpty()) +
{{ __('user_support.no_categories') }}
+ @else +
+ @csrf +
+ + +
+
+ + +
+
+ + +

{{ __('user_support.description_help') }}

+
+
+ + +
+ @include('themes.ayline.user.support._files') + +
+ {{ __('user_support.cancel') }} + +
+
+ @endif +
+@endsection --- /dev/null +++ b/resources/views/themes/ayline/user/support/show.blade.php @@ -0,0 +1,137 @@ +@extends('themes.ayline.user.layout') +@section('no_index', true) +@section('hide_alerts', true) +@section('breadcrumbs', Breadcrumbs::render('user.profile', $user)) + +@section('title', __('user_support.details').' · '.$ticket->ticket_number) +@section('header_title', __('user_support.details')) +@section('content') +@php + $oldText = static function ($key, $default = '') { + $value = old($key, $default); + return is_string($value) || is_numeric($value) ? (string) $value : ''; + }; +@endphp +
+ @php + $routeParams = ['username' => strtolower($user->username), 'ticketNumber' => $ticket->ticket_number]; + $isClosed = $ticket->status === \App\Models\SupportTicket::STATUS_CLOSED; + $isFinished = in_array($ticket->status, [\App\Models\SupportTicket::STATUS_CLOSED, \App\Models\SupportTicket::STATUS_RESOLVED], true); + @endphp + + @include('themes.ayline.user.support._feedback') +
+

{{ $ticket->ticket_number }}

{{ $ticket->subject }}

+ @include('themes.ayline.user.support._status') +
+
{{ __('user_support.hint_'.$ticket->status) }}
+
+
{{ __('user_support.category') }}
{{ $ticket->category?->name ?? __('user_support.category_missing') }}
+
{{ __('user_support.created_at') }}
{{ $ticket->created_at?->format('d.m.Y H:i') }}
+
{{ __('user_support.updated_at') }}
{{ $ticket->updated_at?->format('d.m.Y H:i') }}
+
{{ __('user_support.priority') }}
{{ __('user_support.priority_'.$ticket->priority) }}
+ @if($ticket->business) +
{{ __('user_support.business') }}
{{ $ticket->business->name }}
+ @endif +
+ +
+ @if($messages->total() === 0) +
+ +
{{ $user->getName() }}

{{ $ticket->description }}

+
+ @endif + @foreach($messages->getCollection()->reverse() as $message) + @php $isAdmin = $message->sender_type === 'admin'; @endphp + + @endforeach +
+ @if($legacyAttachments->isNotEmpty()) +

{{ __('user_support.attachments') }}

+ @foreach($legacyAttachments as $file) + {{ $file->original_name }} + @endforeach +
+ @endif + @if($messages->hasPages()) +

{{ __('user_support.message_pages') }}

+
{{ $messages->links() }}
+ @endif + + @unless($isClosed) +
+ @csrf +
+ + +

{{ __('user_support.reply_help') }}

+
+ @include('themes.ayline.user.support._files') + +
+
+ @endunless + +
+ @if($isFinished) +
+ @csrf +
+ @endif + @unless($isClosed) +
+ @csrf +
+ @endunless +
+ + @if($isFinished && !$ticket->satisfaction_rating) +
has('rating') || $errors->has('comment')) open @endif> + {{ __('user_support.rate') }} +
+ @csrf +
+ +
+
+ + +
+
+ @elseif($ticket->satisfaction_rating) +

{{ __('user_support.your_rating') }}: {{ $ticket->satisfaction_rating }} / 5

+ @endif +

{{ __('user_support.private') }}

+
+@endsection --- /dev/null +++ b/public/themes/ayline/assets/css/user-support-v1.css @@ -0,0 +1,161 @@ +/* User support is scoped to the existing Ayline profile; no global theme reset. */ +.pesyje-profile-tabs > .container { + overflow-x: auto; + overscroll-behavior-inline: contain; + scrollbar-width: thin; +} +.pesyje-profile-tabs .row { width: max-content; min-width: 100%; } +.pesyje-profile-tabs .page-tab { white-space: nowrap; min-height: 44px; } +.pys-support { + --pys-accent: rgb(var(--primary_color, 0, 128, 120)); + --pys-text: rgb(var(--text_color, 26, 36, 41)); + --pys-muted: rgb(var(--text_muted, 91, 105, 112)); + --pys-border: rgb(var(--border_color, 226, 233, 234)); + --pys-box: rgb(var(--box_background_color, 255, 255, 255)); + --pys-field: rgb(var(--elements_inner_background_color, 255, 255, 255)); + color: var(--pys-text); + font-size: .9375rem; + padding-bottom: calc(32px + env(safe-area-inset-bottom, 0px)); + overflow-wrap: anywhere; +} +.pys-support *, .pys-support *::before, .pys-support *::after { box-sizing: border-box; } +.pys-support p { margin: 0 0 .625rem; } +.pys-support a { color: var(--pys-accent); } +.pys-support a:focus-visible, .pys-support button:focus-visible, +.pys-support input:focus-visible, .pys-support select:focus-visible, +.pys-support textarea:focus-visible, .pys-support summary:focus-visible { + outline: 3px solid var(--pys-accent); + outline-offset: 3px; +} +.pys-muted, .pys-help { color: var(--pys-muted); } +.pys-support .pys-help { font-size: .8rem; line-height: 1.6; margin-top: .45rem; } +.pys-intro { display: flex; align-items: flex-start; justify-content: space-between; gap: 20px; margin-bottom: 20px; } +.pys-intro > div { min-width: 0; } +.pys-intro > .pys-btn { flex-shrink: 0; } +.pys-btn { + display: inline-flex; align-items: center; justify-content: center; gap: 8px; + min-height: 44px; padding: 10px 17px; border-radius: 10px; + border: 1px solid transparent; font: inherit; font-weight: 600; + line-height: 1.35; text-decoration: none; cursor: pointer; text-align: center; +} +.pys-support .pys-btn-primary { background: var(--pys-accent); color: #fff; } +.pys-support .pys-btn-primary:hover { filter: brightness(.93); color: #fff; text-decoration: none; } +.pys-support .pys-btn-secondary { background: var(--pys-box); border-color: var(--pys-border); color: var(--pys-text); } +.pys-support .pys-btn-secondary:hover { border-color: var(--pys-accent); text-decoration: none; } +.pys-btn:disabled { opacity: .65; cursor: wait; } +.pys-filters { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 16px; } +.pys-support .pys-filter { + display: inline-flex; align-items: center; justify-content: center; gap: 7px; min-height: 40px; + padding: 8px 11px; border: 1px solid var(--pys-border); border-radius: 9px; + background: var(--pys-box); color: var(--pys-muted); text-decoration: none; font-size: .8rem; +} +.pys-filter span { font-variant-numeric: tabular-nums; } +.pys-support .pys-filter.is-active { border-color: var(--pys-accent); color: var(--pys-accent); background: rgba(var(--primary_color, 0, 128, 120), .08); font-weight: 600; } +.pys-search { display: flex; gap: 8px; margin-bottom: 18px; } +.pys-search .pys-input { flex: 1; min-width: 0; } +.pys-input { + display: block; width: 100%; min-height: 46px; padding: 11px 13px; + border: 1px solid var(--pys-border); border-radius: 9px; + background: var(--pys-field); color: var(--pys-text); font: inherit; line-height: 1.5; +} +.pys-input::placeholder { color: var(--pys-muted); opacity: .85; } +textarea.pys-input { min-height: 112px; resize: vertical; } +.pys-ticket-list { display: grid; gap: 14px; } +.pys-support .pys-ticket-card { + display: flex; align-items: flex-start; gap: 13px; padding: 19px; + border: 1px solid var(--pys-border); border-radius: 13px; background: var(--pys-box); + color: var(--pys-text); text-decoration: none; +} +.pys-support .pys-ticket-card:hover { border-color: var(--pys-accent); text-decoration: none; } +.pys-ticket-icon { display: grid; place-items: center; flex: 0 0 43px; width: 43px; height: 43px; border-radius: 50%; font-size: 20px; } +.pys-ticket-copy { flex: 1; min-width: 0; } +.pys-card-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 10px; } +.pys-card-heading h4 { margin: 0 0 7px; font-size: .96rem; font-weight: 650; line-height: 1.5; color: var(--pys-text); } +.pys-support .pys-ticket-meta { font-size: .78rem; color: var(--pys-muted); margin-bottom: 6px; } +.pys-ticket-meta span, .pys-ticket-category span { margin: 0 4px; } +.pys-support .pys-ticket-preview { color: var(--pys-muted); font-size: .85rem; line-height: 1.6; margin-bottom: 5px; } +.pys-support .pys-ticket-category { color: var(--pys-muted); font-size: .74rem; line-height: 1.6; margin: 0; } +.pys-ticket-arrow { align-self: center; font-size: 14px; flex-shrink: 0; } +.pys-status { display: inline-flex; align-items: center; align-self: flex-start; padding: 5px 9px; border-radius: 999px; font-size: .72rem; font-weight: 600; line-height: 1.4; flex-shrink: 0; } +.pys-status-new { background: #fff0f2; color: #a82c43; } +.pys-status-in_progress { background: #fff4dd; color: #855704; } +.pys-status-waiting_user, .pys-status-resolved { background: #e5f7ed; color: #15653a; } +.pys-status-waiting_staff { background: #e7f5f8; color: #176778; } +.pys-status-closed { background: #eef0f3; color: #515b69; } +.pys-panel { padding: 23px; border: 1px solid var(--pys-border); border-radius: 13px; background: var(--pys-box); margin-bottom: 18px; } +.pys-field { margin-bottom: 19px; } +.pys-field > label { display: block; font-weight: 600; font-size: .86rem; margin-bottom: 7px; } +.pys-upload { display: flex; align-items: flex-start; gap: 12px; padding: 14px; border: 1px dashed var(--pys-border); border-radius: 9px; } +.pys-upload > i { font-size: 24px; color: var(--pys-muted); } +.pys-upload > div { flex: 1; min-width: 0; } +.pys-upload input { max-width: 100%; width: 100%; color: var(--pys-text); font: inherit; font-size: .8rem; } +.pys-upload input::file-selector-button { padding: 9px 12px; margin-right: 10px; border: 1px solid var(--pys-border); border-radius: 7px; background: var(--pys-field); color: var(--pys-text); cursor: pointer; } +.pys-form-actions { display: flex; justify-content: flex-end; gap: 10px; flex-wrap: wrap; } +.pys-form-error { color: #a82c43; font-size: .875rem; } +.pys-empty { text-align: center; border: 1px solid var(--pys-border); border-radius: 13px; background: var(--pys-box); padding: 42px 20px; } +.pys-empty > i { font-size: 34px; color: var(--pys-accent); display: block; margin-bottom: 13px; } +.pys-empty h4 { font-size: 1rem; } +.pys-empty p { color: var(--pys-muted); } +.pys-pagination { margin-top: 18px; overflow-x: auto; } +.pys-pagination nav { min-width: 0; } +.pys-bottom-links { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 15px; font-size: .83rem; margin: 20px 0 0; } +.pys-bottom-links a, .pys-back { display: inline-flex; align-items: center; gap: 7px; min-height: 36px; } +.pys-back { margin-bottom: 12px; text-decoration: none; } +.pys-detail-nav { margin: 0 0 14px; } +.pys-detail-nav .pys-back { margin-bottom: 0; } +.pys-detail-header { display: flex; justify-content: space-between; align-items: flex-start; gap: 12px; margin-bottom: 16px; } +.pys-detail-header h2 { font-size: 1.2rem; line-height: 1.5; margin: 0; } +.pys-notice { display: flex; align-items: flex-start; gap: 10px; padding: 13px 15px; border-radius: 10px; margin-bottom: 16px; font-size: .875rem; background: rgba(var(--primary_color, 0, 128, 120), .07); border: 1px solid var(--pys-border); } +.pys-notice-success { background: #e5f7ed; color: #15653a; } +.pys-notice-error { display: block; background: #fff0f2; color: #a82c43; } +.pys-notice-error ul { margin: 7px 0 0; padding-left: 22px; } +.pys-details { display: grid; gap: 11px; } +.pys-details > div { display: grid; grid-template-columns: minmax(115px, 32%) 1fr; gap: 12px; } +.pys-details dt { color: var(--pys-muted); font-size: .84rem; font-weight: 400; } +.pys-details dd { margin: 0; min-width: 0; font-size: .86rem; } +.pys-thread { display: grid; gap: 16px; margin: 24px 0; } +.pys-message { display: flex; gap: 10px; align-items: flex-start; } +.pys-message-avatar { display: grid; place-items: center; width: 36px; height: 36px; border-radius: 50%; background: var(--pys-accent); color: #fff; flex-shrink: 0; font-size: 17px; } +.pys-message-avatar img { display: block; width: 36px; height: 36px; border-radius: 50%; object-fit: cover; } +.is-admin .pys-message-avatar { background: #17252b; } +.pys-message-bubble { flex: 1; min-width: 0; padding: 15px 17px; border: 1px solid var(--pys-border); border-radius: 0 12px 12px 12px; background: rgba(var(--primary_color, 0, 128, 120), .05); } +.is-admin .pys-message-bubble { background: var(--pys-box); } +.pys-message-head { display: flex; flex-wrap: wrap; align-items: baseline; gap: 7px 12px; margin-bottom: 9px; } +.pys-message-head strong { font-size: .85rem; } +.pys-message-head time { font-size: .74rem; color: var(--pys-muted); } +.pys-support .pys-message-body { white-space: pre-wrap; line-height: 1.7; margin: 0; font-size: .9rem; } +.pys-attachments { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 12px; } +.pys-support .pys-file { display: inline-flex; align-items: center; gap: 7px; max-width: 100%; min-height: 40px; padding: 7px 10px; border: 1px solid var(--pys-border); border-radius: 8px; font-size: .8rem; text-decoration: none; background: var(--pys-box); } +.pys-file > span { min-width: 0; } +.pys-file small { color: var(--pys-muted); white-space: nowrap; } +.pys-small-heading { font-size: .9rem; } +.pys-reply { margin-top: 24px; } +.pys-ticket-actions { display: flex; flex-wrap: wrap; gap: 10px; margin: 20px 0; } +.pys-rating summary { cursor: pointer; font-size: .9rem; font-weight: 600; } +.pys-rating[open] summary { margin-bottom: 20px; } +.pys-privacy { margin-top: 24px; } +@media (max-width: 767.98px) { + .pys-support { padding-bottom: calc(96px + env(safe-area-inset-bottom, 0px)); } + .pys-intro { flex-direction: column; gap: 12px; } + .pys-intro > .pys-btn { width: 100%; } + .pys-ticket-card { gap: 10px; } + .pys-support .pys-ticket-card { padding: 15px 12px; } + .pys-ticket-icon { width: 36px; height: 36px; flex-basis: 36px; font-size: 18px; } + .pys-card-heading { flex-wrap: wrap; gap: 3px 8px; margin-bottom: 6px; } + .pys-card-heading h4 { flex-basis: 100%; margin-bottom: 0; font-size: .91rem; } + .pys-ticket-arrow { font-size: 12px; } + .pys-panel { padding: 17px 14px; } + .pys-input { font-size: 16px; } + .pys-filters { gap: 7px; } + .pys-filter { flex-grow: 0; } + .pys-search .pys-btn { padding: 10px 12px; } + .pys-message { gap: 8px; } + .pys-message-avatar, .pys-message-avatar img { width: 31px; height: 31px; } + .pys-message-bubble { padding: 12px; } + .pys-detail-header { flex-wrap: wrap; } + .pys-details > div { grid-template-columns: minmax(95px, 40%) 1fr; gap: 9px; } + .pys-form-actions > .pys-btn-primary { flex: 1; } +} +@media (prefers-reduced-motion: reduce) { + .pesyje-profile-tabs > .container { scroll-behavior: auto; } +} --- /dev/null +++ b/public/themes/ayline/assets/js/user-support-v1.js @@ -0,0 +1,66 @@ +/* Progressive enhancement only: forms, filters and downloads also work without JS. */ +(function () { + 'use strict'; + + function init() { + const scroller = document.querySelector('.pesyje-profile-tabs > .container'); + const active = scroller && scroller.querySelector('.page-tab.active'); + if (scroller && active) { + const left = active.getBoundingClientRect().left - scroller.getBoundingClientRect().left; + if (left < 0 || left + active.offsetWidth > scroller.clientWidth) { + scroller.scrollLeft += left - (scroller.clientWidth - active.offsetWidth) / 2; + } + } + + document.querySelectorAll('[data-pys-files]').forEach(function (input) { + input.addEventListener('change', function () { + const files = Array.from(input.files || []); + const valid = files.length <= 5 && files.every(function (file) { + return file.size <= 5 * 1024 * 1024 && /\.(jpe?g|png|webp|pdf)$/i.test(file.name); + }); + input.setCustomValidity(valid ? '' : input.dataset.error); + const summary = input.closest('.pys-field').querySelector('[aria-live]'); + if (summary) summary.textContent = valid ? files.map(function (file) { return file.name; }).join(', ') : input.dataset.error; + if (!valid) input.reportValidity(); + }); + }); + + document.querySelectorAll('.pys-form').forEach(function (form) { + form.addEventListener('submit', function (event) { + if (form.dataset.submitting === 'true') { event.preventDefault(); return; } + if (!form.checkValidity()) { event.preventDefault(); form.reportValidity(); return; } + if (navigator.onLine === false) { + event.preventDefault(); + const error = form.querySelector('.pys-form-error'); + if (error) { error.textContent = form.dataset.offline; error.hidden = false; } + return; + } + if (form.dataset.confirm && !window.confirm(form.dataset.confirm)) { + event.preventDefault(); return; + } + form.dataset.submitting = 'true'; + form.querySelectorAll('button[type="submit"]').forEach(function (button) { button.disabled = true; }); + const label = form.querySelector('[data-pys-submit-label]'); + if (label && form.dataset.sending) label.textContent = form.dataset.sending; + }); + }); + + const errors = document.querySelector('[data-pys-errors]'); + if (errors) errors.focus(); + } + + if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init); + else init(); + + // Do not redisplay a private conversation from the back/forward cache after logout. + window.addEventListener('pagehide', function (event) { + if (event.persisted && document.body.hasAttribute('data-private-support')) { + document.body.style.visibility = 'hidden'; + } + }); + window.addEventListener('pageshow', function (event) { + if (event.persisted && document.body.hasAttribute('data-private-support')) { + window.location.reload(); + } + }); +})(); --- a/public/sw.js +++ b/public/sw.js @@ -1,5 +1,5 @@ /* PESYJE V13.4 - PWA ADVANCED PRO */ -const CACHE_VERSION = 'pesyje-pwa-v13-4'; +const CACHE_VERSION = 'pesyje-pwa-support-profile-v1'; const STATIC_CACHE = `${CACHE_VERSION}-static`; const RUNTIME_CACHE = `${CACHE_VERSION}-runtime`; const DB_NAME = 'pesyje-pwa'; @@ -7,7 +7,6 @@ const QUEUE_STORE = 'telemetry_queue'; const APP_SHELL = [ - '/', '/offline.html', '/manifest.webmanifest', '/pwa/config.json', @@ -75,6 +74,7 @@ event.waitUntil( caches.open(STATIC_CACHE) .then(cache => Promise.allSettled(APP_SHELL.map(url => cache.add(url)))) + .then(() => self.skipWaiting()) .then(() => telemetry('sw_installed', { cache: CACHE_VERSION })) ); }); @@ -104,9 +104,24 @@ const prefixes = [ '/tatalala', '/admin', '/login', '/logout', '/register', '/password', '/email', '/sanctum', '/livewire', '/api', + '/user', '/support', '/bizneset', '/payments', '/pwa/telemetry', '/pwa/push/subscribe' ]; - return prefixes.some(prefix => url.pathname.startsWith(prefix)); + let pathname; + try { pathname = decodeURIComponent(url.pathname).toLowerCase(); } + catch (_) { return true; } + return prefixes.some(prefix => pathname.startsWith(prefix)); +} + +function canCacheResponse(response) { + if (!response || !response.ok || response.redirected) return false; + const control = response.headers.get('Cache-Control') || ''; + if (/\b(no-store|private|no-cache)\b/i.test(control)) return false; + if (response.url) { + const destination = new URL(response.url); + if (shouldBypass({ method: 'GET' }, destination)) return false; + } + return true; } async function config() { @@ -153,7 +168,7 @@ try { const response = await fetch(request); - if (response && response.ok) { + if (canCacheResponse(response)) { const copy = response.clone(); const cache = await caches.open(RUNTIME_CACHE); await cache.put(request, copy); @@ -173,7 +188,7 @@ const cached = await caches.match(request); if (cached) return cached; const response = await fetch(request); - if (response && response.ok) { + if (canCacheResponse(response)) { const cfg = await config(); const copy = response.clone(); const cache = await caches.open(RUNTIME_CACHE);