# Notification Localization: Frontend Integration Guide

**For:** admin dashboard, trainer dashboard, and storefront (learner) developers
**API base:** `/api/v1`
**Status:** implemented on `feature/notification-cycle`, ships with the next deploy
**Related:** [notifications-integration.md](./notifications-integration.md) (stream and event reference), [notifications-frontend-update.md](./notifications-frontend-update.md) (stream migration)

---

## 0. TL;DR

**The problem.** A user switched the UI to Arabic and the notification list still showed a mix of English and Arabic. Two backend causes:

1. Business notifications were saved as one already-rendered string in whatever language the recipient had **at the moment of sending**. Switching language later could never change them.
2. Ten notification strings had no Arabic translation at all (book purchase, program assigned, course revision requested, payment failed, and others).

**What changed.** New notifications store their title and body in **every** supported language (`en` and `ar`). The list endpoint picks the language of each request. The API response fields are unchanged: `title` and `body` are still plain strings.

**What you have to do**

| # | Action | Why |
|---|---|---|
| 1 | Send `Accept-Language: ar` or `en` on **every** REST call, including `GET /notifications` (§3) | The list is rendered in this language. Without it the server answers in `ar`. |
| 2 | When the user changes language, refetch the notification list (§4) | The same notification now comes back in the other language. |
| 3 | Never cache `title`/`body` across languages, and never persist them in local storage (§4) | They are language-specific snapshots. |
| 4 | When the user changes language, also `PUT` their profile `locale` (§5) | Real-time stream events are rendered in the **profile** language, not the request language. |
| 5 | Nothing else. Response shapes, routes, and the stream event shape did not change. | |

---

## 1. What is (and is not) fixed

| Notification | Behavior now |
|---|---|
| Created **after** this deploy | Follows the language of the request (list) or of the profile (stream). Switching language re-renders it. |
| Created **before** this deploy | Stays in the language it was sent in. The original translation keys were never stored, so the server cannot re-translate these. They age out as new ones arrive, or the user can use "clear all". |
| Sent by an admin from the dashboard (`admin.message_sent`) | Shown exactly as the admin typed it. Admin text is free-form and is **not** translated. |

Expect a mix of languages in an old account's list right after the deploy. That is old data, not a bug.

---

## 2. Endpoint contract (unchanged shape)

### `GET /api/v1/notifications`

Shared by the admin, trainer, and learner apps. Paginated, 15 per page. Optional `?read_status=read|unread`.

```http
GET /api/v1/notifications
Authorization: Bearer <JWT>
Accept-Language: ar
```

```json
{
  "status": true,
  "message": "تم استرجاع الإشعارات بنجاح",
  "data": {
    "unread_count": 3,
    "notifications": {
      "super_admin_notifications": [
        {
          "id": "9b1d6c0e-4f7a-4d3e-9a51-2f0c9d1e7a10",
          "title": "تم تأكيد شراء كتاب",
          "body": "أتمّ متعلم شراء كتاب رقمي.",
          "type": null,
          "created_at": "2026 سبتمبر 28, 09:25 ص",
          "is_read": false,
          "notify_id": null,
          "additional_data": {
            "event": "book.purchase_confirmed",
            "resource": { "type": "book", "id": "book-public-uuid" },
            "user_id": 44,
            "book_id": 7
          }
        }
      ],
      "links": {},
      "meta": {}
    }
  }
}
```

Notes:

- The array key depends on the caller: `super_admin_notifications`, `supervisor_notifications`, `trainer_notifications`, or `user_notifications`. Read it by the caller's role.
- `title` and `body` are always `string | null`. They are never objects, so you do not handle a `{en, ar}` shape on the client.
- `created_at` is formatted with the same request language, which is why the month name and AM/PM marker change with `Accept-Language`.
- Route on `additional_data.event` and `additional_data.resource`, never on `title` or `body`.

