---
feature: Banner management
slug: banners
surface: admin
consumer: react
version: 1.0.1
status: ready
brd_version: "1.3"
brd_ids: [ADMIN-023]
business_rules: [BR-29, BR-30]
decisions: [DEC-17, TD-02, TD-03, TD-12, TD-14, TD-15, TD-27, TD-99]
schema_tables: [sliders, slider_translations, media]
related_docs: [content.md, ../client/home.md]
last_updated: 2026-11-11
---

# Banner management — React Dashboard Integration Guide

> **Base URL:** `{HOST}/api/v1/admin` · Shared rules (headers, envelope, errors,
> pagination, axios setup, TS types): [API_CONVENTIONS.md](../API_CONVENTIONS.md)

## Version history

Newest first. Never edit an old row.

| Version | Date | Type | Summary | Dashboard action required |
|---------|------|------|---------|---------------------------|
| 1.0.1 | 2026-11-11 | PATCH | Enum `label` values on the admin FAQ, banner (target app, placement, link type), static page (slug, audience) and onboarding-slide (app) resources are now translated to the request locale (Arabic by default) instead of the English title-cased case name; `value` is unchanged and English responses are identical (QA 2026-11-11). | None; render `label` as before. |
| 1.0.0 | 2026-10-25 | **Major** | ADMIN-023: banners are targeted (`target_app`, `placement`), can open a screen or record (`link_type` + `link`), carry display dates and a badge, and send text as `{ar, en}`. Image is required and is now actually attached. | **Breaking.** Replace the flat `title` / `subtitle` with `ar` / `en` objects, send `target_app` and `placement` on create, send an image hash on create, read enum fields as `{value, label}`. See the migration note. |
| 0.1.1 | 2026-10-22 | Patch | `GET /sliders` no longer returns 500 (`Attempted to lazy load [translations]`) when the page holds more than one banner: translations are eager-loaded. | None. |
| 0.1.0 | 2026-10-21 | Initial (base) | Documents the existing `/sliders` CRUD with real responses and gaps vs BR-30. | Usable for a simple list/edit screen; targeting comes in 1.0.0. |

## Changelog

### 1.0.1 — 2026-11-11

- **Fixed:** Enum `label` values on the admin FAQ, banner (target app, placement, link type), static page (slug, audience) and onboarding-slide (app) resources are now translated to the request locale (Arabic by default) instead of the English title-cased case name; `value` is unchanged and English responses are identical (QA 2026-11-11).
- **Breaking:** No. **App action required:** None; render `label` as before.

### 1.0.0 — 2026-10-25
- **Added:** request fields `ar` / `en` (`title`, `subtitle`, `badge_text`), `target_app`, `placement`, `link_type`, `starts_at`, `ends_at`; response fields `badge_text`, `target_app`, `placement`, `link_type`, `starts_at`, `ends_at`; `created_by` is stamped from the signed-in admin.
- **Changed:** `link` is validated by `link_type` (§5) and is now at most 255 characters (the column size). `sort_order` no longer accepts `null`. `image` is **required on create** (db-schema §20) and is attached through the media hash; before, the hash was accepted but never attached to the banner.
- **Fixed:** the image sent on `POST` / `PUT` was silently dropped. Title and subtitle limits now match the columns (200 / 300), so long text returns 422 instead of a 500 from the database.
- **Removed / Deprecated:** the flat `title` and `subtitle` request fields (no longer read).
- **Breaking:** Yes
- **Endpoints affected:** `GET|POST /sliders`, `GET|PUT /sliders/{id}`
- **Dashboard action required:** see the migration note.
- **BRD / decisions:** ADMIN-023, BR-30, TD-12, TD-99

**Migration note (0.1.x → 1.0.0)**

| 0.1.x | 1.0.0 |
|-------|-------|
| `{"title": "…", "subtitle": "…"}` in the request locale | `{"ar": {"title": "…", "subtitle": "…"}, "en": {…}}`; `ar.title` required on create |
| Every banner was a customer-app `home` banner | Send `target_app` and `placement` on create |
| `image` optional | `image` required on create |
| `link` free URL | `link` depends on `link_type` (§5); `link_type: "none"` takes no link |
| No enum fields in responses | `target_app`, `placement`, `link_type` are `{value, label}` |

