---
feature: Service listing, service details, package listing, package details and search
slug: catalog
surface: client
consumer: flutter
version: 1.2.0
status: ready
brd_version: "1.3"
brd_ids: [CUST-006, CUST-007, CUST-008, CUST-001]
business_rules: [BR-02, BR-05, BR-06, BR-08, BR-09]
decisions: [D1, DEC-23, TD-02, TD-87, TD-19, TD-36, TD-42, TD-64, TD-69, TD-71, TD-78, TD-79, TD-80]
schema_tables: [categories, category_translations, services, service_translations, provider_services, provider_categories, providers, provider_districts, packages, package_items, customer_addresses, v_provider_featured]
screens: [customer--catalog--*, customer--search--*]
related_docs: [home.md, addresses.md, ../provider/services-pricing.md, ../provider/packages.md, ../admin/packages.md, ../admin/services.md]
last_updated: 2026-10-27
---

# Service listing, details and search — Flutter Integration Guide (client)

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

## Version history

Newest first. Never edit an old row.

| Version | Date | Type | Summary | App action required |
|---------|------|------|---------|---------------------|
| 1.2.0 | 2026-10-27 | MINOR | Package details can now offer purchase to an eligible signed-in customer. | Use `can_buy` to show Buy and follow [my packages](my-packages.md). |
| 1.1.1 | 2026-10-26 | PATCH | Documentation only: validation 422 examples and rows now show `data.errors` (`{field: [messages]}`, TD-02, API_CONVENTIONS 2.7.0) instead of `data: null`; examples are the captured Postman responses. | None. |
| 1.1.0 | 2026-10-25 | MINOR | `GET /packages/{package}`: package details (provider, included services with sessions and durations, occasions, price, savings, validity) at the customer's location. Packages stop selling when an included service is no longer enabled (BR-07). `can_buy` is `false` until checkout. 422 responses carry `data.errors`. | Build the package details screen from the card tap; show "Buy" only when `can_buy` is `true`. |
| 1.0.0 | 2026-10-22 | Initial | Categories, category page with "from" prices, service details with option groups, search over services / packages / providers. Package listing only; package details and purchase come later. Base search routes retired. | Integrate as described below; stop calling `/client/search/{suggestions,trending,popular,recent}`. |

## Changelog

### 1.2.0 — 2026-10-27

- **Added / changed:** Package details can now offer purchase to an eligible signed-in customer.
- **Fixed / removed:** None.
- **Breaking:** No.
- **Endpoints affected:** Existing order or package reads and actions described below.
- **App action required:** Use `can_buy` to show Buy and follow [my packages](my-packages.md).
- **BRD / decisions:** D1, TD-123–128.


### 1.1.1 — 2026-10-26

- **Added:** N/A.
- **Changed:** Validation 422 examples and error-table rows show `data.errors`, not `data: null` (TD-02).
- **Fixed:** Documentation only; the server already returned `data.errors`.
- **Removed / Deprecated:** N/A.
- **Breaking:** No.
- **Endpoints affected:** None (documentation).
- **App action required:** None; map `data.errors` onto form fields, fall back to `message`.
- **BRD / decisions:** TD-02.

### 1.1.0 — 2026-10-25
- **Added:** `GET /packages/{package}` (public, optional customer token, same location keys as the other discovery endpoints); packages visible in search and home also require every included service to be active and enabled by the provider (BR-07).
- **Changed:** `data.errors` on every 422 (TD-02; was `data: null`).
- **Fixed / Removed / Deprecated:** —
- **Breaking:** No.
- **Endpoints affected:** `GET /packages/{package}` (new); `GET /search?type=packages` and `GET /home` `featured_packages` may now return fewer packages when a provider disabled an included service.
- **App action required:** open this screen from a package card (`id`); keep the buy button hidden while `can_buy` is `false`.
- **BRD / decisions:** CUST-008 (details), D1, BR-07, BR-08, TD-87.

### 1.0.0 — 2026-10-22
- **Added:** `GET /categories`, `GET /categories/{category}`, `GET /services/{service}`, `GET /search` (public; optional customer token).
- **Changed:** —
- **Fixed:** —
- **Removed / Deprecated:** `GET /search/suggestions`, `/search/trending`, `/search/popular`, `/search/recent` are unregistered (404, TD-80).
- **Breaking:** No (the base routes were never part of a released feature doc).
- **Endpoints affected:** the four above; the four retired search routes.
- **App action required:** build the categories, category, service details and search screens from these endpoints.
- **BRD / decisions:** CUST-006, CUST-007, CUST-008 (listing), SCR-10, BR-05/06/08/09, TD-42/78/79/80.

