# المنتجات

المنتج هو الوحدة الأساسية القابلة للبيع في المتجر. كل نداءات المنتجات مقيّدة بمتجر المفتاح المنادي — لا يمكنك أبدًا الوصول إلى بيانات تاجر آخر بالخطأ.

## `GET /v1/products`[​](#get-v1products "رابط مباشر إلى get-v1products")

قائمة المنتجات. ترقيم بالمؤشّر. تُخدَم حديثًا في كل نداء: طلب `GET` المُرسَل مباشرة بعد الكتابة يُرجع القيم الجديدة.

**المصادقة:** مفتاح منصة بصلاحية `products:read` (ممنوحة افتراضيًا). المفتاح الذي لا يحملها يحصل على `403 forbidden`.

### معاملات الاستعلام[​](#معاملات-الاستعلام "رابط مباشر إلى معاملات الاستعلام")

| المعامل  | النوع                             | الافتراضي | ملاحظات                           |
| -------- | --------------------------------- | --------- | --------------------------------- |
| `limit`  | int (1–200)                       | 50        | حجم الصفحة                        |
| `cursor` | string                            | —         | من `next_cursor` لاستجابة سابقة   |
| `status` | `active` \| `draft` \| `archived` | —         | تصفية حسب الحالة                  |
| `search` | string                            | —         | يطابق `name` (LIKE) و`sku` بالضبط |

القيمة غير المعروفة في `status` تُتجاهَل بدل أن تُرفَض — فتحصل على القائمة غير المصفّاة، وهي **تشمل المنتجات بحالة `archived`**. صفِّ صراحةً إن أردت العناصر الحيّة فقط.

### الطلب[​](#الطلب "رابط مباشر إلى الطلب")

```
curl 'https://api.dzbuild.app/v1/products?limit=10&status=active' \

  -H "Authorization: Bearer $DZ_KEY"
```

### الاستجابة 200[​](#الاستجابة-200 "رابط مباشر إلى الاستجابة 200")

```
{

  "data": {

    "items": [

      {

        "id":             26,

        "name":           "PRO",

        "slug":           "pro",

        "short_description": null,

        "price":          1000,

        "compare_price":  null,

        "sku":            "",

        "stock_quantity": 0,

        "track_stock":    false,

        "status":         "active",

        "has_variants":   true,

        "featured":       false,

        "primary_image":  "https://cdn.dzbuild.app/uploads/products/123/123_1700000000_example.webp",

        "created_at":     "2026-01-13 15:06:06",

        "updated_at":     "2026-01-13 15:12:32"

      }

    ],

    "next_cursor": null,

    "has_more":    false

  },

  "meta": { "request_id": "...", "api_version": "v1" }

}
```

تغيّر في v1.1 — روابط الصور صارت كاملة

صار `primary_image` (وكذلك `images[].url` في `GET /v1/products/{id}`) **رابط CDN كاملًا** جاهزًا للاستعمال كما هو. قبل v1.1 كان الاثنان يُرجعان اسم ملف مجرّدًا على المستدعي أن يضيف إليه البادئة بنفسه. فإن كان تكاملك يبني البادئة يدويًا، احذف ذلك المنطق — فالقيمة تبدأ أصلًا بـ `https://`.

## `GET /v1/products/{id}`[​](#get-v1productsid "رابط مباشر إلى get-v1productsid")

تفاصيل المنتج كاملة بما فيها الصور والمتغيرات.

**المصادقة:** مفتاح منصة بصلاحية `products:read`.

### الطلب[​](#الطلب-1 "رابط مباشر إلى الطلب")

```
curl https://api.dzbuild.app/v1/products/26 \

  -H "Authorization: Bearer $DZ_KEY"
```

### الاستجابة 200[​](#الاستجابة-200-1 "رابط مباشر إلى الاستجابة 200")

```
{

  "data": {

    "id":               26,

    "name":             "PRO",

    "slug":             "pro",

    "description":      "- Single store\n- Up to 300 products\n- ...",

    "short_description": null,

    "category_id":      null,

    "pricing": {

      "price":         1000,

      "compare_price": null,

      "cost_price":    null

    },

    "inventory": {

      "sku":             "",

      "barcode":         null,

      "track_stock":     false,

      "stock_quantity":  0,

      "low_stock_alert": 5

    },

    "shipping": {

      "weight": null, "height": null, "width": null, "length": null,

      "do_insurance": false

    },

    "status":       "active",

    "featured":     false,

    "has_variants": true,

    "images": [

      { "id": 28, "url": "https://cdn.dzbuild.app/uploads/products/123/123_1700000000_example.webp",

        "alt_text": "Front view", "is_primary": true, "sort_order": 0 }

    ],

    "variants": [

      {

        "id":   11,

        "name": "Duration",

        "type": "text",

        "required": true,

        "sort_order": 0,

        "options": [

          { "id": 14, "value": "30 days", "color_code": null, "price_adjustment": 0,

            "stock": null, "sku": null, "image_id": null, "show_as_card": false,

            "sort_order": 0, "is_active": true },

          { "id": 15, "value": "90 days", "color_code": null, "price_adjustment": 500,

            "stock": null, "sku": null, "image_id": null, "show_as_card": false,

            "sort_order": 1, "is_active": true }

        ]

      }

    ],

    "combinations": [],

    "combination_count": 0,

    "combinations_truncated": false,

    "created_at": "2026-01-13 15:06:06",

    "updated_at": "2026-01-13 15:12:32"

  }

}
```

أُضيف في v1.1

`images[].alt_text`، وحقول الخيار الكاملة (`price_adjustment` و`sku` و`show_as_card` و`sort_order` و`is_active`)، و`required` / `sort_order` على مستوى المجموعة، وكتلة `combinations` بأكملها — كلها جديدة. تسرد `combinations` 300 مدخلة كحد أقصى، بينما يبقى `combination_count` دائمًا الإجمالي الحقيقي، و`combinations_truncated` يخبرك متى اقتُطعت القائمة.

## `POST /v1/products` — إنشاء[​](#post-v1products--إنشاء "رابط مباشر إلى post-v1products--إنشاء")