The other REST helpers (`unread-count`, `PATCH /{id}/read`, `POST /mark-all-read`, `DELETE /{id}`, `DELETE /clear-all`) do not return notification text. They are unaffected.

### Language resolution on the server

| Request has | Result |
|---|---|
| `Accept-Language: en` | English |
| `Accept-Language: ar` | Arabic |
| `Accept-Language: en-US,en;q=0.9` | The **first** tag before the comma is used (`en-US`). It is not a supported value, so the server falls back to `ar`. Send the bare code (`en`), not a browser default. |
| Header missing or unsupported | `ar` |

If a stored notification has no text for the requested language, the server falls back to English, then to whichever language exists. The list never returns an empty title because of a language mismatch.

---

## 3. Send `Accept-Language` on every call

Set it once in your HTTP client from the **UI** language, so it can never drift from what the user sees.

```ts
// axios
api.interceptors.request.use((config) => {
  config.headers['Accept-Language'] = i18n.language; // 'ar' | 'en' only, no region suffix
  return config;
});
```

```ts
// fetch wrapper
export const apiFetch = (path: string, init: RequestInit = {}) =>
  fetch(`/api/v1${path}`, {
    ...init,
    headers: {
      ...init.headers,
      Authorization: `Bearer ${token()}`,
      'Accept-Language': i18n.language,
    },
  });
```

Normalize before sending. `navigator.language` is often `en-US`, which the server does not treat as `en`:

```ts
const supported = ['ar', 'en'] as const;
const apiLang = supported.find((l) => i18n.language.startsWith(l)) ?? 'ar';
```

---

## 4. Handling a language change

The list is a language-specific snapshot, so a language switch has to invalidate it.

```ts
i18n.on('languageChanged', () => {
  // React Query
  queryClient.invalidateQueries({ queryKey: ['notifications'] });
  queryClient.invalidateQueries({ queryKey: ['notifications', 'unread-count'] }); // count is language-independent, refetch is optional
});
```

Rules:

1. **Include the language in the cache key**, or invalidate on switch, so a cached Arabic list is never shown to an English UI.
   ```ts
   useQuery({ queryKey: ['notifications', lang, page], queryFn: fetchNotifications });
   ```
2. **Do not persist `title`/`body`** in `localStorage`, `sessionStorage`, Redux-persist, or a service worker. Persist ids and read state only.
3. **Do not translate on the client.** The backend owns the text. Client-side lookup tables keyed on `title` will break as soon as the server changes wording.
4. The `notification` dropdown in your screenshot (bell menu) should read from the same query, so it updates together with the page.

---

## 5. Keep the profile language in sync (real-time stream)

The **stream** (`GET /{user|admin|trainer}/notifications/stream`) pushes text that was rendered when the event was published, in the recipient's **profile** `locale`, not in the language of the stream request. That is different from the list, which follows `Accept-Language`.

If the UI language and the profile language disagree, a toast can appear in one language while the same item in the list is in the other.

Fix it by updating the profile whenever the user switches language:

```http
PUT /api/v1/admin/profile     (or /trainer/profile, /user/profile)
Authorization: Bearer <JWT>
Content-Type: application/json

{ "locale": "ar" }
```

- `locale` can be sent on its own. Every other profile field is optional on update.
- Send only `ar` or `en`.
- Do this in the same handler that changes `i18n.language`. Do not wait for the user to open a profile form.
- The profile response carries the stored value in `locale`. Use it to initialize the UI language after login so the two start aligned.

As a safety net, treat the stream event as a **trigger to refetch**, not as the source of truth for display. Every app already refetches the list on stream open (see `notifications-frontend-update.md` §6). Showing a toast from the event `title` is fine once §5 is in place.

---

## 6. Stream event shape (unchanged)