---

## 1. Business summary

Customers browse main services (categories), open one to see its bookable
services, and open a service to see its options (CUST-006/007). Guests can do
all of this (CUST-001). Each service card shows a **"from" price**: the lowest
price among providers who can serve the customer's location right now. It is
not the order price — the final price is set after she picks a provider
(CUST-011). `from_price: null` means nobody can serve that service there.

Search returns only offerings someone can actually deliver at the location:
services, provider packages (CUST-008, D1), or providers. A provider counts
when she is approved, accepting bookings, covers the location and has the
service fully priced (BR-06/08, TD-78). Featured providers rank first, but only
after that filter (BR-09).

The location rules are the same as the home page ([home.md](home.md) §3.2):
`address_id` → `city_id`/`district_id` → the signed-in customer's default address.

## 2. User flow and screens

| Step | Screen / widget | Endpoint | Notes (states, empty, loading, errors) |
|------|-----------------|----------|-----------------------------------------|
| 1 | Categories list (`customer--catalog--01`) | `GET /categories` | Card: icon, name, description, "N services" (`services_count`). |
| 2 | Category page (`customer--catalog--02`) | `GET /categories/{category}` + location | Header = category; cards = `services[]` with "from X SAR · N min". `from_price: null` → show "not available at this address". Footer note: prices depend on the address; final price after choosing a provider. |
| 3 | Service details | `GET /services/{service}` + location | Option groups: `is_required` groups must have a choice before "Continue" (AC-CUST-007); `selection` single → radio, multiple → checkboxes. Choice add-on "from X" or "free". "Continue" → login if guest (BR-02), then provider selection (CUST-010). |
| 4 | Search (`customer--search--01…04`) | `GET /search` | Chips = categories (`category_id`). Tabs = `type`. "N results" = `meta.total`. Page with `links.next`. |
| 5 | Package details (`customer--packages--02-details-p1/p2`) | `GET /packages/{package}` + location | Provider, included services ("N sessions · M min"), occasions, price vs `compare_at_price` (`savings_amount`, `discount_percent`), validity. Buy button only when `can_buy`. |

```
Categories ──► Category page ──► Service details ──► [login] ──► Providers (CUST-010)
Search ──► service card ──► Service details
       └─► package card ──► Package details (§3.5) ──► Buy → [purchase and sessions](my-packages.md)
       └─► provider card ──► (provider profile: CUST-010)
```

## 3. Endpoints

Every endpoint is public (guest allowed). A token, when sent, must be a valid
**customer** token: invalid/expired → `401 UNAUTHENTICATED`, another app's token
→ `403 PERMISSION_DENIED` (same as [home.md](home.md) §3.2). Responses for a
signed-in customer are `Cache-Control: no-store, private`.

**Location query parameters** (on `categories/{category}`, `services/{service}`, `packages/{package}` and `search`)

| Name | In | Type | Required | Rules | Description |
|------|----|------|----------|-------|-------------|
| `address_id` | query | uuid (string) | no | string, max 64; must be one of her live addresses | Signed-in only; else her default address is used |
| `city_id` | query | uuid (string) | with `district_id` | uuid, exists `governorates` | City to browse; must be enabled |
| `district_id` | query | uuid (string) | no | uuid, exists `regions`; needs `city_id` | District of that city; must belong to it and be enabled |

**Location errors** (all three endpoints)

| HTTP | `error_code` | When | What the app should do |
|------|--------------|------|------------------------|
| 404 | `ENTITY_NOT_FOUND` | `address_id` is not hers, or a guest sent one | Reload addresses |
| 422 | `SERVICE_AREA_UNAVAILABLE` | Chosen address / city / district is disabled | Ask for another area |
| 422 | `BUSINESS_RULE_VIOLATED` | District not in the city | Reset the district |
| 422 | — (`data.errors`) | Malformed uuid, `district_id` without `city_id` | Show `message`; `data.errors` names the key |

### 3.1 Categories

`GET /api/v1/client/categories`

| | |
|---|---|
| Auth | Public · optional customer token |
| Query | none (the list is the same everywhere) |

**Success — `200`**

```json
{
  "status": "success",
  "message": "Categories retrieved.",
  "data": [
    {
      "id": "01a16342-48b4-73dd-92af-6f4ae4b07df0",
      "name": "Makeup",
      "description": "Occasion makeup",
      "icon_key": "lipstick",
      "image": "https://ui-avatars.com/api/?name=C&background=fef3c7&color=d97706&size=200",
      "services_count": 2
    }
  ]
}
```