**المصادقة:** مفتاح منصة بصلاحيتَي `products:write` و`products:read`. الرد هو المنتج كما يُرجعه `GET /v1/products/{id}`، لذا فالمفتاح الذي لا يحمل `products:read` يحصل على `403 forbidden` رغم أن المنتج أُنشئ. **يتطلب `Idempotency-Key`.**

### الجسم[​](#الجسم "رابط مباشر إلى الجسم")

| الحقل                                  | النوع                             | إلزامي | ملاحظات                                                                                                                                                                                                                  |
| -------------------------------------- | --------------------------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `name`                                 | string (1–255)                    | ✅     |                                                                                                                                                                                                                          |
| `price`                                | number ≥ 0                        | ✅     | بالدينار الجزائري                                                                                                                                                                                                        |
| `compare_price`                        | number ≥ 0 \| null                |        | السعر المشطوب                                                                                                                                                                                                            |
| `cost_price`                           | number ≥ 0 \| null                |        | للتاجر فقط — لا يُعرض للعميل                                                                                                                                                                                             |
| `description`                          | string                            |        | وصف طويل، يقبل الأسطر وتنسيق HTML (خط عريض، قوائم، عناوين، روابط، جداول، صور)؛ وتُحذف السكربتات وغيرها من الوسوم غير الآمنة. الحد الأقصى 60000 بايت: ما يتجاوزه يُرجع `bad_request` `"description exceeds 60000 bytes"`. |
| `short_description`                    | string ≤ 500                      |        | عبارة قصيرة                                                                                                                                                                                                              |
| `sku`                                  | string ≤ 100                      |        | SKU داخلي                                                                                                                                                                                                                |
| `barcode`                              | string ≤ 100                      |        | UPC/EAN                                                                                                                                                                                                                  |
| `weight`                               | number                            |        | كغ، للشحن                                                                                                                                                                                                                |
| `shipping_height` / `width` / `length` | number                            |        | سم                                                                                                                                                                                                                       |
| `do_insurance`                         | bool                              |        | فرض تأمين الشحن لهذا المنتج                                                                                                                                                                                              |
| `track_stock`                          | bool                              |        | الافتراضي `false`                                                                                                                                                                                                        |
| `stock_quantity`                       | int ≥ 0                           |        | إن كان `track_stock`                                                                                                                                                                                                     |
| `low_stock_alert`                      | int ≥ 0                           |        | الافتراضي `5`. يقود شارة "مخزون منخفض" في لوحة التحكم.                                                                                                                                                                   |
| `variant_stock_enabled`                | bool                              |        | تتبّع المخزون لكل **خيار** متغيّر (أحمر، L، …)                                                                                                                                                                           |
| `combination_stock_enabled`            | bool                              |        | تتبّع المخزون لكل **تركيبة** متغيرات (أحمر+L). يستلزم `variant_stock_enabled`.                                                                                                                                           |
| `category_id`                          | int                               |        | يجب أن يكون موجودًا في متجرك. ينضمّ المنتج إلى هذه الفئة وتصير فئته الرئيسية، مع الإبقاء على الفئات التي ينتمي إليها أصلًا. وفي PATCH تمسح القيمة `null` الفئة الرئيسية دون إخراج المنتج من أي فئة.                      |
| `status`                               | `active` \| `draft` \| `archived` |        | الافتراضي `draft`                                                                                                                                                                                                        |
| `featured`                             | bool                              |        | الافتراضي `false`                                                                                                                                                                                                        |

عند تفعيل `variant_stock_enabled` أو `combination_stock_enabled`، يُعطَّل `track_stock` تلقائيًا (المتغيرات تتحكم بمخزونها).

نادرًا ما تحتاج إلى ضبط هذين الحقلين مباشرةً: فـ `PUT /v1/products/{id}/variants` يضبطهما لك انطلاقًا من الحمولة التي ترسلها (مخزون لكل خيار أو تركيبات).

### حد الخطة[​](#حد-الخطة "رابط مباشر إلى حد الخطة")

Free: 5 منتجات نشطة. Pro: 300. Unlimited / Enterprise: غير محدود. يحسب فقط المنتجات ذات الحالة `active`، والمسوّدات لا تُحسب، ويجري العدّ لحظيًا عند كل نداء. الفحص يجري عند **الإنشاء فقط**: تحويل مسوّدة موجودة إلى `active` عبر PATCH لا يُحجب أبدًا، فيمكن لمتجر على الخطة المجانية تجاوز 5 منتجات نشطة بهذه الطريقة. ولأن الفحص يجري عند كل إنشاء، لا يمكن لمتجر بلغ حدّه إنشاء منتج جديد حتى مع `status: "draft"`. واسم خطة غير معروف يعود إلى الحد المجاني وهو 5. عند بلوغ الحد:

```
{ "error": { "code": "bad_request",

             "message": "Plan 'free' allows at most 5 active products. Upgrade to add more." } }
```

### الطلب[​](#الطلب-2 "رابط مباشر إلى الطلب")

```
curl -X POST 'https://api.dzbuild.app/v1/products' \

  -H "Authorization: Bearer $DZ_KEY" \

  -H "Content-Type: application/json" \

  -H "Idempotency-Key: $(uuidgen)" \

  -d '{

    "name":           "T-shirt - Cotton 200gsm",

    "price":          1500,

    "compare_price":  1900,

    "description":    "100% cotton, made in Algeria.",

    "sku":            "TS-COT-200",

    "stock_quantity": 50,

    "track_stock":    true,

    "status":         "draft"

  }'
```

### الاستجابة 200[​](#الاستجابة-200-2 "رابط مباشر إلى الاستجابة 200")

الإنشاء الناجح يُرجع HTTP **200** (وليس 201) بنفس جسم `GET /v1/products/{id}`. لا تتفرّع على `status === 201` — تحقّق من `data.id` بدلًا من ذلك. `id` و`slug` و`created_at` صارت معبّأة.

عند الإنشاء يُشتقّ `slug` دائمًا من `name` — وأي `slug` في الجسم **يُتجاهل**. لتعيين slug محدّد، أنشئ أولًا ثم استعمل `PATCH /v1/products/{id}` مع `{"slug":"…"}`. التطبيع يحوّل إلى حروف صغيرة ويستبدل كل تتابع من المحارف غير الحرفية وغير الرقمية بـ `-` (مدرك لليونيكود — الحروف العربية والمشكّلة تُحفَظ، فهو إذًا ليس `[a-z0-9-]`)، مع الاقتطاع عند 200 حرف؛ والتعارضات تأخذ لواحق `-2` و`-3` وهكذا.