### 0.1.1 — 2026-10-22
- **Fixed:** `GET /sliders` returned 500 `INTERNAL_ERROR` ("Attempted to lazy load [translations] on model [Slider]") because `SliderController` did not eager-load `translations`. It tripped only when the list held two or more banners.
- **Breaking:** No · **Endpoints affected:** `GET /sliders` · **Dashboard action required:** None.

### 0.1.0 — 2026-10-21
- **Added:** documentation for `GET|POST /sliders`, `GET|PUT|DELETE /sliders/{id}`. No code change.
- **Breaking:** No · **Dashboard action required:** none yet.

---

## 1. Business summary

Banners show offers, alerts and campaigns in the customer, provider, and driver
apps. BR-30: a banner is shown only while active, only in its target app and
placement, and only between its display dates. The admin picks the app and the
screen (placement) of that app, optionally what the banner opens when tapped,
and an optional display period. The customer app already filters its home
banners by all of these ([client/home.md](../client/home.md)); the provider and
driver apps have no banner endpoint yet, so their banners are stored and wait
for it (§13).

## 2. Pages and permissions

| Page / component | Purpose | Endpoint(s) | RBAC permission (route name) |
|------------------|---------|-------------|------------------------------|
| BannersListPage | Table of banners | `GET /sliders` | `admin.sliders.index` |
| BannerForm (edit) | Load one banner | `GET /sliders/{id}` | `admin.sliders.show` |
| BannerForm (create) | Add a banner | `POST /sliders` | `admin.sliders.store` |
| BannerForm (save) | Edit | `PUT /sliders/{id}` | `admin.sliders.update` |
| DeleteBannerDialog | Delete | `DELETE /sliders/{id}` | `admin.sliders.destroy` |

Section `banners_content` (TD-27). Sidebar entry: **Content → Banners**.

## 3. List / table

`GET /api/v1/admin/sliders` — list key `sliders`. Default order: newest first.

| Column | Field | Sortable (`sort[...]`) | Filter (`filters[...]`) | Notes |
|--------|-------|------------------------|-------------------------|-------|
| Image | `image` | no | — | Placeholder URL when none |
| Title | `title` | yes | `filters[title]` | Request locale |
| Subtitle | `subtitle` | yes | `filters[subtitle]` | |
| Badge | `badge_text` | yes | `filters[badge_text]` | |
| App | `target_app` | yes | `filters[target_app]=driver` | `{value, label}` |
| Placement | `placement` | yes | `filters[placement]=home` | `{value, label}` |
| Opens | `link_type` | yes | `filters[link_type]` | `{value, label}` |
| Link | `link` | yes | `filters[link]` | |
| Order | `sort_order` | yes | `filters[sort_order]` | |
| From / Until | `starts_at`, `ends_at` | yes | `filters[starts_at]`, `filters[ends_at]` | `null` = no limit |
| Active | `is_active` | yes | `filters[is_active]=1` | |
| Created | `created_at` | yes | `filters[created_at]` | `Y-m-d H:i:s` (TD-03) |

`search` matches the text and the fields above.

Postman: `admin.sliders.index` › "200 Banners filtered by target app".

```json
{
  "status": "success",
  "message": "Data retrieved successfully",
  "data": {
    "sliders": [
      {
        "id": "01a1735e-1caf-734f-bc9a-cf604c7d5342",
        "title": "Sample-vcbj52609136",
        "subtitle": null,
        "badge_text": null,
        "target_app": { "value": "driver", "label": "Driver" },
        "placement": { "value": "home", "label": "Home" },
        "link_type": { "value": "none", "label": "None" },
        "link": null,
        "image": "https://ui-avatars.com/api/?name=S&background=e0e7ff&color=4f46e5&size=400",
        "sort_order": 0,
        "starts_at": null,
        "ends_at": null,
        "is_active": true,
        "created_at": "2026-10-25 14:01:50"
      }
    ],
    "links": { "first": "…?page=1", "last": "…?page=1", "prev": null, "next": null },
    "meta": { "current_page": 1, "from": 1, "last_page": 1, "per_page": 15, "to": 1, "total": 1 }
  }
}
```