| Field | Type | Nullable | Description |
|-------|------|----------|-------------|
| `id` | uuid | no | Category id (use in `/categories/{category}` and `search?category_id=`) |
| `name`, `description` | string | yes | Localized, Arabic fallback |
| `icon_key` | string | yes | App icon key (e.g. `lipstick`) |
| `image` | URL | no | Placeholder when none |
| `services_count` | int | no | Active bookable services in the category |

Only active categories, in admin order; unpaginated.

### 3.2 Category page

`GET /api/v1/client/categories/{category}`

| Name | In | Type | Required | Rules | Description |
|------|----|------|----------|-------|-------------|
| `category` | path | uuid (string) | yes | active category | Unknown, inactive or integer id → 404 |
| + location keys | query | | no | | See above |

**Success — `200`** (district chosen; second service has no eligible provider)

```json
{
  "status": "success",
  "message": "Category retrieved.",
  "data": {
    "id": "01a16342-48b4-73dd-92af-6f4ae4b07df0",
    "name": "Makeup",
    "description": "Occasion makeup",
    "icon_key": "lipstick",
    "image": "https://ui-avatars.com/api/?name=C&background=fef3c7&color=d97706&size=200",
    "services_count": 2,
    "services": [
      {
        "id": "01a16342-48b7-714c-aada-d24f0c391de0",
        "name": "Evening makeup",
        "description": "Full evening look",
        "image": "https://ui-avatars.com/api/?name=N&background=f3f4f6&color=9ca3af&size=200",
        "category": {
          "id": "01a16342-48b4-73dd-92af-6f4ae4b07df0",
          "name": "Makeup"
        },
        "duration_min": 120,
        "from_price": "350.00"
      },
      {
        "id": "01a16342-48ba-72ef-bf8a-6d1c426a9dea",
        "name": "Soft makeup",
        "description": null,
        "image": "https://ui-avatars.com/api/?name=N&background=f3f4f6&color=9ca3af&size=200",
        "category": {
          "id": "01a16342-48b4-73dd-92af-6f4ae4b07df0",
          "name": "Makeup"
        },
        "duration_min": 60,
        "from_price": null
      }
    ]
  }
}
```

| Field | Type | Nullable | Description |
|-------|------|----------|-------------|
| category fields | | | As §3.1 |
| `services[]` | ServiceCard | no (list) | Active bookable services of the category, admin order |
| `services[].category` | `{id, name}` | no | Parent category |
| `services[].duration_min` | int | no | Admin default duration |
| `services[].from_price` | money string | **yes** | Lowest eligible provider price here; `null` = nobody can serve it at this location |

**Errors:** location errors above, plus

| HTTP | `error_code` | When | What the app should do |
|------|--------------|------|------------------------|
| 404 | `ENTITY_NOT_FOUND` | Category unknown or inactive | Go back and reload categories |

```json
{
  "status": "fail",
  "message": "Category not found.",
  "data": {
    "error_code": "ENTITY_NOT_FOUND",
    "correlation_id": "9dc535a7-96b9-428a-8940-115bed22d9ff"
  }
}
```

### 3.3 Service details

`GET /api/v1/client/services/{service}`

| Name | In | Type | Required | Rules | Description |
|------|----|------|----------|-------|-------------|
| `service` | path | uuid (string) | yes | active bookable (type 1) service in an active category | Option groups/choices, inactive service or category → 404 |
| + location keys | query | | no | | See above |

**Success — `200`**

```json
{
  "status": "success",
  "message": "Service retrieved.",
  "data": {
    "id": "01a16342-4afc-7257-aea8-0e150a69348b",
    "name": "Evening makeup",
    "description": "Full evening look",
    "image": "https://ui-avatars.com/api/?name=N&background=f3f4f6&color=9ca3af&size=200",
    "category": {
      "id": "01a16342-4af9-7294-b2dc-0e2a9b5a47ab",
      "name": "Makeup"
    },
    "duration_min": 120,
    "from_price": "350.00",
    "option_groups": [
      {
        "id": "01a16342-4aff-71f7-8023-71bf974562aa",
        "name": "Lashes",
        "description": null,
        "is_required": true,
        "selection": {
          "value": "single",
          "label": "Single"
        },
        "choices": [
          {
            "id": "01a16342-4b03-7396-89aa-234ef74a1332",
            "name": "With lashes",
            "description": null,
            "extra_duration_min": 15,
            "is_free": false,
            "from_price": "40.00"
          },
          {
            "id": "01a16342-4b06-7375-93bc-84613fda1ca7",
            "name": "Without lashes",
            "description": null,
            "extra_duration_min": 0,
            "is_free": true,
            "from_price": "0.00"
          }
        ]
      },
      {
        "id": "01a16342-4b0b-7204-8e6f-09cd2b0909d8",
        "name": "Extras",
        "description": null,
        "is_required": false,
        "selection": {
          "value": "multiple",
          "label": "Multiple"
        },
        "choices": [
          {
            "id": "01a16342-4b0e-70dd-aeb6-9034a85f0b30",
            "name": "Glitter",
            "description": null,
            "extra_duration_min": 10,
            "is_free": false,
            "from_price": "20.00"
          }
        ]
      }
    ]
  }
}
```