### الأخطاء[​](#الأخطاء "رابط مباشر إلى الأخطاء")

| الكود                                                        | السبب                          |
| ------------------------------------------------------------ | ------------------------------ |
| `bad_request` "Body must be valid JSON"                      | Content-Type خاطئ أو JSON تالف |
| `bad_request` "name is required (1-255 chars)"               | الاسم مفقود أو طويل جدًا       |
| `bad_request` "price must be a non-negative number"          | سعر غير صالح                   |
| `bad_request` "category\_id N does not belong to this store" | فئة من متجر آخر                |
| `bad_request` "Plan 'free' allows at most …"                 | تجاوز حد الخطة                 |

## `PATCH /v1/products/{id}` — تعديل[​](#patch-v1productsid--تعديل "رابط مباشر إلى patch-v1productsid--تعديل")

**المصادقة:** مفتاح منصة بصلاحيتَي `products:write` و`products:read`. الرد هو المنتج المحدَّث، لذا فالمفتاح الذي لا يحمل `products:read` يحصل على `403 forbidden` رغم أن التعديل حُفظ. **يتطلب `Idempotency-Key`.**

تحديث جزئي — أرسل فقط الحقول التي تريد تغييرها. الحقول غير المرسلة تُحفظ كما هي.

```
curl -X PATCH 'https://api.dzbuild.app/v1/products/26' \

  -H "Authorization: Bearer $DZ_KEY" \

  -H "Content-Type: application/json" \

  -H "Idempotency-Key: $(uuidgen)" \

  -d '{ "price": 1200, "status": "active" }'
```

تُرجع `200` والمنتج الكامل المحدَّث. إذا لم يكن المنتج موجودًا (أو ينتمي لمتجر آخر) ستحصل على `404 not_found`.

عند تغيير الاسم عبر `PATCH { name: ... }` يُعاد توليد الـ `slug` تلقائيًا فقط إن لم تُرسل `slug` صراحةً. أرسل `slug` للحفاظ على رابط محدد بعد إعادة التسمية.

## `DELETE /v1/products/{id}`[​](#delete-v1productsid "رابط مباشر إلى delete-v1productsid")

**المصادقة:** مفتاح منصة بصلاحية `products:write`. **يتطلب `Idempotency-Key`.**

```
curl -X DELETE 'https://api.dzbuild.app/v1/products/26' \

  -H "Authorization: Bearer $DZ_KEY" \

  -H "Idempotency-Key: del-26-2026-04-30"
```

الاستجابة:

```
{ "data": { "deleted": true, "id": 26 } }
```

هذا **حذف نهائي**: يُحذف المنتج ومعه صوره ومتغيراته وعروضه وإضافاته (`add-ons`) وتركيباته وآراء العملاء عليه. أما **ملفات** الصور المخزَّنة فلا يحذفها هذا النداء، لذا قد يبقى رابط صورة حفظته سابقًا يعمل.

الحذف يفصل السجلّ، والمنتج المرتبط بصفحة هبوط لا يُحذف

الطلبات السابقة تحتفظ ببنودها، ويبقى اسم المنتج و SKU والسعر كما سُجّلت وقت الشراء، فتظل الطلبات القديمة مقروءة، لكن البند لم يعد مرتبطًا بمنتج (`product_id` يصبح `null`). المنتج الذي تستخدمه صفحة هبوط (على مستوى الصفحة، أو في قسم استمارة طلب أو زر طلب أو عروض منتج) يُرفض حذفه بـ `409 product_in_use_by_landing_page`؛ وتسرد بيانات الخطأ الصفحات في `landing_pages[]` مع `id` و `title` و `slug`. احذف صفحة الهبوط أولًا (`DELETE /v1/landing-pages/{id}`) أو اربطها بمنتج آخر (`PATCH /v1/landing-pages/{id}` مع `product_id` جديد)، ثم احذف المنتج. يُفضّل `PATCH { "status": "archived" }` على الحذف.

## `POST /v1/products/{id}/images` — إضافة صورة[​](#post-v1productsidimages--إضافة-صورة "رابط مباشر إلى post-v1productsidimages--إضافة-صورة")

*أُضيف في v1.1.* **المصادقة:** مفتاح منصة بصلاحية `products:write`. **يتطلب `Idempotency-Key`.**

أنت تُعطي **رابط `https` عموميًا**؛ وتتولّى DZBuild تنزيل الصورة من جهة الخادم وتحويلها وتحسينها ثم استضافتها على شبكة CDN الخاصة بالمتجر. لا يوجد رفع للملفات عبر الواجهة البرمجية — تكفي الإشارة إلى الصورة برابط ونحن نجلبها.

### الجسم[​](#الجسم-1 "رابط مباشر إلى الجسم")

| الحقل        | النوع         | إلزامي | ملاحظات                                   |
| ------------ | ------------- | ------ | ----------------------------------------- |
| `url`        | string ≤ 2000 | ✅     | رابط `https://` عمومي لملف الصورة         |
| `alt_text`   | string ≤ 255  |        | نص لإمكانية الوصول وتحسين محركات البحث    |
| `is_primary` | bool          |        | اجعل هذه الصورة هي الصورة الرئيسية للمنتج |

```
curl -X POST 'https://api.dzbuild.app/v1/products/26/images' \

  -H "Authorization: Bearer $DZ_KEY" \

  -H "Content-Type: application/json" \

  -H "Idempotency-Key: $(uuidgen)" \

  -d '{ "url": "https://example.com/tshirt-front.jpg", "alt_text": "T-shirt front" }'
```

```
{ "data": { "image": { "id": 88,

                       "url": "https://cdn.dzbuild.app/uploads/products/123/123_1700000001_example.webp",

                       "alt_text": "T-shirt front", "is_primary": true, "sort_order": 0,

                       "file_size": 27652, "width": 1000, "height": 1000 },

            "deduplicated": false } }
```

قواعد يجدر معرفتها:

* **أول صورة للمنتج تصير تلقائيًا الصورة الرئيسية.**
* إرسال رابط بايتاته مرفقة أصلًا بالمنتج **لا** يُنشئ نسخة مكرّرة — بل تستعيد الصورة الموجودة مع `"deduplicated": true` (وHTTP 200 بدل 201).
* الصيغ المقبولة: JPEG وPNG وWebP وGIF وBMP وAVIF وHEIC/HEIF وTIFF. الحد الأقصى **20 ميغابايت** و**10000×10000 بكسل**. تُحوَّل الصور إلى WebP (مع إزالة بيانات EXIF)، والصورة التي يزيد عرضها على 2000 بكسل يُصغَّر عرضها إلى 2000 بكسل مع الحفاظ على تناسب أبعادها. أما ملف WebP بحجم 3 ميغابايت أو أقل وعرض لا يتجاوز 2000 بكسل فيُخزَّن كما أُرسل.
* حد أقصى **20 صورة لكل منتج**.

### أي الروابط تُقبل[​](#أي-الروابط-تُقبل "رابط مباشر إلى أي الروابط تُقبل")

لأسباب أمنية، لا يقبل الجالب إلا العناوين **العمومية** ولا يتبع أي إعادة توجيه. يُرفَض الرابط (`url_refused`) إذا لم يكن `https`، أو حمل بيانات اعتماد (`https://user:pass@…`)، أو استعمل منفذًا غير 443، أو كان عنوان IP بدل اسم مضيف، أو أدّى إلى عنوان خاص أو داخلي أو عنوان بيانات وصفية سحابية. أما الرابط الذي يردّ بإعادة توجيه أو بحالة خطأ فيفشل بالخطأ `image_fetch_failed`، والرابط الذي يردّ بصفحة ويب (صفحة تسجيل دخول مثلًا) أو بأي ملف آخر ليس صورة مدعومة فيفشل بالخطأ `unsupported_image`.

### الأخطاء[​](#الأخطاء-1 "رابط مباشر إلى الأخطاء")

| الكود                | HTTP | السبب                                                               |
| -------------------- | ---- | ------------------------------------------------------------------- |
| `validation_error`   | 422  | `url` مفقود أو أطول من 2000 حرف                                     |
| `url_refused`        | 422  | رابط مرفوض حسب القواعد أعلاه                                        |
| `image_fetch_failed` | 422  | المضيف غير متاح، أو إعادة توجيه، أو استجابة غير 200                 |
| `unsupported_image`  | 422  | ليس صورة (صفحة ويب مثلًا)، أو صيغة غير مدعومة، أو أبعاد خارج المجال |
| `image_too_large`    | 422  | أكبر من 20 ميغابايت                                                 |
| `too_many_images`    | 422  | المنتج يحتوي أصلًا على 20 صورة                                      |
| `not_found`          | 404  | المنتج ليس في متجرك                                                 |

## `PATCH /v1/products/{id}/images/{image_id}`[​](#patch-v1productsidimagesimage_id "رابط مباشر إلى patch-v1productsidimagesimage_id")

*أُضيف في v1.1.* لتعديل `alt_text` أو `sort_order` (0–999)، أو لترقية الصورة إلى رئيسية عبر `is_primary: true`.

```
curl -X PATCH 'https://api.dzbuild.app/v1/products/26/images/88' \

  -H "Authorization: Bearer $DZ_KEY" -H "Content-Type: application/json" \

  -H "Idempotency-Key: $(uuidgen)" \

  -d '{ "is_primary": true }'
```

يحتفظ كل منتج دائمًا بصورة رئيسية واحدة بالضبط، لذا يُرفض `is_primary: false` بالخطأ `primary_required` — رقِّ صورة أخرى بدلًا من ذلك.

## `DELETE /v1/products/{id}/images/{image_id}`[​](#delete-v1productsidimagesimage_id "رابط مباشر إلى delete-v1productsidimagesimage_id")

*أُضيف في v1.1.* يحذف سجلّ الصورة وملفاتها المخزَّنة.

```
{ "data": { "deleted": true, "new_primary_image_id": 89,

            "variant_references_cleared": 2, "remaining_images": 3 } }
```

إن كانت خيارات متغيرات تشير إلى هذه الصورة، فتُمسح تلك الروابط (وتبقى الخيارات نفسها موجودة) — و`variant_references_cleared` يخبرك بعددها. وحذف الصورة الرئيسية يُرقّي الصورة التالية تلقائيًا.

## `PUT /v1/products/{id}/variants` — استبدال المتغيرات[​](#put-v1productsidvariants--استبدال-المتغيرات "رابط مباشر إلى put-v1productsidvariants--استبدال-المتغيرات")

*أُضيف في v1.1.* **المصادقة:** مفتاح منصة بصلاحية `products:write`. ترويسة `Idempotency-Key` اختيارية: معها تحصل إعادة المحاولة بنفس القيمة على الرد الأول، ومن دونها يُعاد تنفيذ الاستبدال الكامل في كل نداء.

هذا يستبدل كل متغيرات المنتج

لا يوجد تحديث جزئي للمتغيرات. اقرأ الحالة الحالية عبر `GET /v1/products/{id}` وأعد إرسال **كل ما تريد الاحتفاظ به** — فكل ما تحذفه من الحمولة يُحذف فعليًا. أرسل `{"groups": []}` لإزالة جميع المتغيرات.

### الجسم[​](#الجسم-2 "رابط مباشر إلى الجسم")