## 4. Details view

`GET /api/v1/admin/sliders/{id}` — same fields as a list row plus `updated_at`
and the stored text of **both** locales (`null` values for a locale never written).

Postman: `admin.sliders.show` › "200 Banner details with both locales".

```json
{
  "status": "success",
  "message": "Data retrieved successfully",
  "data": {
    "id": "01a1735e-1bfd-704f-9383-a5ebaef16d05",
    "title": "Sample-msts56583278",
    "subtitle": null,
    "badge_text": null,
    "target_app": { "value": "provider", "label": "Provider" },
    "placement": { "value": "account", "label": "Account" },
    "link_type": { "value": "none", "label": "None" },
    "link": null,
    "image": "https://ui-avatars.com/api/?name=S&background=e0e7ff&color=4f46e5&size=400",
    "sort_order": 0,
    "starts_at": "2026-11-01 00:00:00",
    "ends_at": "2026-11-30 23:59:59",
    "is_active": true,
    "created_at": "2026-10-25 14:01:49",
    "updated_at": "2026-10-25 14:01:49",
    "en": { "title": "Provider news", "subtitle": null, "badge_text": null },
    "ar": { "title": "Sample-msts56583278", "subtitle": null, "badge_text": null }
  }
}
```

| Field | Type | Nullable | Description |
|-------|------|----------|-------------|
| `id` | uuid | no | |
| `title` / `subtitle` / `badge_text` | string | title no | Request locale |
| `target_app` | `{value, label}` | no | §7 |
| `placement` | `{value, label}` | no | §7 |
| `link_type` | `{value, label}` | no | §7 |
| `link` | string | yes | Per `link_type` (§5) |
| `image` | string | no | Absolute URL; placeholder when no image |
| `sort_order` | int | no | |
| `starts_at` / `ends_at` | string | yes | `Y-m-d H:i:s`; `null` = no limit |
| `is_active` | boolean | no | |
| `en` / `ar` | `{title, subtitle, badge_text}` | no | All locales |

## 5. Forms (create / edit)

`POST /api/v1/admin/sliders` · `PUT /api/v1/admin/sliders/{id}`
(update accepts partial payloads)

| Field | Input type | Required on create | Validation | Notes |
|-------|-----------|--------------------|-----------|-------|
| `ar` | object | **yes** | array; only `title`, `subtitle`, `badge_text` | TD-12 |
| `ar.title` | text | **yes** | string, max 200 | |
| `ar.subtitle` | text | no | nullable string, max 300 | |
| `ar.badge_text` | text | no | nullable string, max 60 | e.g. "عرض الأسبوع" |
| `en` | object | no | nullable array; same keys | Send `en.title` with any other English field |
| `en.title` / `en.subtitle` / `en.badge_text` | text | no | same limits | |
| `target_app` | select | **yes** | `customer`, `provider`, `driver` | BR-30 |
| `placement` | select | **yes** | `home`, `services`, `packages`, `joshen_finder`, `account` | BR-30 |
| `link_type` | select | no | `none` (default), `category`, `service`, `package`, `provider`, `screen`, `url` | |
| `link` | by type | by type | see below | Max 255 |
| `sort_order` | number | no | integer; default 0 | Lowest first |
| `starts_at` | datetime | no | nullable date; before `ends_at` | `null` = starts now |
| `ends_at` | datetime | no | nullable date; after `starts_at` | `null` = never ends |
| `is_active` | switch | no | boolean; default true | |
| `image` | file → hash | **yes** | `hash` from `POST /api/v1/media`; must exist and be an image | jpg/jpeg/png/webp, ≤ 5120 KB (TD-14); sending a new hash on update replaces the image |

`link` per `link_type` (TD-99):

| `link_type` | `link` |
|-------------|--------|
| `none` | must be empty (sending a value is 422; switching an existing banner to `none` clears it) |
| `category`, `service`, `package`, `provider` | the record's **uuid**; it must exist |
| `screen` | an app route name, free text |
| `url` | a full URL |

Create request:

```json
{
  "ar": { "title": "خصم الافتتاح", "subtitle": "٢٠٪ على أول حجز" },
  "en": { "title": "Opening offer", "subtitle": "20% off your first booking" },
  "target_app": "customer",
  "placement": "home",
  "link_type": "url",
  "link": "https://example.com",
  "sort_order": 1,
  "starts_at": null,
  "ends_at": null,
  "is_active": true,
  "image": "<hash from POST /api/v1/media>"
}
```

Create — 201 (Postman: `admin.sliders.store` › "201 Banner created with both locales"):

```json
{
  "status": "success",
  "message": "Data saved successfully",
  "data": {
    "id": "01a1735e-1a5b-7061-843f-49cead401604",
    "title": "خصم الافتتاح",
    "subtitle": "٢٠٪ على أول حجز",
    "badge_text": null,
    "target_app": { "value": "customer", "label": "Customer" },
    "placement": { "value": "home", "label": "Home" },
    "link_type": { "value": "url", "label": "Url" },
    "link": "https://example.com",
    "image": "http://localhost/storage/sliders/banner-image-hash.png",
    "sort_order": 1,
    "starts_at": null,
    "ends_at": null,
    "is_active": true,
    "created_at": "2026-10-25 14:01:49",
    "updated_at": "2026-10-25 14:01:49"
  }
}
```

Update `{"target_app":"provider","placement":"account","starts_at":"2026-11-01 00:00:00","ends_at":"2026-11-30 23:59:59","en":{"title":"Provider news"}}` — 200
(Postman: `admin.sliders.update` › "200 Banner retargeted to the provider app with dates"):

```json
{
  "status": "success",
  "message": "Data saved successfully",
  "data": {
    "id": "01a1735e-ae47-7337-a224-cba0f4f9d9af",
    "title": "Sample-vtkw24913057",
    "subtitle": null,
    "badge_text": null,
    "target_app": { "value": "provider", "label": "Provider" },
    "placement": { "value": "account", "label": "Account" },
    "link_type": { "value": "none", "label": "None" },
    "link": null,
    "image": "https://ui-avatars.com/api/?name=S&background=e0e7ff&color=4f46e5&size=400",
    "sort_order": 0,
    "starts_at": "2026-11-01 00:00:00",
    "ends_at": "2026-11-30 23:59:59",
    "is_active": true,
    "created_at": "2026-10-25 14:02:27",
    "updated_at": "2026-10-25 14:02:27"
  }
}
```

## 6. Actions (non-CRUD)

### 6.1 Delete

`DELETE /api/v1/admin/sliders/{id}` · Permission: `admin.sliders.destroy`
(Postman: `admin.sliders.destroy` › "200 Banner deleted")

```json
{
  "status": "success",
  "message": "Data removed successfully",
  "data": null
}
```

### Errors (all endpoints in this doc)

| HTTP | `error_code` | When | Dashboard behavior |
|------|--------------|------|--------------------|
| 401 | `UNAUTHENTICATED` | Missing or expired token | Refresh, then retry |
| 403 | `PERMISSION_DENIED` | Missing route permission | Hide/disable the action |
| 404 | — | Unknown or integer id | Toast, reload |
| 422 | — | Validation failed: combined `message` plus `data.errors` per field (TD-02) | Show each field error |

Validation examples (Postman: `admin.sliders.store` › "422 Image hash does not exist", "422 Legacy flat title without ar", "422 Target app and placement missing", "422 Link points at a category that does not exist"; `admin.sliders.update` › "422 New end date is before the stored start date"):

```json
{
  "status": "fail",
  "message": "The Target App field is required. (and 1 more error)",
  "data": {
    "errors": {
      "target_app": ["The Target App field is required."],
      "placement": ["The Placement field is required."]
    }
  }
}
```

An image hash that was never uploaded fails on `image` ("The media file was not found."),
and the old flat `{ "title": … }` payload fails on `ar` and `ar.title`.

## 7. Statuses and enums

| Enum | `value` | Meaning |
|------|---------|---------|
| `target_app` | `customer`, `provider`, `driver` | App that shows the banner |
| `placement` | `home`, `services`, `packages`, `joshen_finder`, `account` | Screen of that app |
| `link_type` | `none`, `category`, `service`, `package`, `provider`, `screen`, `url` | What a tap opens |

`label` is the readable form of the key (`Joshen Finder`), not translated.