| Field | Type | Nullable | Description |
|-------|------|----------|-------------|
| card fields | | | As `services[]` in §3.2 (`from_price` nullable) |
| `option_groups[]` | OptionGroup | no (list) | Active groups that have active choices; empty list → go straight to provider selection |
| `option_groups[].is_required` | bool | no | The customer must pick a choice before continuing (BR-05, AC-CUST-007) |
| `option_groups[].selection` | EnumValue | no | `single` (exactly one) or `multiple` (one or more) |
| `choices[].extra_duration_min` | int | no | Minutes the choice adds |
| `choices[].is_free` | bool | no | Admin range is 0–0 |
| `choices[].from_price` | money string | **yes** | Lowest add-on amount among providers who can book this service here; `null` when none |

There is no total or final price (CUST-006): the app shows the from-prices only.
The backend re-checks every group's rule at checkout (booking feature, TD-42).

**Errors:** location errors above, plus

| HTTP | `error_code` | When | What the app should do |
|------|--------------|------|------------------------|
| 404 | `ENTITY_NOT_FOUND` | Not a bookable service, inactive, or its category is inactive | Go back |

```json
{
  "status": "fail",
  "message": "Service not found.",
  "data": {
    "error_code": "ENTITY_NOT_FOUND",
    "correlation_id": "75298b56-ce22-4417-981f-2f5e306cd8ff"
  }
}
```

```json
{
  "status": "fail",
  "message": "The city id field is required when district id is present.",
  "data": {
    "errors": {
      "city_id": [
        "The city id field is required when district id is present."
      ]
    }
  }
}
```

### 3.4 Search

`GET /api/v1/client/search`

| Name | In | Type | Required | Rules | Description |
|------|----|------|----------|-------|-------------|
| `q` | query | string | no | max 100 | Text in service names (ar or en), package names, provider names |
| `category_id` | query | uuid (string) | no | uuid, exists `categories` | Category chip |
| `type` | query | string | no | `services` (default) · `packages` · `providers` | Result tab |
| `page` | query | int | no | ≥ 1 | Page |
| `per_page` | query | int | no | 1–100, default 15 | Page size |
| + location keys | query | | no | | See above |

Results are paginated under `data.data` with `links` and `meta`; `links.next`
keeps every query parameter.

| `type` | Item | Contains | Order |
|--------|------|----------|-------|
| `services` | ServiceCard (§3.2) | Active bookable services with at least one eligible provider at the location | Admin order |
| `packages` | PackageCard | Live packages (approved, visible, not hidden) of eligible providers, active category | Featured providers first, then newest |
| `providers` | ProviderCard | Eligible providers; with `category_id`, those with a bookable service in it | Featured first, then rating, then name |

**Success — `200`, services matching text**