| الحقل                                 | النوع                                                           | إلزامي | ملاحظات                                                                                                                         |
| ------------------------------------- | --------------------------------------------------------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------- |
| `groups`                              | array                                                           | ✅     | مجموعات المتغيرات بترتيب العرض. و`[]` تمسح كل المتغيرات.                                                                        |
| `groups[].name`                       | string ≤ 100                                                    | ✅     | مثل `Color` أو `Size`. فريد داخل المنتج.                                                                                        |
| `groups[].type`                       | `text` \| `color` \| `image_text` \| `selectable` \| `dropdown` |        | الافتراضي `text`. و`selectable` تعني مجموعة إضافات اختيارية متعددة الاختيار. و`dropdown` تعني خيارات نصية تظهر في قائمة منسدلة. |
| `groups[].required`                   | bool                                                            |        | الافتراضي `true` (ودائمًا `false` مع `selectable`)                                                                              |
| `groups[].options[].name`             | string ≤ 100                                                    | ✅     | فريد داخل المجموعة                                                                                                              |
| `groups[].options[].color_code`       | `#rrggbb`                                                       |        | لمجموعات `color`                                                                                                                |
| `groups[].options[].price_adjustment` | number                                                          |        | يُضاف إلى السعر الأساسي (أو يُطرح منه)                                                                                          |
| `groups[].options[].stock`            | int ≥ 0 \| null                                                 |        | مخزون لكل خيار                                                                                                                  |
| `groups[].options[].sku`              | string ≤ 100                                                    |        | SKU لكل خيار                                                                                                                    |
| `groups[].options[].image_id`         | int                                                             |        | يجب أن يكون صورة موجودة **لهذا المنتج**                                                                                         |
| `groups[].options[].show_as_card`     | bool                                                            |        | عرض الخيار على شكل بطاقة صورة                                                                                                   |
| `combinations`                        | array                                                           |        | مخزون لكل تركيبة (يحتاج مجموعتين أو أكثر من غير نوع `selectable`)                                                               |
| `combinations[].options`              | object                                                          | ✅     | `{ "Color": "Red", "Size": "L" }` — مدخلة واحدة لكل مجموعة من غير نوع `selectable`                                              |
| `combinations[].stock`                | int ≥ 0                                                         | ✅     |                                                                                                                                 |
| `combinations[].sku`                  | string ≤ 100                                                    |        |                                                                                                                                 |
| `combinations[].is_active`            | bool                                                            |        | الافتراضي `true`                                                                                                                |

الحدود: 10 مجموعات، و100 خيار لكل مجموعة، و200 خيار إجمالًا، و1000 تركيبة.

```
curl -X PUT 'https://api.dzbuild.app/v1/products/26/variants' \

  -H "Authorization: Bearer $DZ_KEY" -H "Content-Type: application/json" \

  -H "Idempotency-Key: $(uuidgen)" \

  -d '{

    "groups": [

      { "name": "Color", "type": "color", "options": [

          { "name": "Red",  "color_code": "#ff0000", "image_id": 88 },

          { "name": "Blue", "color_code": "#0000ff" } ] },

      { "name": "Size", "type": "text", "options": [

          { "name": "L" }, { "name": "XL", "price_adjustment": 100 } ] }

    ],

    "combinations": [

      { "options": { "Color": "Red",  "Size": "L"  }, "stock": 5, "sku": "TS-R-L" },

      { "options": { "Color": "Blue", "Size": "XL" }, "stock": 2 }

    ]

  }'
```

تُرجع كتلتَي `variants` و`combinations` الجديدتين (بنفس شكل `GET /v1/products/{id}`).

### وضع المخزون يُضبط لك تلقائيًا[​](#وضع-المخزون-يُضبط-لك-تلقائيًا "رابط مباشر إلى وضع المخزون يُضبط لك تلقائيًا")

* **عند إرسال تركيبات**: مخزون لكل تركيبة (`combination_stock_enabled`)، مع تعطيل `track_stock` على مستوى المنتج.
* **بلا تركيبات لكن الخيارات تحمل `stock`**: مخزون لكل خيار (`variant_stock_enabled`)، مع تعطيل `track_stock`.
* **لا هذا ولا ذاك**: المتغيرات للعرض فقط، ويبقى المخزون على مستوى المنتج يعمل كالمعتاد.

### الأخطاء[​](#الأخطاء-2 "رابط مباشر إلى الأخطاء")

| الكود                         | HTTP | السبب                                                           |
| ----------------------------- | ---- | --------------------------------------------------------------- |
| `validation_error`            | 422  | أسماء أو أنواع أو ألوان أو أرقام غير صالحة، أو تجاوز أحد الحدود |
| `invalid_image_id`            | 422  | `image_id` ليس صورة لهذا المنتج                                 |
| `combinations_not_applicable` | 422  | أُرسلت تركيبات مع أقل من مجموعتين من غير نوع `selectable`       |
| `duplicate_combination`       | 422  | تركيبتان بنفس مجموعة الخيارات                                   |
| `not_found`                   | 404  | المنتج ليس في متجرك                                             |

يجري التحقق من الصحة **قبل** حذف أي شيء — فالحمولة المرفوضة تترك متغيراتك الحالية دون أي مساس.

## `GET /v1/products/{id}/stock`[​](#get-v1productsidstock "رابط مباشر إلى get-v1productsidstock")

يقرأ وضع مخزون المنتج والعدد الحالي لكل هدف يمكنك ضبطه في هذا الوضع. اقرأه قبل أي مزامنة للمخزون لتحصل على معرّفات الخيارات والتركيبات. وعلى خلاف `GET /v1/products`، هذه القراءة غير مخزَّنة مؤقتًا، فتُظهر أي تعديل في الحال.

**المصادقة:** مفتاح منصة بصلاحية `products:read`.

```
curl https://api.dzbuild.app/v1/products/30/stock \

  -H "Authorization: Bearer $DZ_KEY"
```

```
{

  "data": {

    "product_id": 30,

    "mode": "variant_options",

    "track_stock": false,

    "flags": { "variant_stock_enabled": true, "combination_stock_enabled": false },

    "max_stock": 9999999,

    "options": [

      { "target": "option", "id": 41, "group": "Size", "value": "M",

        "stock": null, "unlimited": true },

      { "target": "option", "id": 42, "group": "Size", "value": "L",

        "stock": 10, "unlimited": false }

    ]

  }

}
```

يبيّن `mode` أين يُحسب مخزون المنتج، ويسرد الرد أهداف هذا الوضع:

| `mode`            | يُحسب المخزون على           | ما يظهر في الرد                                                                                                          |
| ----------------- | --------------------------- | ------------------------------------------------------------------------------------------------------------------------ |
| `product`         | المنتج نفسه                 | `product.stock_quantity`                                                                                                 |
| `variant_options` | كل خيار من خيارات المتغيرات | `options[]`                                                                                                              |
| `combinations`    | كل تركيبة من الخيارات       | `combinations[]` مع `sku` و`is_active` و`options` (اسم المجموعة مقابل اسم الخيار). وتظهر `options[]` أيضًا، للقراءة فقط. |

الخيار الذي يحمل `"stock": null` و`"unlimited": true` مخزونه غير محدود. ويتبع الوضعُ المتغيراتِ المحفوظة عبر `PUT /v1/products/{id}/variants` (انظر أعلاه).

