# Our Services (خدماتنا) — Frontend Guide

Everything the website and the admin dashboard need to build the **"Our Services"** landing-page section: the four image cards under the *"استكشف خدماتنا الرئيسية"* heading (Educational Programs, Training Programs, Library, Consultations).

- **Audience:** frontend developers (public website / mobile, admin dashboard)
- **Base path:** `{{base_url}}` = `/api/v1`
- **Related docs:** [media-upload-guide.md](./media-upload-guide.md) (how to upload the card image), [admin.md](./admin.md#our-services) and [user.md](./user.md) (auto-generated endpoint reference)

Common headers on every call: `Accept: application/json`, `Content-Type: application/json`, `Accept-Language: en|ar`. Responses use the standard envelope `{ "status", "message", "data" }`.

---

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

The section in the design has two parts:

| Part | Source | Notes |
|---|---|---|
| **Badge** ("خدماتنا"), **heading** ("استكشف خدماتنا الرئيسية") and **sub-heading** ("اختر من بين خدماتنا التعليمية المتنوعة، وابدأ رحلة تطوير مهاراتك بثقة مع نخبة من الخبراء.") | **Static — your i18n files** | Not stored in the backend and no endpoint returns them. |
| **The cards** (image + label + link) | **Backend — this guide** | Managed by admins, served to the public website. |

Suggested static copy:

| Key | `ar` | `en` |
|---|---|---|
| `services.badge` | خدماتنا | Our Services |
| `services.title` | استكشف خدماتنا الرئيسية | Explore Our Main Services |
| `services.subtitle` | اختر من بين خدماتنا التعليمية المتنوعة، وابدأ رحلة تطوير مهاراتك بثقة مع نخبة من الخبراء. | Choose from our diverse educational services and start your skill-building journey with confidence, guided by a select group of experts. |

### The card data model

One card = one record:

| Field | Type | Translatable | Meaning |
|---|---|---|---|
| `id` | UUID string | no | Public identifier. Use it in URLs and requests (never a numeric id). |
| `title` | string ≤ 255 | **yes** (`en`, `ar`) | The label on the card's white tab, e.g. "المكتبة" |
| `description` | string ≤ 1000, nullable | **yes** (`en`, `ar`) | Optional short text about the service (not shown in the current design — use it for a tooltip, a hover overlay or a future card variant). `null` when not set. |
| `link` | string ≤ 500, nullable | no | Where the card navigates to. A frontend route (`/library`) or an absolute URL — the backend does not validate the format. |
| `image` | URL | no | The card photo. Falls back to a generic placeholder image URL when none is uploaded. |
| `sort_order` | integer | no | Position in the row, ascending (1 = first). |
| `is_active` | boolean | no | Inactive cards are hidden from the public website. |

### Seeded default cards

`php artisan db:seed --class=OurServiceSeeder` creates the four cards in the screenshot's order. They have **no image yet** (placeholder until an admin uploads one) and the links below are placeholders — adjust them in the dashboard.

| `sort_order` | `en` | `ar` | `link` |
|---|---|---|---|
| 1 | Educational Programs | البرامج التعليمية | `/courses` |
| 2 | Training Programs | البرامج التدريبية | `/programs` |
| 3 | Library | المكتبة | `/library` |
| 4 | Consultations | الاستشارات | `/consultations` |

---

## 2. Endpoint map

| Audience | Method & path | Purpose | Auth |
|---|---|---|---|
| Public website | `GET /user/our-services` | List active cards for the landing page | none |
| Admin | `GET /admin/our-services` | Paginated list (all cards, active or not) | admin token |
| Admin | `POST /admin/our-services` | Create a card | admin token |
| Admin | `GET /admin/our-services/{our_service}` | Card details (both languages) for the edit form | admin token |
| Admin | `PUT /admin/our-services/{our_service}` | Update a card (partial) | admin token |
| Admin | `DELETE /admin/our-services/{our_service}` | Delete a card | admin token |
| Admin | `DELETE /admin/our-services/bulk-delete` | Delete several cards | admin token |

`{our_service}` is the card's **UUID**.

Admin routes require an authenticated, active admin/supervisor. Supervisors additionally need the matching permissions (`admin.our-services.index|show|store|update|destroy|bulk-delete`, resource `our-services`). Super admins pass automatically. If a supervisor gets **403**, grant those permissions to their role.

---

## 3. Public website API

### 3.1 List cards — `GET /user/our-services`

No authentication, **no pagination**, no query parameters. Returns only `is_active = true` cards, ordered by `sort_order` ascending. The `title` is already in the language of the `Accept-Language` header (`ar` or `en`).

```http
GET {{base_url}}/user/our-services
Accept: application/json
Accept-Language: ar
```

```json
{
  "status": "success",
  "message": "Our services retrieved successfully",
  "data": [
    {
      "id": "a2da395e-95b1-491a-a942-57c8dfee849c",
      "title": "البرامج التعليمية",
      "description": "برامج تعليمية متنوعة يقدمها نخبة من الخبراء",
      "link": "/courses",
      "image": "https://…/storage/uploads/…webp"
    },
    {
      "id": "b7c1…",
      "title": "البرامج التدريبية",
      "description": null,
      "link": "/programs",
      "image": "https://…/storage/uploads/…webp"
    }
  ]
}
```

Notes:

- `data` is a plain array (not `{ items, meta }`). An empty section returns `data: []` — hide the whole section when it is empty.
- Render the cards **in array order**; do not re-sort on the client.
- Show **all** returned cards. The design has 4 but the admin can add more; use a responsive grid/carousel rather than a hard-coded 4 columns.
- `description` is optional and in the same language as `title`; it is `null` when the admin left it empty — always null-check before rendering.
- `link` can be `null` → render the card as non-clickable.
- `image` is never `null`. Without an uploaded image it is a generic placeholder URL, so a card with no real image still renders.
- Results are cached server-side per language, and the cache refreshes automatically as soon as an admin creates/updates/deletes a card. You do not need to cache-bust; a normal HTTP fetch is enough.

### 3.2 TypeScript & example

```ts
export type OurService = {
  id: string;
  title: string;
  description: string | null;
  link: string | null;
  image: string;
};

export async function fetchOurServices(locale: 'ar' | 'en'): Promise<OurService[]> {
  const res = await fetch(`${BASE_URL}/user/our-services`, {
    headers: { Accept: 'application/json', 'Accept-Language': locale },
  });
  if (!res.ok) throw new Error(`our-services ${res.status}`);
  return (await res.json()).data;
}
```

### 3.3 UI notes (from the design)

- RTL row of image cards, equal size, rounded corners (~24 px), landscape aspect ratio ≈ **3 : 2** (the mock cards are 322 × 210).
- The **label sits in a white tab** notched into the card's bottom-right (bottom-left in LTR) corner — the tab shows `title`.
- Use `object-fit: cover` on the image. Ask admins to upload landscape images (see §4.5).
- The whole card is the click target → navigate to `link`.
- Section header is static (see §1), centered above the cards.

---

## 4. Admin dashboard API

All admin calls need `Authorization: Bearer {{admin_token}}`.

### 4.1 List — `GET /admin/our-services`

Query parameters (all optional):

| Param | Example | Notes |
|---|---|---|
| `page`, `per_page` | `page=1&per_page=15` | Paginated by default; `per_page` max 100 |
| `paginate=false` | | Return every card as a plain array (fine here — there are only a handful) |
| `search` | `search=lib` | Matches the title |
| `sort[field]=asc\|desc` | `sort[sort_order]=asc` | **Recommended default for this screen:** `sort[sort_order]=asc` so the table matches the public order |
| `filters[is_active]` | `filters[is_active]=1` | Filter by active state |

Paginated response:

```json
{
  "status": "success",
  "message": "Data retrieved successfully",
  "data": {
    "our_services": [
      {
        "id": "a2da395e-95b1-491a-a942-57c8dfee849c",
        "title": "Library",
        "description": "Books and research",
        "link": "/library",
        "image": "https://…",
        "sort_order": 3,
        "is_active": true,
        "created_at": "2026-09-28 07:44:50"
      }
    ],
    "links": { "first": "…?page=1", "last": "…?page=1", "prev": null, "next": null },
    "meta": { "current_page": 1, "from": 1, "last_page": 1, "path": "…", "per_page": 15, "to": 1, "total": 1 }
  }
}
```

With `?paginate=false` the `data` is the bare array of the same card objects.

`title` in list/show responses follows the request's `Accept-Language`.

### 4.2 Show — `GET /admin/our-services/{our_service}`

Same fields plus `updated_at` and **both language blocks** for the edit form:

```json
{
  "status": "success",
  "message": "Data retrieved successfully",
  "data": {
    "id": "a2da395e-95b1-491a-a942-57c8dfee849c",
    "title": "Library",
    "description": "Books and research",
    "link": "/library",
    "image": "https://…",
    "sort_order": 3,
    "is_active": true,
    "created_at": "2026-09-28 07:44:50",
    "updated_at": "2026-09-28 07:44:50",
    "en": { "title": "Library", "description": "Books and research" },
    "ar": { "title": "المكتبة", "description": null }
  }
}
```

Bind the form's inputs to `data.en.title` / `data.ar.title` and `data.en.description` / `data.ar.description` (not to the top-level `title` / `description`, which are just the current-locale values).

### 4.3 Create — `POST /admin/our-services`

```json
{
  "en": { "title": "Library", "description": "Books and research" },
  "ar": { "title": "المكتبة", "description": "الكتب والأبحاث" },
  "link": "/library",
  "sort_order": 3,
  "is_active": true,
  "image": "<media-hash>"
}
```

Returns **201**:

```json
{
  "status": "success",
  "message": "Data saved successfully",
  "data": {
    "id": "a2da395e-95b1-491a-a942-57c8dfee849c",
    "title": "Library",
    "description": "Books and research",
    "link": "/library",
    "image": "https://…",
    "sort_order": 3,
    "is_active": true,
    "created_at": "2026-09-28 07:44:50",
    "updated_at": "2026-09-28 07:44:50"
  }
}
```

> The create/update response does **not** include the `en`/`ar` blocks. After saving, navigate back to the list or re-fetch with `GET …/{id}` if the form stays open.

### 4.4 Update — `PUT /admin/our-services/{our_service}`

**Partial update** — send only what changed; omitted fields are kept.

```json
{ "is_active": false }
```

```json
{ "ar": { "title": "المكتبة الرقمية", "description": "مكتبة رقمية شاملة" }, "sort_order": 4 }
```

Sending one language block updates only that language; the other language's title and description are preserved. To clear a description, send it as `null` (or `""`). Returns **200** with the same shape as create.

### 4.5 Uploading the card image

Images are never sent as files to this endpoint. It is a two-step flow (full details in [media-upload-guide.md](./media-upload-guide.md)):

1. Upload the file: `POST {{media_base_url}}/media` (**host root, no `/api/v1`**), `multipart/form-data` with `file` = the image and `collection` = `image`. Send `Accept: application/json` and the bearer token. Don't set `Content-Type` by hand.
2. Take the `hash` from the response and send it as `image` in the create/update body.

Image constraints for this entity: `jpg`, `jpeg`, `png`, `webp`; up to **5 MB**; up to **1200 × 1200 px**. The server converts to WebP and generates variants. Recommend a landscape image around 3 : 2 (e.g. 900 × 600) because the card is landscape and cropped with `object-fit: cover`.

Uploaded-but-unattached files are deleted after **6 hours** — upload when the admin submits, or re-upload if the form sat open for a long time (error: `The media file was not found.`). A hash can be attached to only one record. Omit `image` on update to keep the current image.

### 4.6 Delete — `DELETE /admin/our-services/{our_service}`

Hard-deletes the card and its translations. Returns **200**. Ask for confirmation in the UI.

### 4.7 Bulk delete — `DELETE /admin/our-services/bulk-delete`

```json
{ "ids": ["a2da395e-…", "b7c1…"] }
```

```json
{ "status": "success", "message": "Deleted successfully", "data": { "count": 2 } }
```

All-or-nothing: if any id does not exist, nothing is deleted and the call fails (**422** validation for an unknown id).

### 4.8 Validation rules (422)

| Field | Create | Update | Rule |
|---|---|---|---|
| `en.title` | **required** | optional | string, ≤ 255 |
| `ar.title` | **required** | optional | string, ≤ 255 |
| `en.description`, `ar.description` | optional | optional | string, ≤ 1000, may be `null` |
| `link` | optional | optional | string ≤ 500, may be `null` |
| `sort_order` | optional | optional | integer. **Omit it rather than sending `null`.** |
| `is_active` | optional (default `true`) | optional | boolean |
| `image` | optional | optional | media hash string (must exist and not be attached elsewhere), may be `null` |

Validation errors carry the **first** error only, with no per-field map:

```json
{ "status": "fail", "message": "The Title (ar) field is required.", "data": null }
```

Show `message` in a toast/alert and highlight the fields you can infer. The message is localized by `Accept-Language`.

### 4.9 Suggested admin screen

- **Table:** image thumbnail, title, link, sort order, active toggle, actions (edit / delete). Load with `?sort[sort_order]=asc&paginate=false`.
- **Active toggle:** `PUT` with `{ "is_active": <bool> }`.
- **Reordering:** persist by sending `sort_order` per card (`PUT` each changed card, 1-based, unique-looking values). There is no bulk-reorder endpoint.
- **Form:** two title inputs (Arabic / English, both required), two optional description textareas (Arabic / English, max 1000 chars), link, sort order, active switch, image uploader (preview, landscape hint).
- After **any** save/delete the public website is up to date immediately.

---

## 5. Error reference

| HTTP | When |
|---|---|
| 401 | Missing / expired admin token |
| 403 | Authenticated but the role lacks the `our-services` permission |
| 404 | Unknown card UUID (`Resource not found`) |
| 422 | Validation failure (§4.8) or unknown media hash / unknown id in bulk delete |
| 429 | Throttled (media endpoints — see the media guide) |

---

## 6. QA checklist

**Public website**
- [ ] Section shows only active cards, in ascending `sort_order`.
- [ ] Switching the language re-fetches and shows Arabic / English titles.
- [ ] Card with `link: null` is not clickable; card with a link navigates correctly.
- [ ] Card without an uploaded image still renders (placeholder).
- [ ] Empty `data: []` hides the whole section.
- [ ] Deactivating a card in the dashboard removes it from the site on next load.

**Admin**
- [ ] Create requires both Arabic and English titles (descriptions optional); the error message appears.
- [ ] Edit form is prefilled from `data.en.title` / `data.ar.title`.
- [ ] Updating only `ar.title` leaves `en.title` and both descriptions unchanged.
- [ ] Description saved in one language shows in that language on the public list; empty description is `null`.
- [ ] Upload → attach hash → card shows the new image; an expired hash shows the media error.
- [ ] Toggling `is_active` and changing `sort_order` are reflected on the public list.
- [ ] Delete and bulk delete remove the card(s).
- [ ] A supervisor without permissions gets 403 and the UI handles it.

---

## 7. Backend reference

| Concern | Location |
|---|---|
| Model (translations, media field, `active` scope) | `app/Models/OurService.php`, `OurServiceTranslation.php` |
| Migration | `database/migrations/2026_09_28_000001_create_our_services_table.php` |
| Admin controller / service | `app/Http/Controllers/Api/V1/Admin/OurServiceController.php`, `app/Services/Admin/OurServiceService.php` |
| Validation | `app/Http/Requests/Admin/OurService/OurServiceRequest.php` |
| Admin resources | `app/Http/Resources/Admin/OurService/` |
| Public endpoint | `ContentController::ourServices()`, `ContentService::ourServices()`, `app/Http/Resources/Client/OurServiceResource.php` |
| Routes | `routes/api/v1/admin.php` (`admin.our-services.*`), `routes/api/v1/user.php` (`user.our-services.index`) |
| Permissions | `database/seeders/PermissionSeeder.php` (resource `our-services`) |
| Seeder | `database/seeders/OurServiceSeeder.php` |
| Tests | `tests/Feature/OurServiceTest.php` |