```json
{
  "id": "9b1d6c0e-4f7a-4d3e-9a51-2f0c9d1e7a10",
  "type": "book.purchase_confirmed",
  "entity": { "type": "book", "id": "book-public-uuid" },
  "title": "تم تأكيد شراء كتاب",
  "body": "أتمّ متعلم شراء كتاب رقمي.",
  "data": { "user_id": 44, "book_id": 7 },
  "readAt": null,
  "createdAt": "2026-09-28T09:25:00.000000Z"
}
```

`id` is the same UUID as the list item `id`. De-duplicate toasts against the list by `id`.

---

## 7. Newly translated strings

These previously had no Arabic text and always appeared in English. They are now fully translated:

| Event | English | Arabic |
|---|---|---|
| `book.purchase_confirmed`, `book_order.created` | Book purchase confirmed | تم تأكيد شراء كتاب |
| `book.purchase_confirmed`, `book_order.created` (body) | A learner completed a book purchase. / …a digital book purchase. | أتمّ متعلم شراء كتاب. / أتمّ متعلم شراء كتاب رقمي. |
| `program.assigned` | Program assigned | تم تعيين البرنامج |
| `program.assigned` (body) | A training program has been assigned to you. | تم تعيين برنامج تدريبي لك. |
| `course.revision_requested` | Course revision requested | مطلوب تعديل الكورس |
| `course.revision_requested` (body) | Your course content was returned for revision. | تمت إعادة محتوى الكورس إليك للتعديل. |
| `program.certificate_issued` (body) | Your training program certificate is now available. / A training program certificate has been issued. | شهادة برنامجك التدريبي متاحة الآن. / تم إصدار شهادة برنامج تدريبي. |
| payment failure (body) | We could not complete your payment. Please try again or use another payment method. | تعذّر إتمام عملية الدفع. يُرجى المحاولة مرة أخرى أو استخدام وسيلة دفع أخرى. |

Nothing changes in how you consume them. If a notification of any type still shows English under an Arabic request, it is either an old row (§1), an admin-typed message, or a genuine gap. Report the `additional_data.event` value so it can be added.

---

## 8. Test checklist

1. Log in as an admin with UI language `ar`. Trigger a book purchase from a learner account. The bell menu shows an Arabic title and body.
2. Switch the UI to `en` without reloading. The same item re-renders in English after the refetch. Switch back to `ar`, and it is Arabic again.
3. Open DevTools and confirm `Accept-Language` is exactly `ar` or `en` (no `en-US`) on `GET /notifications`.
4. With the UI on `en` and the stream connected, trigger another purchase. The toast is English. This confirms the profile `locale` was updated by the language switch (§5).
5. Send a notification from the dashboard's send-notification screen (`POST /admin/notifications/send`) with a custom Arabic title. It appears exactly as typed in both UI languages. This is expected (§1).
6. Old notifications from before the deploy stay in their original language. This is expected (§1).

---

## 9. Backend reference

For backend reviewers.

- Storage: `GeneralNotification` accepts `string|array` for the message. `BusinessNotificationService::sendToUsers()` now renders the message and title for every locale in `app.available_locales` (`['en', 'ar']` by default) and stores `{locale: text}` maps. The former per-recipient language grouping was removed.
- Reading: `Modules\Notification\Http\Resources\NotificationResource` resolves `title` and `body` against `app()->getLocale()`, then the fallback locale, then the first available value. A plain string (old rows, admin messages) is returned as is.
- Stream: `StreamNotificationPresenter` already resolves `{locale: text}` maps using the recipient's profile locale, so no stream change was needed.
- Translations: ten strings were added to `lang/ar.json` and `lang/en.json`. `BusinessNotificationServiceTest::test_every_notification_string_has_an_arabic_translation` scans the codebase and fails if a `sendTo`, `sendToUsers`, or `sendToAdmins` call uses a string missing from `lang/ar.json`.
- Adding a new notification: pass an English source string to `BusinessNotificationService`, add its Arabic text to `lang/ar.json`, and the test above enforces it.