## `POST /v1/products/{id}/stock`[​](#post-v1productsidstock "رابط مباشر إلى post-v1productsidstock")

يضبط أعداد المخزون أو يغيّرها. **المصادقة:** مفتاح منصة بصلاحية `products:write`. **يتطلب `Idempotency-Key`.**

### الجسم[​](#الجسم-3 "رابط مباشر إلى الجسم")

تسرد `items` من 1 إلى 500 هدف. يحمل كل عنصر واحدًا فقط من `set` أو `delta` أو `"unlimited": true`، ولا يظهر الهدف الواحد إلا مرة واحدة في النداء.

| الحقل               | النوع                                  | إلزامي                        | ملاحظات                                                                                                                            |
| ------------------- | -------------------------------------- | ----------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- |
| `items[].target`    | `product` \| `option` \| `combination` | ✅                            | يجب أن يطابق `mode` الخاص بالمنتج: `product`، أو `option` مع `variant_options`، أو `combination` مع `combinations`                 |
| `items[].id`        | int ≥ 1                                | ✅ مع `option` و`combination` | المعرّف الذي يُرجعه `GET /v1/products/{id}/stock`                                                                                  |
| `items[].set`       | int، من 0 إلى 9999999                  |                               | العدد الجديد                                                                                                                       |
| `items[].delta`     | int، ليس 0                             |                               | الوحدات المضافة، وبقيمة سالبة للإنقاص. تبقى النتيجة بين 0 و9999999.                                                                |
| `items[].unlimited` | bool                                   |                               | للخيارات فقط. `true` تجعل مخزون الخيار غير محدود. والخيار غير المحدود حاليًا يحتاج `"unlimited": false` إلى جانب `set` ليبدأ عدّه. |

أي `set` أو `delta` على هدف `product` يفعّل أيضًا `track_stock`، فيحسب المتجر مخزون هذا المنتج من تلك اللحظة.

```
curl -X POST 'https://api.dzbuild.app/v1/products/30/stock' \

  -H "Authorization: Bearer $DZ_KEY" -H "Content-Type: application/json" \

  -H "Idempotency-Key: $(uuidgen)" \

  -d '{

    "items": [

      { "target": "option", "id": 41, "set": 20, "unlimited": false },

      { "target": "option", "id": 42, "delta": -2 }

    ]

  }'
```

تُرجع `200` مع المخزون بعد التعديل، بنفس شكل GET أعلاه: الخيار 41 صار `20` والخيار 42 صار `8`. تُحفظ العناصر معًا: إن رُفض أحدها لا يُحفظ أي منها. ويُسجَّل التعديل في سجل تعديلات المتجر (`GET /v1/changes`)، و`POST /v1/changes/{id}/undo` يُرجع الأعداد السابقة.

### الأخطاء[​](#الأخطاء-3 "رابط مباشر إلى الأخطاء")

| الكود                    | HTTP | السبب                                                                                                                                                                                                                     |
| ------------------------ | ---- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `validation_error`       | 422  | `items` غائبة أو فارغة أو تتجاوز 500؛ أو `target` أو `id` أو `set` أو `delta` غير صالح؛ أو الهدف نفسه مرتين؛ أو عنصر لا يحمل واحدًا فقط من `set` و`delta` و`"unlimited": true`؛ أو `"unlimited": true` على منتج أو تركيبة |
| `option_stock_unlimited` | 422  | مخزون الخيار غير محدود حاليًا: `set` دون `"unlimited": false`، أو أي `delta`                                                                                                                                              |
| `stock_mode_mismatch`    | 409  | `target` لا يطابق وضع مخزون المنتج                                                                                                                                                                                        |
| `not_found`              | 404  | المنتج ليس في متجرك، أو الخيار أو التركيبة ليست من هذا المنتج                                                                                                                                                             |

## `GET /v1/products/{id}/offers`[​](#get-v1productsidoffers "رابط مباشر إلى get-v1productsidoffers")

يقرأ عروض الكمية للمنتج، أي الحزم التي تظهر في صفحة المنتج.

**المصادقة:** مفتاح منصة بصلاحية `products:read`.

```
curl https://api.dzbuild.app/v1/products/26/offers \

  -H "Authorization: Bearer $DZ_KEY"
```

```
{

  "data": {

    "product_id": 26,

    "pricing_note": "price is the TOTAL for the whole bundle of `quantity` units, not a unit price.",

    "offers": [

      { "id": 51, "title": "Buy 2, get 1 free", "quantity": 3, "price": 2000,

        "compare_price": 3000, "discount_type": null, "discount_value": null,

        "badge_text": "Best value", "badge_color": "#10b981", "free_shipping": true,

        "image_path": null, "image_url": null, "sort_order": 0, "is_active": true },

      { "id": 52, "title": "Pack of 2", "quantity": 2, "price": 0,

        "compare_price": null, "discount_type": "percent", "discount_value": 10,

        "badge_text": null, "badge_color": "#10b981", "free_shipping": false,

        "image_path": null, "image_url": null, "sort_order": 1, "is_active": true }

    ]

  }

}
```

`price` هو سعر الحزمة كاملة

`price` هو ما يدفعه الزبون مقابل كل وحدات `quantity` معًا، وليس سعر الوحدة. على منتج بـ 1000 دج، عرض «اشترِ 2 والثالثة مجانًا» يكون `"quantity": 3, "price": 2000`. والعرض الذي يحمل `discount_type` يُخزَّن فيه `price` بـ `0` ويُطرح `discount_value` من سعر المنتج مضروبًا في الكمية: `amount` يطرح مبلغًا بالدينار، و`percent` يطرح نسبة مئوية ويطبّقها أيضًا على فروق أسعار المتغيرات. العرض الثاني أعلاه يبيع وحدتين بـ 1800 دج.

`image_url` هو رابط CDN الكامل لصورة العرض، و`null` إن لم تكن له صورة.

## `POST /v1/products/{id}/offers`[​](#post-v1productsidoffers "رابط مباشر إلى post-v1productsidoffers")

يستبدل عروض الكمية للمنتج. **المصادقة:** مفتاح منصة بصلاحية `products:write`. **يتطلب `Idempotency-Key`.**

هذا يستبدل كل عروض المنتج