## Data model

| Table (`db-schema.sql`) | Read / write | Notes |
|-------------------------|--------------|-------|
| `sliders` | read + write | `target_app`, `placement`, `link_type`, `link`, `sort_order`, `starts_at`, `ends_at`, `is_active`, `created_by` |
| `slider_translations` | read + write | `title` (varchar 200), `subtitle` (300), `badge_text` (60) |
| `media` | read + write | Collection `sliders.image` (TD-14) |

## 8. TypeScript types

```ts
export interface EnumValue<T extends string = string> { value: T; label: string }
export interface Banner {
  id: string; // uuid
  title: string;
  subtitle: string | null;
  badge_text: string | null;
  target_app: EnumValue<'customer' | 'provider' | 'driver'>;
  placement: EnumValue<'home' | 'services' | 'packages' | 'joshen_finder' | 'account'>;
  link_type: EnumValue<'none' | 'category' | 'service' | 'package' | 'provider' | 'screen' | 'url'>;
  link: string | null;
  image: string; // absolute URL
  sort_order: number;
  starts_at: string | null; // "Y-m-d H:i:s" (TD-03)
  ends_at: string | null;
  is_active: boolean;
  created_at: string;
}
type BannerText = { title: string | null; subtitle: string | null; badge_text: string | null };
export interface BannerDetails extends Banner {
  updated_at: string;
  en: BannerText;
  ar: BannerText;
}
export type BannerList = Paginated<'sliders', Banner>;
export interface BannerBody {
  ar: { title: string; subtitle?: string | null; badge_text?: string | null };
  en?: { title: string; subtitle?: string | null; badge_text?: string | null };
  target_app: 'customer' | 'provider' | 'driver';
  placement: 'home' | 'services' | 'packages' | 'joshen_finder' | 'account';
  link_type?: Banner['link_type']['value'];
  link?: string | null;
  sort_order?: number;
  starts_at?: string | null;
  ends_at?: string | null;
  is_active?: boolean;
  image: string; // media hash; required on create
}
// PUT accepts Partial<BannerBody>
```

## 9. Business rules enforced by the backend

| Rule | What the backend does | What the admin sees |
|------|-----------------------|---------------------|
| BR-29 | `rbac.auto` on every route | 403 `PERMISSION_DENIED` |
| BR-30 | Stores target app, placement, active flag and dates. The customer home returns only active banners for `customer` + `home` inside their dates | A banner outside its dates or switched off is not shown; no error |

## 10. Notifications triggered

N/A.

## 11. Integration checklist

- [ ] Image uploaded first through `POST /api/v1/media`; the form sends the `hash`
- [ ] Form sends `ar` (required) and optional `en`; never the flat `title`
- [ ] `target_app` and `placement` selects on the create form
- [ ] `link` input changes with `link_type` (empty for `none`, uuid picker, route text, URL)
- [ ] `ends_at` later than `starts_at`
- [ ] Title ≤ 200, subtitle ≤ 300, badge ≤ 60 characters in the form
- [ ] Enum fields read as `{value, label}`
- [ ] Menu items/buttons gated by the listed permissions
- [ ] RTL (`ar`) and `en` verified

## 12. Acceptance scenarios

| AC ID | Scenario | Expected |
|-------|----------|----------|
| AC-ADMIN-023 | Create an enabled banner for one app and display period; show it only to the intended app during its schedule, then hide it when disabled | Covered by `SliderManagementTest` (store, retarget, dates, link rules, image) and, for the customer home filter, `HomeControllerTest`. Provider and driver apps have no banner endpoint yet (§13) |

## 13. Open decisions and known limitations

| ID | Impact on this feature | Current behavior |
|----|------------------------|------------------|
| TD-99 | `link` format per `link_type`, image required, defaults | Working assumption; the BRD does not define link targets |
| — | Provider and driver apps | Their banners are stored; the apps get an endpoint with PROV / DRIV home work |
| — | Banner display time zone | `starts_at` / `ends_at` are compared with the server clock; send values in the server's zone |
| TD-03 | Dates `Y-m-d H:i:s` without offset | Parse as local until TD-03 |
| TD-02 | 422 has one combined `message` and `data.errors` per field | Show each field error |