```json
{
  "status": "success",
  "message": "Search results retrieved.",
  "data": {
    "data": [
      {
        "id": "01a16382-b509-7082-92d9-86383e059e76",
        "name": "Evening makeup",
        "description": "Full evening look",
        "image": "https://ui-avatars.com/api/?name=N&background=f3f4f6&color=9ca3af&size=200",
        "category": {
          "id": "01a16382-b506-731c-bae6-41c27e4b792c",
          "name": "Makeup"
        },
        "duration_min": 120,
        "from_price": "350.00"
      },
      {
        "id": "01a16382-b517-73e1-8ceb-f2666ed10361",
        "name": "Evening blow-dry",
        "description": null,
        "image": "https://ui-avatars.com/api/?name=N&background=f3f4f6&color=9ca3af&size=200",
        "category": {
          "id": "01a16382-b515-7083-98fa-22d27c92eaa1",
          "name": "Hair"
        },
        "duration_min": 60,
        "from_price": "150.00"
      }
    ],
    "links": {
      "first": "http://localhost/api/v1/client/search?q=evening&city_id=01a16382-b4f9-7048-bcd0-718e67a95446&district_id=01a16382-b500-72e5-a503-16b37e1a99ef&page=1",
      "last": "http://localhost/api/v1/client/search?q=evening&city_id=01a16382-b4f9-7048-bcd0-718e67a95446&district_id=01a16382-b500-72e5-a503-16b37e1a99ef&page=1",
      "prev": null,
      "next": null
    },
    "meta": {
      "current_page": 1,
      "from": 1,
      "last_page": 1,
      "links": [
        {
          "url": null,
          "label": "&laquo; Previous",
          "page": null,
          "active": false
        },
        {
          "url": "http://localhost/api/v1/client/search?q=evening&city_id=01a16382-b4f9-7048-bcd0-718e67a95446&district_id=01a16382-b500-72e5-a503-16b37e1a99ef&page=1",
          "label": "1",
          "page": 1,
          "active": true
        },
        {
          "url": null,
          "label": "Next &raquo;",
          "page": null,
          "active": false
        }
      ],
      "path": "http://localhost/api/v1/client/search",
      "per_page": 15,
      "to": 2,
      "total": 2
    }
  }
}
```

**Success — `200`, packages**

```json
{
  "status": "success",
  "message": "Search results retrieved.",
  "data": {
    "data": [
      {
        "id": "01a16342-4d0d-732f-88bf-ea950df823dc",
        "name": "Full glam package",
        "description": null,
        "image": "https://ui-avatars.com/api/?name=N&background=f3f4f6&color=9ca3af&size=200",
        "category": {
          "id": "01a16342-4c5f-704b-b0a0-73939124c701",
          "name": "Makeup"
        },
        "provider": {
          "id": "01a16342-4c6b-715d-949e-8727a8803d78",
          "name": "Lama Alotaibi"
        },
        "price": "640.00",
        "compare_at_price": "760.00",
        "savings_amount": "120.00",
        "validity_days": 60,
        "services": [
          {
            "id": "01a16342-4c62-7068-8506-3b4e4d0023ec",
            "name": "Evening makeup",
            "sessions": 2
          }
        ]
      }
    ],
    "links": {
      "first": "http://localhost/api/v1/client/search?type=packages&city_id=01a16342-4c54-72eb-b579-32ba30d2f947&district_id=01a16342-4c5a-72cd-9742-e17a7a21c9ce&page=1",
      "last": "http://localhost/api/v1/client/search?type=packages&city_id=01a16342-4c54-72eb-b579-32ba30d2f947&district_id=01a16342-4c5a-72cd-9742-e17a7a21c9ce&page=1",
      "prev": null,
      "next": null
    },
    "meta": {
      "current_page": 1,
      "from": 1,
      "last_page": 1,
      "links": [
        {
          "url": null,
          "label": "&laquo; Previous",
          "page": null,
          "active": false
        },
        {
          "url": "http://localhost/api/v1/client/search?type=packages&city_id=01a16342-4c54-72eb-b579-32ba30d2f947&district_id=01a16342-4c5a-72cd-9742-e17a7a21c9ce&page=1",
          "label": "1",
          "page": 1,
          "active": true
        },
        {
          "url": null,
          "label": "Next &raquo;",
          "page": null,
          "active": false
        }
      ],
      "path": "http://localhost/api/v1/client/search",
      "per_page": 15,
      "to": 1,
      "total": 1
    }
  }
}
```

**Success — `200`, providers (featured first)**