أرسل كل عرض تريد الاحتفاظ به، بالترتيب الذي تظهر به العروض. كل ما تحذفه من الحمولة يُحذف فعليًا. أرسل `{"offers": []}` لإزالة كل العروض.

### الجسم[​](#الجسم-4 "رابط مباشر إلى الجسم")

| الحقل                     | النوع                         | إلزامي                 | ملاحظات                                                                |
| ------------------------- | ----------------------------- | ---------------------- | ---------------------------------------------------------------------- |
| `offers`                  | array (من 0 إلى 50)           | ✅                     | العروض بالترتيب الذي تظهر به. و`[]` تزيل كل العروض.                    |
| `offers[].title`          | string                        | ✅                     | يُقصّ إلى 255 حرفًا                                                    |
| `offers[].quantity`       | int، من 1 إلى 9999            | ✅                     | عدد الوحدات في الحزمة. يحتاج كل عرض كمية مختلفة.                       |
| `offers[].price`          | number > 0                    | ✅ دون `discount_type` | السعر الإجمالي للحزمة كلها، بالدينار. يُتجاهل عند ضبط `discount_type`. |
| `offers[].discount_type`  | `amount` \| `percent` \| null |                        | الافتراضي `null` (سعر ثابت للحزمة)                                     |
| `offers[].discount_value` | number > 0                    | ✅ مع `discount_type`  | بالدينار مع `amount`، و100 كحد أقصى مع `percent`                       |
| `offers[].compare_price`  | number ≥ 0 \| null            |                        | السعر المشطوب، بالدينار                                                |
| `offers[].badge_text`     | string                        |                        | يُقصّ إلى 100 حرف                                                      |
| `offers[].badge_color`    | `#rgb` أو `#rrggbb`           |                        | الافتراضي `#10b981`                                                    |
| `offers[].free_shipping`  | bool                          |                        | التوصيل مجاني عندما يطلب الزبون هذا العرض. الافتراضي `false`.          |
| `offers[].image_path`     | string                        |                        | يحتفظ بصورة العرض: أعد إرسال `image_path` الذي أرجعه GET               |
| `offers[].is_active`      | bool                          |                        | الافتراضي `true`                                                       |

لا يمكن رفع صور العروض عبر الـ API: أضفها من لوحة التحكم. والصورة التي لا تعيد إرسال `image_path` الخاص بها تُحذف.

```
curl -X POST 'https://api.dzbuild.app/v1/products/26/offers' \

  -H "Authorization: Bearer $DZ_KEY" -H "Content-Type: application/json" \

  -H "Idempotency-Key: $(uuidgen)" \

  -d '{

    "offers": [

      { "title": "Buy 2, get 1 free", "quantity": 3, "price": 2000,

        "compare_price": 3000, "badge_text": "Best value", "free_shipping": true },

      { "title": "Pack of 2", "quantity": 2, "discount_type": "percent", "discount_value": 10 }

    ]

  }'
```

تُرجع `200` مع العروض الجديدة، بنفس شكل GET أعلاه. والحمولة المرفوضة تترك عروضك الحالية دون أي مساس.

### الأخطاء[​](#الأخطاء-4 "رابط مباشر إلى الأخطاء")

| الكود                      | HTTP | السبب                                                                                                                                                                                     |
| -------------------------- | ---- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `validation_error`         | 422  | `offers` غائبة أو ليست قائمة، أو أكثر من 50 عرضًا، أو `title` غائب، أو قيمة غير صالحة في `quantity` أو `price` أو `discount_type` أو `discount_value` أو `compare_price` أو `badge_color` |
| `duplicate_offer_quantity` | 422  | عرضان بنفس `quantity`                                                                                                                                                                     |
| `invalid_image_path`       | 422  | `image_path` ليس صورة تستعملها عروض هذا المنتج حاليًا                                                                                                                                     |
| `not_found`                | 404  | المنتج ليس في متجرك                                                                                                                                                                       |

## `GET /v1/products/{id}/addons`[​](#get-v1productsidaddons "رابط مباشر إلى get-v1productsidaddons")

يقرأ حقول إدخال الزبون للمنتج: حقول إضافية يملؤها الزبون في صفحة المنتج (نص قصير أو نص طويل أو رفع صورة)، ومفتاح `enabled` الذي يُظهرها أو يُخفيها.

**المصادقة:** مفتاح منصة بصلاحية `products:read`.

```
curl https://api.dzbuild.app/v1/products/26/addons \

  -H "Authorization: Bearer $DZ_KEY"
```

```
{

  "data": {

    "product_id": 26,

    "enabled": true,

    "addons": [

      { "id": 7, "title": "Name to print", "input_type": "text",

        "placeholder": "Up to 20 letters", "is_required": true, "extra_price": 300,

        "max_length": 20, "allowed_extensions": null, "sort_order": 0, "is_active": true },

      { "id": 8, "title": "Your photo", "input_type": "image",

        "placeholder": null, "is_required": false, "extra_price": 0,

        "max_length": null, "allowed_extensions": "jpg,png", "sort_order": 1, "is_active": true }

    ]

  }

}
```

لا تُظهر صفحة المنتج الحقول إلا ما دام `enabled` يساوي `true`، ولا تُظهر إلا الحقول التي تحمل `"is_active": true`. ويُضاف `extra_price` إلى الطلب عندما يملأ الزبون ذلك الحقل.

## `POST /v1/products/{id}/addons`[​](#post-v1productsidaddons "رابط مباشر إلى post-v1productsidaddons")

يستبدل حقول إدخال الزبون للمنتج، ويمكنه ضبط مفتاح `enabled` في النداء نفسه. **المصادقة:** مفتاح منصة بصلاحية `products:write`. **يتطلب `Idempotency-Key`.**

هذا يستبدل كل حقول الإدخال في المنتج

أرسل كل حقل تريد الاحتفاظ به، بالترتيب الذي تظهر به الحقول. كل ما تحذفه من الحمولة يُحذف فعليًا. أرسل `{"addons": []}` لإزالة كل الحقول.

### الجسم[​](#الجسم-5 "رابط مباشر إلى الجسم")

