From cf07ca6a6e8ee1a690ba3be21dd7a4e2dba7494a Mon Sep 17 00:00:00 2001 From: System User Date: Fri, 6 Mar 2026 13:00:14 +0100 Subject: [PATCH] feat :sparkles: : add buttons for read notifs --- .../Club/NotificationController.php | 47 +++- .../DeleteNotificationsRequest.php | 29 +++ .../club/partials/_card-notification.scss | 123 +++++++++++ resources/ts/club/club.ts | 1 + .../ts/club/partials/delete-notifications.ts | 205 ++++++++++++++++++ .../club/pages/notifications/index.blade.php | 55 ++++- .../club/partials/card-notification.blade.php | 78 +++++-- routes/web-club.php | 7 + 8 files changed, 514 insertions(+), 31 deletions(-) create mode 100644 app/Http/Requests/Club/Notifications/DeleteNotificationsRequest.php create mode 100644 resources/ts/club/partials/delete-notifications.ts diff --git a/app/Http/Controllers/Club/NotificationController.php b/app/Http/Controllers/Club/NotificationController.php index 1ce7b56e..c9de63e4 100644 --- a/app/Http/Controllers/Club/NotificationController.php +++ b/app/Http/Controllers/Club/NotificationController.php @@ -3,6 +3,9 @@ namespace App\Http\Controllers\Club; use App\Http\Controllers\Controller; +use App\Http\Requests\Club\Notifications\DeleteNotificationsRequest; +use App\Models\Notification; +use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\View as FacadesView; class NotificationController extends Controller @@ -16,7 +19,10 @@ class NotificationController extends Controller { /** @var \App\Models\Customer $customerModel */ $customerModel = auth('club')->user(); - $notificationModels = $customerModel->notifications()->orderBy('created_at', 'DESC')->get(); + $notificationModels = $customerModel->notifications() + ->whereNotIn('id', $customerModel->readNotifications()->pluck('notification_id')) + ->orderBy('created_at', 'DESC') + ->get(); return view('club.pages.notifications.index', compact('notificationModels')); } @@ -29,4 +35,41 @@ class NotificationController extends Controller { return response()->json(FacadesView::shared('notifications_count', 0)); } -} + + /** + * Delete notification from list + * @param Notification $notification + * @return \Illuminate\Http\JsonResponse + */ + public function destroy(Notification $notification): \Illuminate\Http\JsonResponse + { + /** @var \App\Models\Customer $customer */ + $customer = Auth::guard('club')->user(); + + $customer->readNotifications()->syncWithoutDetaching($notification->id); + session()->flash('success', __('Notification supprimée.')); + return response()->json(['success' => true]); + } + + /** + * Delete notification from list + * @param \App\Http\Requests\Club\Notifications\DeleteNotificationsRequest $request + * @return \Illuminate\Http\JsonResponse + */ + public function destroyMultiple(DeleteNotificationsRequest $request): \Illuminate\Http\JsonResponse + { + /** @var \App\Models\Customer $customer */ + $customer = Auth::guard('club')->user(); + + $customer->readNotifications()->syncWithoutDetaching($request->validated('ids')); + + $count = count($request->validated(('ids'))); + + session()->flash('success', trans_choice( + '{1} Notification supprimée.|[2,*] :count notifications supprimées.', + $count, + ['count' => $count] + )); + return response()->json(['success' => true]); + } +} \ No newline at end of file diff --git a/app/Http/Requests/Club/Notifications/DeleteNotificationsRequest.php b/app/Http/Requests/Club/Notifications/DeleteNotificationsRequest.php new file mode 100644 index 00000000..bfbbc59e --- /dev/null +++ b/app/Http/Requests/Club/Notifications/DeleteNotificationsRequest.php @@ -0,0 +1,29 @@ +check(); + } + + /** + * Get the validation rules that apply to the request. + * + * @return array|string> + */ + public function rules(): array + { + return [ + 'ids' => ['required', 'array', 'min:2'], + 'ids.*' => ['integer', 'exists:notifications,id'], + ]; + } +} diff --git a/resources/sass/club/partials/_card-notification.scss b/resources/sass/club/partials/_card-notification.scss index 6673893e..1ac1dac6 100644 --- a/resources/sass/club/partials/_card-notification.scss +++ b/resources/sass/club/partials/_card-notification.scss @@ -25,3 +25,126 @@ } } } + +// ------------------------Style for deleting notification in notification list page ------------------- +// Checkbox custom + +.notif-checkbox-icon { + display: inline-flex; + width: 1.25rem;; + height: 1.25rem;; + flex-shrink: 0; + align-items: center; + justify-content: center; + border: 2px solid #adb5bd; + border-radius: 4px; + background: #fff; + cursor: pointer; + transition: background 0.15s ease, border-color 0.15s ease; + + &::after { + display: block; + width: 0.65rem; + height: 0.4rem; + border-bottom: 2.5px solid #fff; + border-left: 2.5px solid #fff; + margin-top: -2px; + content: ''; + transform: rotate(-45deg) scale(0); + transform-origin: center; + transition: transform 0.15s ease; + } + + &.is-checked { + border-color: $color-danger; + background: $color-danger; + + &::after { + transform: rotate(-45deg) scale(1); + } + } + + &.is-indeterminate { + border-color: $color-danger; + background: $color-danger; + + &::after { + width: 0.65rem; + height: 0; + border-bottom: 2.5px solid #fff; + margin-top: 0; + transform: rotate(0deg) scale(1); + } + } +} + +// display checkbox near notif + +.notif-checkbox-wrapper { + display: flex; + overflow: hidden; + width: 0; + flex-shrink: 0; + align-items: center; + justify-content: center; + opacity: 0; + pointer-events: none; + transition: width 0.2s ease, opacity 0.2s ease; +} + +// delete single + +.notif-delete-wrapper { + overflow: hidden; + flex-shrink: 0; + transition: width 0.2s ease, opacity 0.2s ease; +} + +.btn-delete-single { + display: inline-flex; + width: 2rem; + height: 2rem; + align-items: center; + justify-content: center; + padding: 0; + border-radius: 4px; + background: transparent; + cursor: pointer; + transition: background 0.15s ease, color 0.15s ease; + + &:hover { + background: #dc3545; + color: #fff; + } + + i { + font-size: 0.75rem; + } +} + +// Mode select all + +#notifications-list { + &.selection-mode { + .notif-checkbox-wrapper { + width: 1.8rem; + opacity: 1; + pointer-events: auto; + } + + .notification-item-wrapper { + cursor: pointer; + } + + .card-notification { + pointer-events: none; + } + + // hide sigle delete + .notif-delete-wrapper { + width: 0; + opacity: 0; + pointer-events: none; + } + } +} diff --git a/resources/ts/club/club.ts b/resources/ts/club/club.ts index 3c1dcbc6..154db369 100644 --- a/resources/ts/club/club.ts +++ b/resources/ts/club/club.ts @@ -9,6 +9,7 @@ import "./utilities/vue-club"; // PARTIALS import "./partials/layout"; +import './partials/delete-notifications' // CUSTOM import "./base/common"; diff --git a/resources/ts/club/partials/delete-notifications.ts b/resources/ts/club/partials/delete-notifications.ts new file mode 100644 index 00000000..41c44844 --- /dev/null +++ b/resources/ts/club/partials/delete-notifications.ts @@ -0,0 +1,205 @@ +import sweetalert from "../../modules/sweetalertConfirm"; +import trans from "../../modules/trans"; + +interface NotificationsElements { + list: HTMLElement; + toolbar: HTMLElement; + btnToggle: HTMLButtonElement; + btnCancel: HTMLButtonElement; + btnDelete: HTMLButtonElement; + selectedCount: HTMLElement; + selectAllBox: HTMLElement; +} + +// connect with elements +function resolveElements(): NotificationsElements | null { + const list = document.getElementById('notifications-list'); + const toolbar = document.getElementById('selection-toolbar'); + const btnToggle = document.getElementById('btn-toggle-selection'); + const btnCancel = document.getElementById('btn-cancel-selection'); + const btnDelete = document.getElementById('btn-delete-selected'); + const selectedCount = document.getElementById('selected-count'); + const selectAllBox = document.getElementById('select-all-checkbox'); + + if (!list || !toolbar || !btnToggle || !btnCancel || !btnDelete || !selectedCount || !selectAllBox) { + return null; + } + + return { + list, + toolbar, + btnToggle: btnToggle as HTMLButtonElement, + btnCancel: btnCancel as HTMLButtonElement, + btnDelete: btnDelete as HTMLButtonElement, + selectedCount, + selectAllBox, + }; +} + +// selectors + +function getBoxes(list: HTMLElement): HTMLElement[] { + return [...list.querySelectorAll('.notif-checkbox-icon[data-id]')]; +} + +function getChecked(list: HTMLElement): HTMLElement[] { + return getBoxes(list).filter(b => b.classList.contains('is-checked')); +} + +function getCsrfToken(): string { + return document.querySelector('meta[name="csrf-token"]')?.content ?? ''; +} + +// Toolbar + +function updateToolbar(els: NotificationsElements): void { + const { list, btnDelete, selectedCount, selectAllBox } = els; + const checked = getChecked(list); + const total = getBoxes(list).length; + + selectedCount.textContent = String(checked.length); + btnDelete.classList.toggle('d-none', checked.length < 1); + + selectAllBox.classList.remove('is-checked', 'is-indeterminate'); + + if (total > 0 && checked.length === total) { + selectAllBox.classList.add('is-checked'); + selectAllBox.setAttribute('aria-checked', 'true'); + } else if (checked.length > 0) { + selectAllBox.classList.add('is-indeterminate'); + selectAllBox.setAttribute('aria-checked', 'mixed'); + } else { + selectAllBox.setAttribute('aria-checked', 'false'); + } +} + +// Mode select all + +// Enable mode +function enterSelectionMode(els: NotificationsElements): void { + els.list.classList.add('selection-mode'); + els.toolbar.classList.remove('d-none'); + els.toolbar.classList.add('d-flex'); + els.btnToggle.classList.add('d-none'); +} + +// Disable mode +function exitSelectionMode(els: NotificationsElements): void { + els.list.classList.remove('selection-mode'); + els.toolbar.classList.add('d-none'); + els.toolbar.classList.remove('d-flex'); + els.btnToggle.classList.remove('d-none'); + + getBoxes(els.list).forEach(b => { + b.classList.remove('is-checked'); + b.setAttribute('aria-checked', 'false'); + }); + + els.selectAllBox.classList.remove('is-checked', 'is-indeterminate'); + els.selectAllBox.setAttribute('aria-checked', 'false'); + updateToolbar(els); +} + +// call Ajax +function fetchDelete(url: string, body: object | null = null): Promise { + return fetch(url, { + method: 'DELETE', + headers: { + 'X-CSRF-TOKEN': getCsrfToken(), + 'Content-Type': 'application/json', + 'Accept': 'application/json', + }, + ...(body ? { body: JSON.stringify(body) } : {}), + }); +} + +//delete one by one +function deleteSingle(btn: HTMLButtonElement, els: NotificationsElements): void { + sweetalert.methods.confirm( + 'Supprimer cette notification ?', + '', + btn, + () => { + fetchDelete(btn.dataset.url!) + .then(r => { if (!r.ok) throw new Error(); }) + .then(() => window.location.reload()) + .catch(() => sweetalert.methods.alert('Une erreur est survenue.', '', null, null, { icon: 'error' })); + } + ); +} + +// delete multiple +function deleteSelected(els: NotificationsElements): void { + const { list, btnDelete } = els; + const checked = getChecked(list); + + const count = checked.length; + const message = count === 1 + ? trans.methods.__('Une notification sera supprimée.') + : trans.methods.__(':count notifications seront supprimées.').replace(':count', String(count)); + + sweetalert.methods.confirm( + trans.methods.__('Supprimer les notifications sélectionnées ?'), + message, + btnDelete, + () => { + fetchDelete(btnDelete.dataset.url!, { ids: checked.map(b => b.dataset.id!) }) + .then(r => { if (!r.ok) throw new Error(); return r.json(); }) + .then(() => window.location.reload()) + .catch(() => sweetalert.methods.alert('Une erreur est survenue.', '', null, null, { icon: 'error' })); + } + ); +} + +// init +document.addEventListener("DOMContentLoaded", function () { + const els = resolveElements(); + if (!els) return; + + const { list, btnToggle, btnCancel, btnDelete, selectAllBox } = els; + + btnToggle.addEventListener('click', () => enterSelectionMode(els)); + btnCancel.addEventListener('click', () => exitSelectionMode(els)); + btnDelete.addEventListener('click', () => deleteSelected(els)); + + selectAllBox.addEventListener('click', (e: MouseEvent) => { + e.stopPropagation(); + const boxes = getBoxes(list); + const allChecked = getChecked(list).length === boxes.length && boxes.length > 0; + boxes.forEach(b => { + b.classList.toggle('is-checked', !allChecked); + b.setAttribute('aria-checked', String(!allChecked)); + }); + updateToolbar(els); + }); + + selectAllBox.addEventListener('keydown', (e: KeyboardEvent) => { + if (e.key === ' ' || e.key === 'Enter') { e.preventDefault(); selectAllBox.click(); } + }); + + list.addEventListener('click', (e: MouseEvent) => { + const target = e.target as HTMLElement; + + // delete single + const deleteBtn = target.closest('.btn-delete-single'); + if (deleteBtn) { + e.preventDefault(); + e.stopPropagation(); + deleteSingle(deleteBtn, els); + return; + } + + // when mode select all active select all div + if (!list.classList.contains('selection-mode')) return; + + const wrapper = target.closest('.notification-item-wrapper'); + if (!wrapper) return; + + const box = wrapper.querySelector('.notif-checkbox-icon[data-id]'); + if (!box) return; + + const checked = box.classList.toggle('is-checked'); + box.setAttribute('aria-checked', String(checked)); + updateToolbar(els); + }); +}); \ No newline at end of file diff --git a/resources/views/club/pages/notifications/index.blade.php b/resources/views/club/pages/notifications/index.blade.php index 0d38f61f..0f6b6dfc 100644 --- a/resources/views/club/pages/notifications/index.blade.php +++ b/resources/views/club/pages/notifications/index.blade.php @@ -31,13 +31,56 @@
@if (!$notificationModels->count()) {{ __('Pas de nouvelles notifications') }} + @else +
+ + + +
+ + + + + + +
+
+ +
+ @foreach ($notificationModels as $notificationModel) + @include('club.partials.card-notification', [ + 'outterClass' => 'w-100 my-3', + 'notification' => $notificationModel, + ]) + @endforeach +
@endif - @foreach ($notificationModels as $notificationModel) - @include('club.partials.card-notification', [ - 'outterClass' => 'w-100 my-3', - 'notification' => $notificationModel, - ]) - @endforeach
diff --git a/resources/views/club/partials/card-notification.blade.php b/resources/views/club/partials/card-notification.blade.php index 1a66362e..8c78ebb9 100644 --- a/resources/views/club/partials/card-notification.blade.php +++ b/resources/views/club/partials/card-notification.blade.php @@ -9,31 +9,63 @@ default => throw $unhandled_type_exception, }; @endphp - -
- @switch($notification->notifiable_type) - @case(\App\Models\Message::class) - {{ __('Magnet') }} - @break - @default - @php throw $unhandled_type_exception; @endphp - @endswitch +
+ + {{-- Checkbox --}} +
+
-
-
- diff --git a/routes/web-club.php b/routes/web-club.php index 26e09b85..07e7c643 100644 --- a/routes/web-club.php +++ b/routes/web-club.php @@ -52,6 +52,13 @@ Route::name('club.')->prefix('club')->group(function () { Route::get('la-quete', [QuestController::class, 'index'])->name('quests.index'); Route::get('les-jeux', [GameController::class, 'index'])->name('games.index'); + // * NOTIFICATIONS + Route::delete('/notifications/{notification}', [NotificationController::class, 'destroy']) + ->name('notifications.delete'); + Route::delete('/notifications', [NotificationController::class, 'destroyMultiple']) + ->name('notifications.delete-multiple'); + + // * NEWS CLUB Route::get('articles/{news_club_slug}', [SurveyController::class, 'show'])->name('news.show'); Route::post('sondage/{news_club}/reponse', [SurveyController::class, 'store'])->name('surveys.store');