```json
{
  "status": "success",
  "message": "Search results retrieved.",
  "data": {
    "data": [
      {
        "id": "01a16342-4cae-71c8-9f84-704ec528b501",
        "name": "Noura Alqahtani",
        "professional_title": null,
        "provider_type": {
          "value": "individual",
          "label": "Individual"
        },
        "photo": "https://ui-avatars.com/api/?name=U&background=dbeafe&color=1d4ed8&size=200",
        "rating_avg": null,
        "rating_count": 0,
        "completed_orders_count": 0
      },
      {
        "id": "01a16342-4c6b-715d-949e-8727a8803d78",
        "name": "Lama Alotaibi",
        "professional_title": null,
        "provider_type": {
          "value": "individual",
          "label": "Individual"
        },
        "photo": "https://ui-avatars.com/api/?name=U&background=dbeafe&color=1d4ed8&size=200",
        "rating_avg": null,
        "rating_count": 0,
        "completed_orders_count": 0
      }
    ],
    "links": {
      "first": "http://localhost/api/v1/client/search?type=providers&city_id=01a16342-4c54-72eb-b579-32ba30d2f947&district_id=01a16342-4c5a-72cd-9742-e17a7a21c9ce&page=1",
      "last": "http://localhost/api/v1/client/search?type=providers&city_id=01a16342-4c54-72eb-b579-32ba30d2f947&district_id=01a16342-4c5a-72cd-9742-e17a7a21c9ce&page=1",
      "prev": null,
      "next": null
    },
    "meta": {
      "current_page": 1,
      "from": 1,
      "last_page": 1,
      "links": [
        {
          "url": null,
          "label": "&laquo; Previous",
          "page": null,
          "active": false
        },
        {
          "url": "http://localhost/api/v1/client/search?type=providers&city_id=01a16342-4c54-72eb-b579-32ba30d2f947&district_id=01a16342-4c5a-72cd-9742-e17a7a21c9ce&page=1",
          "label": "1",
          "page": 1,
          "active": true
        },
        {
          "url": null,
          "label": "Next &raquo;",
          "page": null,
          "active": false
        }
      ],
      "path": "http://localhost/api/v1/client/search",
      "per_page": 15,
      "to": 2,
      "total": 2
    }
  }
}
```

**PackageCard fields**

| Field | Type | Nullable | Description |
|-------|------|----------|-------------|
| `name`, `description` | string | desc yes | Provider's own text |
| `category`, `provider` | `{id, name}` | no | Card subtitle "by …" |
| `price` | money string | no | Fixed package price (D1) |
| `compare_at_price` | money string | yes | Struck-through price |
| `savings_amount` | money string | yes | `compare_at_price − price` ("save 120") |
| `validity_days` | int | no | 30 / 45 / 60 / 90 (D1) |
| `services[]` | `{id, name, sessions}` | no | Included services |

**ProviderCard fields:** `id`, `name`, `professional_title` (nullable), `provider_type` EnumValue (`individual` / `establishment`), `photo` URL, `rating_avg` (nullable string, 2 decimals), `rating_count`, `completed_orders_count`. No "Featured" or "Verified" label (DEC-23).

**Errors:** location errors above, plus

```json
{
  "status": "fail",
  "message": "The selected type is invalid.",
  "data": {
    "errors": {
      "type": [
        "The selected type is invalid."
      ]
    }
  }
}
```

### 3.5 Package details

`GET /api/v1/client/packages/{package}` — public; `package` is the package uuid (an integer id is 404).
Same optional token and location keys as §3. A package is **live** only while it is approved by Joshen,
switched on by its provider (`is_visible`), not hidden by Joshen, its provider is eligible at the location
(BR-08) and every included service is still active and enabled by her (BR-07). Anything else is **404
`ENTITY_NOT_FOUND`** ("Package not found."), the same as an unknown id, so the app cannot tell a hidden
package from a missing one.

**Success — `200`.** Postman: `client.packages.show` › "200 Package details at a district".

```json
{
  "status": "success",
  "message": "Package retrieved.",
  "data": {
    "id": "01a171fb-c55a-718f-9302-a7142dae39bf",
    "name": "Evening package",
    "description": "Two sessions",
    "image": "https://ui-avatars.com/api/?name=N&background=f3f4f6&color=9ca3af&size=200",
    "category": { "id": "01a171fb-c4e7-712a-b28a-8b1fa4abde32", "name": "Makeup" },
    "provider": {
      "id": "01a171fb-c4ef-7164-9abf-ab4f181d8c1d",
      "name": "Nora Salon",
      "photo": "https://ui-avatars.com/api/?name=U&background=dbeafe&color=1d4ed8&size=200",
      "rating_avg": null,
      "rating_count": 0
    },
    "price": "640.00",
    "compare_at_price": "760.00",
    "savings_amount": "120.00",
    "validity_days": 60,
    "services": [
      { "id": "01a171fb-c4eb-7376-916d-164ab0ff7d86", "name": "Evening makeup", "sessions": 2, "duration_min": 60 }
    ],
    "occasions": [{ "id": "01a171fb-c55e-73d5-bf66-6de5cf97a91a", "name": "Bridal" }],
    "total_sessions": 2,
    "discount_percent": 16,
    "can_buy": false
  }
}
```