| الحقل                         | النوع                           | إلزامي | ملاحظات                                                                                      |
| ----------------------------- | ------------------------------- | ------ | -------------------------------------------------------------------------------------------- |
| `enabled`                     | bool \| null                    |        | `true` يُظهر الحقول في صفحة المنتج، و`false` يُخفيها. غيابه أو `null` يترك المفتاح على حاله. |
| `addons`                      | array (من 0 إلى 20)             | ✅     | الحقول بالترتيب الذي تظهر به. و`[]` تزيل كل الحقول.                                          |
| `addons[].title`              | string                          | ✅     | يُقصّ إلى 255 حرفًا                                                                          |
| `addons[].input_type`         | `text` \| `textarea` \| `image` |        | الافتراضي `text`. و`textarea` نص طويل، و`image` رفع صورة.                                    |
| `addons[].placeholder`        | string                          |        | يُقصّ إلى 255 حرفًا                                                                          |
| `addons[].is_required`        | bool                            |        | الافتراضي `false`                                                                            |
| `addons[].extra_price`        | number ≥ 0                      |        | مبلغ بالدينار يُضاف عندما يملأ الزبون الحقل. الافتراضي `0`.                                  |
| `addons[].max_length`         | int ≥ 1                         |        | لحقول `text` و`textarea` فقط. حدّه الأقصى 65535.                                             |
| `addons[].allowed_extensions` | قائمة أو نص مفصول بفواصل        |        | لحقول `image` فقط: أيّ من `jpg` و`jpeg` و`png` و`gif` و`webp`. الافتراضي الخمسة كلها.        |
| `addons[].is_active`          | bool                            |        | الافتراضي `true`                                                                             |

```
curl -X POST 'https://api.dzbuild.app/v1/products/26/addons' \

  -H "Authorization: Bearer $DZ_KEY" -H "Content-Type: application/json" \

  -H "Idempotency-Key: $(uuidgen)" \

  -d '{

    "enabled": true,

    "addons": [

      { "title": "Name to print", "placeholder": "Up to 20 letters",

        "is_required": true, "extra_price": 300, "max_length": 20 },

      { "title": "Your photo", "input_type": "image", "allowed_extensions": ["jpg", "png"] }

    ]

  }'
```

تُرجع `200` مع الحقول الجديدة والمفتاح، بنفس شكل GET أعلاه. والحمولة المرفوضة تترك حقولك الحالية دون أي مساس.

### الأخطاء[​](#الأخطاء-5 "رابط مباشر إلى الأخطاء")

| الكود              | HTTP | السبب                                                                                                                                                                                                                                            |
| ------------------ | ---- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `validation_error` | 422  | `addons` غائبة أو ليست قائمة، أو أكثر من 20 حقلًا، أو `title` غائب، أو `input_type` غير معروف، أو قيمة غير صالحة في `extra_price` أو `max_length`، أو `max_length` على حقل `image`، أو `allowed_extensions` على نوع حقل آخر أو بامتداد غير مسموح |
| `not_found`        | 404  | المنتج ليس في متجرك                                                                                                                                                                                                                              |

## `GET /v1/products/{id}/quantity-rules`[​](#get-v1productsidquantity-rules "رابط مباشر إلى get-v1productsidquantity-rules")

يقرأ الحد الأدنى والأقصى لكمية هذا المنتج في الطلب الواحد. و`0` تعني بلا حد.

**المصادقة:** مفتاح منصة بصلاحية `products:read`.

```
curl https://api.dzbuild.app/v1/products/26/quantity-rules \

  -H "Authorization: Bearer $DZ_KEY"
```

```
{

  "data": {

    "product_id": 26,

    "min_qty": 2,

    "max_qty": 10,

    "has_rule": true,

    "addon_id": "min-max-quantity",

    "addon_active": false,

    "warning": "The \"min-max-quantity\" addon is not active for this store, so this rule is stored but NOT enforced at checkout. Activate it from the dashboard Addons page."

  }

}
```

لا تُطبَّق القاعدة عند إرسال الطلب إلا ما دامت إضافة [الحد الأدنى والأقصى للكمية لكل منتج](https://dzbuild.com/ar/ar/docs/addons/min-max-quantity.md) مفعّلة في المتجر. والإضافة متاحة في كل الخطط. يخبرك `addon_active` إن كانت مفعّلة، ويظهر `warning` عندما تُحفظ قاعدة والإضافة معطّلة.

## `POST /v1/products/{id}/quantity-rules`[​](#post-v1productsidquantity-rules "رابط مباشر إلى post-v1productsidquantity-rules")

يضبط الحد الأدنى والأقصى لكمية طلب المنتج. **المصادقة:** مفتاح منصة بصلاحية `products:write`. **يتطلب `Idempotency-Key`.**

### الجسم[​](#الجسم-6 "رابط مباشر إلى الجسم")

| الحقل     | النوع               | إلزامي | ملاحظات                                                                            |
| --------- | ------------------- | ------ | ---------------------------------------------------------------------------------- |
| `min_qty` | int، من 0 إلى 10000 |        | `0` = بلا حد أدنى. غيابه يُحسب `0`.                                                |
| `max_qty` | int، من 0 إلى 10000 |        | `0` = بلا حد أقصى. غيابه يُحسب `0`. وإن كان أكبر من `0` فيجب ألا يقل عن `min_qty`. |

كل نداء يكتب القيمتين معًا، لذا أرسلهما سويًا: الحقل الذي تتركه يصير `0`. وإرسال `0` للاثنين يحذف القاعدة.

```
curl -X POST 'https://api.dzbuild.app/v1/products/26/quantity-rules' \

  -H "Authorization: Bearer $DZ_KEY" -H "Content-Type: application/json" \

  -H "Idempotency-Key: $(uuidgen)" \

  -d '{ "min_qty": 2, "max_qty": 10 }'
```

تُرجع `200` مع القاعدة المحفوظة، بنفس شكل GET أعلاه.

### الأخطاء[​](#الأخطاء-6 "رابط مباشر إلى الأخطاء")

| الكود              | HTTP | السبب                                                                                                                   |
| ------------------ | ---- | ----------------------------------------------------------------------------------------------------------------------- |
| `validation_error` | 422  | قيمة ليست عددًا صحيحًا، أو أقل من 0، أو أكبر من 10000، أو `max_qty` أصغر من `min_qty` (قاعدة كهذه تمنع كل طلبات المنتج) |
| `not_found`        | 404  | المنتج ليس في متجرك                                                                                                     |