| Field | Type | Nullable | Description |
|-------|------|----------|-------------|
| `price` | money string | no | Fixed package price, SAR; paid once, sessions are redeemed as separate bookings (D1) |
| `compare_at_price`, `savings_amount`, `discount_percent` | money string / money string / int | yes | What the services cost separately, the saving and the whole-number discount; `null` without a discount |
| `validity_days` | int | no | 30, 45, 60 or 90 days from purchase |
| `services[]` | object | no | `id` of the service, localized `name`, prepaid `sessions`, `duration_min` of one session |
| `total_sessions` | int | no | Sum of `services[].sessions` |
| `provider` | object | no | Same identity as the package card plus `photo`, `rating_avg` (nullable), `rating_count` |
| `can_buy` | bool | no | `true` only for a signed-in customer when the package is buyable; show Buy only when `true` |

Errors: **404** `ENTITY_NOT_FOUND` (unknown / not live / not at this location), **422** location validation (`data.errors`), **401/403** as in §3. Postman: "404 Package not available at this district" · "422 Unknown city in the location".

## 4. Statuses and enums

| Enum | `value` | Meaning | App behaviour |
|------|---------|---------|---------------|
| option group `selection` | `single` | Pick exactly one choice | Radio buttons |
| | `multiple` | Pick one or more (none when not required) | Checkboxes |
| provider `provider_type` | `individual` | One specialist | — |
| | `establishment` | A salon/centre on one account (D4) | — |

## Data model

| Table (`db-schema.sql`) | Read / write | Notes |
|-------------------------|--------------|-------|
| `categories` (+ translations) | read | Active only |
| `services` (+ translations) | read | Type 1 bookable, type 2 option group, type 3 choice (TD-42) |
| `provider_services` | read | Base and choice prices; completeness = TD-71 rule |
| `provider_categories` | read | Approved and enabled category per provider |
| `providers`, `provider_districts` | read | Account, accepting flag, coverage (TD-64/78) |
| `packages`, `package_items` | read | Live packages |
| `customer_addresses` | read | Location |
| `v_provider_featured` | read | Ranking only (BR-09) |

## 5. Dart models

```dart
class ServiceCard {
  final String id;
  final String? name, description;
  final String image;
  final IdName category;
  final int durationMin;
  final String? fromPrice; // null = not available at this location

  ServiceCard.fromJson(Map<String, dynamic> j)
      : id = j['id'] as String,
        name = j['name'] as String?,
        description = j['description'] as String?,
        image = j['image'] as String,
        category = IdName.fromJson(j['category']),
        durationMin = j['duration_min'] as int,
        fromPrice = j['from_price'] as String?;
}

class ServiceDetails extends ServiceCard {
  final List<OptionGroup> optionGroups;

  ServiceDetails.fromJson(Map<String, dynamic> j)
      : optionGroups = (j['option_groups'] as List).map((e) => OptionGroup.fromJson(e)).toList(),
        super.fromJson(j);
}

class OptionGroup {
  final String id;
  final String? name, description;
  final bool isRequired;
  final EnumValue selection; // single | multiple
  final List<Choice> choices;

  OptionGroup.fromJson(Map<String, dynamic> j)
      : id = j['id'] as String,
        name = j['name'] as String?,
        description = j['description'] as String?,
        isRequired = j['is_required'] as bool,
        selection = EnumValue.fromJson(j['selection']),
        choices = (j['choices'] as List).map((e) => Choice.fromJson(e)).toList();
}

class Choice {
  final String id;
  final String? name, description, fromPrice;
  final int extraDurationMin;
  final bool isFree;

  Choice.fromJson(Map<String, dynamic> j)
      : id = j['id'] as String,
        name = j['name'] as String?,
        description = j['description'] as String?,
        extraDurationMin = j['extra_duration_min'] as int,
        isFree = j['is_free'] as bool,
        fromPrice = j['from_price'] as String?;
}

class PackageCard {
  final String id, name, price;
  final String? description, compareAtPrice, savingsAmount;
  final String image;
  final IdName category, provider;
  final int validityDays;
  final List<PackageService> services; // {id, name, sessions}
  // fromJson mirrors the field table in §3.4
}

class ProviderCard {
  final String id, name, photo;
  final String? professionalTitle, ratingAvg;
  final EnumValue providerType;
  final int ratingCount, completedOrdersCount;
}
```

## 6. Business rules enforced by the backend

| Rule | What the backend does | What the user sees |
|------|-----------------------|--------------------|
| BR-02 | All four endpoints are public | Guests browse; "Continue" asks for login |
| BR-05 / TD-42 | Only active groups with active choices are returned, each with its own required/selection rule | The app blocks "Continue" until required groups are answered |
| BR-06 / TD-71 | A provider counts only with an active base price and prices for every active choice in active groups (free choices priced 0) in an approved, enabled, active category | `from_price` and search ignore incomplete pricing |
| BR-08 / TD-78 | Approved (not suspended), accepting bookings, active user, coverage reaching the location | Prices/results change with the address; `null` / empty when nobody covers it |
| BR-09 | Featured ranking only after eligibility | Out-of-area subscribers never appear |
| TD-69 | `address_id` must be hers; chosen disabled areas refused | 404 / 422 |

## 7. Notifications and real-time events

N/A.

## 8. Localization and UI notes

- Category, service, group and choice texts follow `Accept-Language` with Arabic fallback; package and provider names are the provider's own text.
- Show "from X SAR" only when `from_price` is not null; for choices with `is_free: true` show "free".
- Search text matches Arabic and English names; LIKE wildcards are treated as plain characters.

## 9. Integration checklist

- [ ] Models parse the real responses above (nullable `from_price`)
- [ ] Location keys sent the same way as on the home page
- [ ] Required option groups block "Continue"; `single` vs `multiple` widgets
- [ ] Search tabs map to `type`; chips to `category_id`; paging follows `links.next`
- [ ] 401 / 403 / 404 / 422 handled per §3
- [ ] Stopped calling the retired `/search/*` routes
- [ ] Tested in `ar` (RTL) and `en`

## 10. Acceptance scenarios

| AC ID | Scenario | Expected | Test |
|-------|----------|----------|------|
| AC-CUST-006 | Category list | Active categories in order with active bookable-service counts | `CatalogControllerTest::test_ac_cust_006_categories_list_counts_active_services` |
| AC-CUST-006 | Browse a category for a location | Its services; unapproved, suspended, paused, out-of-area, inactive and incomplete prices excluded from `from_price` | `…test_ac_cust_006_from_price_ignores_unapproved_out_of_area_and_inactive_prices` |
| AC-CUST-006 | Coverage modes | District, radius (map point), city fallback, no location | `…test_ac_cust_006_coverage_by_district_radius_and_signed_in_default_address` |
| AC-CUST-007 | Service with mandatory options | Required/optional groups returned with rules; empty/inactive groups and inactive choices hidden; no final price | `…test_ac_cust_007_service_details_list_selectable_groups_and_choice_from_prices` (blocking "Continue" is app-side; checkout re-checks) |
| AC-CUST-007 | No provider serves the area | Details still open; service and choice `from_price` are `null` | `…test_ac_cust_007_service_details_without_an_eligible_provider_have_null_from_prices` |
| AC-CUST-008 (listing) | Provider-owned packages | Provider, included services, fixed price; only eligible providers; featured first | `…test_ac_cust_008_search_respects_eligibility_and_featured_order` |
| AC-CUST-008 (details) | Package details at a served district; guests allowed | `PackageDetailsTest::test_ac_cust_008_guest_sees_package_details_at_a_served_district` |
| AC-CUST-008 (details) | Not found outside coverage, or when not approved / hidden / switched off / archived | `…test_ac_cust_008_package_is_not_found_outside_coverage_or_when_not_live` |
| BR-07 | A package stops selling when an included service is disabled | `…test_br_07_package_stops_selling_when_a_service_is_no_longer_enabled` |
| AC-CUST-008 | Signed-in search | Her default address is the location; another customer's `address_id` 404 | `…test_ac_cust_008_signed_in_search_uses_default_address_and_refuses_foreign_address` |
| AC-CUST-001 | Guest browsing | Same URLs without a token | All tests above except the signed-in ones call without a token |

## 11. Open decisions and known limitations

| ID | Impact on this feature | Current behavior |
|----|------------------------|------------------|
| CUST-008 purchase, D1 | Buying a package and redeeming sessions | Purchase and redemption: [my packages](my-packages.md); the guide is still being verified |
| PROV-011 | Providers create packages ([provider/packages.md](../provider/packages.md)) and Joshen reviews them ([admin/packages.md](../admin/packages.md)) | `type=packages` lists only approved, visible, not hidden packages |
| TD-78 / PROV-006 | `is_accepting_bookings` has no toggle yet | Real providers are not eligible until the provider home ships, so `from_price` is `null` in real data |
| DEC-23 | Customer-facing "Featured"/"Verified" labels | Not returned |
| DEC-04 | Availability by date/time | Browsing uses the accepting-bookings flag only; slots belong to booking (CUST-012/013) |
| CUST-009 | Joshen finder | `client/joshan.md` |
