Skip to content

Product Badges (Admin)

Flow ID: AD-25 Module(s): badges, eshop Complexity: Low-Medium Last Updated: 2026-04-04

Business Context

Product badges are visual labels (image + localized text) overlaid on product cards and product pages. They communicate marketing messages such as "New", "Sale", or "Hot" at a glance. Each product can have at most one badge, stored as a foreign key (product_img_type) on the product record. Badge display priority yields to gift rule images when both exist -- gift images take precedence over badges on the storefront.

Architecture

Dual-layer implementation: Legacy HMVC admin panel for badge CRUD and product assignment, plus a modern Domain + REST API for programmatic access.

LayerComponentPath
Legacy ControllerAdv_badgesecommercen/badges/controllers/Adv_badges.php
Legacy ModelAdv_badges_modelecommercen/badges/models/Adv_badges_model.php
Admin Viewslist, create, updateapplication/views/admin/badges/
Domain EntityEntity, MuiEntitysrc/Domains/Product/Badge/Repository/
Domain ServiceService (read), WriteService (write)src/Domains/Product/Badge/
REST ControllerBadgesrc/Rest/Product/Controllers/Badge.php
REST ResourcesResource, Collection, MuiResource, MuiCollectionsrc/Rest/Product/Resources/Badge/
DI RegistrationDomain container, REST containersrc/Domains/Product/container.php, src/Rest/Product/container.php
Integration TestsServiceTest, RepositoryTesttests/Integration/Domains/Product/Badge/

Database Schema

shop_product_badges (master)

ColumnTypeConstraints
idint(11)PK, AUTO_INCREMENT
namevarchar(100)NOT NULL
badgevarchar(250)NOT NULL (image filename)

shop_product_badges_mui (translations)

ColumnTypeConstraints
idint(11)PK, AUTO_INCREMENT
badge_idint(11)NOT NULL, FK to shop_product_badges.id
badge_texttextDEFAULT NULL
langvarchar(2)NOT NULL

Indexes: badge_id, badge_id + lang composite.

Product linkage: shop_product.product_img_type (indexed) references shop_product_badges.id. This is a soft FK -- no database-level constraint.

Admin Controller (Legacy)

Controller: Adv_badges extends Admin_c

Authorization: AUTH_ROLE_ADVISABLE, AUTH_ROLE_ADMIN, AUTH_ROLE_MEDIA, AUTH_ROLE_PRODUCTS

MethodRouteDescription
index()badgesList all badges with MUI text for current admin language
add()badges/addCreate badge: name + image upload + per-language badge_text
edit($id)badges/edit/{id}Update badge: modify name, replace image, update translations
delete($id)badges/delete/{id}Delete badge (blocked if assigned to products)
remove_badge_from_products($id)badges/remove_badge_from_products/{id}Clear badge from all assigned products, report affected count

Routes (from application/config/routes.php):

badges → badges
badges/(.+) → badges/$1
(\w{2})/badges → badges         (language-prefixed)
(\w{2})/badges/(.+) → badges/$2

Admin Views

ViewPurpose
list.phpTable with ID, name, badge_text, badge image (80px height). Actions: remove-from-products (eraser icon), edit, delete (with JS confirmation). "Add" button top-right. Scribe-how tutorial integration.
create.phpForm: name text input, image file upload, per-language badge_text inputs with language tab switching (mixed_crud_lang_tabs partial). Multipart form encoding.
update.phpForm: pre-populated name, optional image replacement (with "show image" popup link and delete checkbox), per-language badge_text pre-populated from MUI records.

Badge CRUD Flow

Create:

  1. Admin submits form with name, badges_image file, per-language badge_text_{lang} fields.
  2. Advuploader library handles file upload to files/badges/ directory.
  3. If upload succeeds: model inserts master record, then one MUI row per admin language.
  4. afterAdd($id) hook fires (empty by default, overridable in client repos).
  5. Redirects to badge list.

Update:

  1. Admin submits form. Image upload is optional (only processed if a new file is provided).
  2. If new image uploaded: badge column updated. If not: existing image preserved.
  3. MUI records updated via updateOrInsertMui() (upsert pattern from Adv_base_model).
  4. afterEdit($id) hook fires.

Delete:

  1. canDeleteRecord($id) checks if any product has product_img_type = $id.
  2. If products reference this badge: deletion blocked, error flashed (badges.error.used_in_products).
  3. If no products reference it: deletes master + MUI rows, then clears product_img_type on any product (safety cleanup).
  4. afterDelete($id) hook fires.

Remove from products:

  1. Sets product_img_type = NULL on all products referencing the badge.
  2. Returns affected row count via success flash message.

Product-Badge Assignment

Badges are assigned to products through the product edit form, not through the badge admin. The product admin controller (Adv_products_admin) loads badges_combo (dropdown of badge names) for the create/edit/clone views. The product_img_type field is saved as part of the product record.

Assignment points:

ContextHow badge dropdown is loadedView
Product createbadges_model->dropdownName()admin/products/create.php
Product editbadges_model->dropdownName()admin/products/update.php
Product clonebadges_model->dropdownName()admin/products/clone.php
ERP product addbadges_model->dropdownName()admin/products/add_erp_product.php
New product createbadges_model->dropdownName()admin/products/new_products_create.php

Batch Badge Assignment (Mass Action)

The product list admin supports a bulk action put_icon to assign/remove badges from multiple selected products at once.

  1. Admin selects products via checkboxes on the product list page.
  2. Selects "put_icon" from the Marketing optgroup in the batch action dropdown.
  3. A badge dropdown appears (loaded via badges_model->dropdownName(true) with a "None" option).
  4. Selecting "None" clears the badge; selecting a badge ID assigns it.
  5. Executes product_model->update_batch_icons($products, $iconId) which runs UPDATE shop_product SET product_img_type = ? WHERE id IN (...).

Product List Search/Filter by Badge

The admin product list includes a multiselect filter for badges:

  • Dropdown: badges_model->dropDownSearchProducts(true) (includes "All" option with value 0).
  • Search condition: WHERE shop_product.product_img_type IN (...) added to the product query.
  • Badge images also displayed in the product list table rows via badgesImages lookup map.

Storefront Display

Badges are rendered on two surfaces:

Product cards (listing pages):

  1. Adv_front_controller::renderBadges() loads productBadges (id => filename map) and productBadgesText (id => text map) via pscache (cached model calls).
  2. getTheBadge($product, $productBadges, $productBadgesText) in application/helpers/main_theme_helper.php resolves the display badge:
    • First checks for gift rule images (product-level, then vendor-level). If found, returns badge_type = 'gift'.
    • Falls back to product badge if product_img_type is set. Returns badge_type = 'badge'.
  3. badgeImage() helper resolves CSS classes and image src differently for gift vs badge types.
  4. Badge image rendered via html_picture() with responsive from_w600 srcset profile.

Product page:

  1. Same getTheBadge() resolution.
  2. Badge displayed in a card below the gallery: image on left, badge_text on right (if non-empty).
  3. Default theme view: application/views/default/layouts/library/product_page/product.php.

Vue/JSON state: Adv_front_controller also populates jsonState.productBadges for Vue components.

Model Helper Methods

MethodReturnsUsed By
getAdminList()All badges with MUI text for current languageList view, dropdown helpers, pscache
getAdminRecord($id)Badge master + all MUI rows keyed by langEdit view
getRecord($id)Single badge with MUI text for current languageGeneral lookup
dropdownImage()[id => filename] mapProduct list badge column, storefront rendering
dropdownText()[id => badge_text] mapStorefront badge text display
dropdownName($allowNone)[id => name] map, optional "None" entryProduct edit forms, batch action
dropDownSearchProducts($allowNone)[id => name] map, optional "All" entry with 0 keyProduct list search filter
canDeleteRecord($id)boolDelete guard
removeBadgeFromProducts($id)int (affected rows)Remove-from-products action, post-delete cleanup

REST API

Base path: /rest/product/badge

Authentication: Backend JWT. Roles: AUTH_ROLE_ADMIN, AUTH_ROLE_PRODUCTS, AUTH_ROLE_MEDIA.

MethodPathActionDescription
GET/rest/product/badgeindex()Paginated collection with filter/sort
GET/rest/product/badge/itemitem()Single entity by filter criteria
GET/rest/product/badge/{id}show($id)Single entity by ID
POST/rest/product/badgestore()Create badge (JSON or multipart with file)
POST/rest/product/badge/{id}update($id)Update badge (JSON or multipart with file)
DELETE/rest/product/badge/{id}destroy($id)Delete badge + MUI records

Language-prefixed routes also available: /{lang}/rest/product/badge/...

Filters: id (exact), name (partial), badge (partial), badgeText.{locale} (partial).

Sorts: id, name, badgeText.{locale}.

Relations: translations (one-to-many, shop_product_badges_mui).

File uploads: The controller uses HandlesUploadActions trait with UploadFieldConfig('files/badges/') for the badge field. Accepts both application/json (filename only) and multipart/form-data (with file binary).

Domain Layer (Modern)

Read path: Service -> Repository -> Entity

  • Service::all(ListRequest) — paginated collection with Specification pattern (Filter, Sort, Pagination, WithRelations).
  • Service::get($id, $relations) — single entity with optional relation loading.
  • Service::item(ListRequest) — single entity by filter.
  • Entity implements FilterTranslation for cross-table filtering.

Write path: WriteService -> WriteRepository + MuiWriteRepository

  • WriteService::create($data) — validates via Validator, inserts master + translations in transaction.
  • WriteService::update($id, $data) — validates, updates master (null fields excluded), replaces translations (delete + re-insert).
  • WriteService::delete($id) — deletes MUI first, then master, in transaction.

Validation (Validator):

  • Create: name required and non-empty.
  • Update: name cannot be empty string (but can be omitted/null for partial update).
  • Translations: each entry must have lang field.

WriteData: name (string|null), badge (string|null). OpenAPI schema Badge.

MuiWriteData: lang (string), badgeText (string|null). Accepts both badge_text and badgeText keys in input. OpenAPI schema BadgeTranslation.

Resource Transformation

BadgeResource output:

json
{
  "id": 1,
  "name": "New Product",
  "badge": "/files/badges/new-product.png",
  "translations": [...]
}

The badge field is formatted via formatFile() to prepend the /files/badges/ path prefix.

BadgeMuiResource output:

json
{
  "id": 1,
  "badgeId": 1,
  "badgeText": "New Product",
  "lang": "el"
}

Product Resource integration: The Product REST resource includes badge as an optional relation (addResourceToData), and exposes productImgType (integer) as a direct field.

DI Container Registration

Domain (src/Domains/Product/container.php):

  • Badge\Repository\Repository
  • Badge\Repository\RepositoryConfigurator (defines translations relation: one-to-many via badge_id)
  • Badge\Repository\MuiRepository (with NullRelationConfigurator)
  • Badge\Service
  • Badge\Repository\WriteRepository
  • Badge\Repository\MuiWriteRepository
  • Badge\Validator
  • Badge\WriteService

REST (src/Rest/Product/container.php):

  • Badge controller with injected: Badge\Service, Badge\Resource, Badge\Collection, Badge\ListRequest, Badge\WriteService, UploadService.

Client Extensibility

The legacy controller provides three empty hook methods for client repo overrides:

  • afterAdd($badgeId) — fires after successful badge creation.
  • afterEdit($badgeId) — fires after successful badge update.
  • afterDelete($badgeId) — fires after successful badge deletion.

Additionally, afterBatchActionSubmitUpdateIcon($products, $iconId) is called on the product admin controller after batch badge assignment.

Client repos can extend Adv_badges in application/controllers/badges.php to override these hooks for custom logic (e.g., cache invalidation, ERP sync, audit logging).

Business Rules

RuleImplementation
One badge per productproduct_img_type is a single FK column on shop_product
Deletion blocked if in usecanDeleteRecord() checks shop_product.product_img_type
Gift images override badgesgetTheBadge() checks gift rules first, falls back to badge
Badge image required on create (legacy)Upload must succeed; error flashed if upload fails
Badge name required (REST)Validator::validateForCreate() enforces non-empty name
MUI translations require langValidator::validateTranslations() enforces lang on each entry
Badge data cached on storefrontpscache wraps model calls in renderBadges()
Batch assignment supports "None"Passing empty icon_id clears product_img_type

Test Coverage

Integration tests in tests/Integration/Domains/Product/Badge/:

RepositoryTest (14 tests): get() by ID, match() with exact/IN filters, matchOne(), count(), sort ascending/descending, pagination (first/second page), relation loading (translations), MUI match by language/badge_id, empty result handling.

ServiceTest (12 tests): all() with pagination, filter, item(), get() with relations. WriteService: create, create with translations, create validation (missing/empty name), update fields, update replaces translations, delete removes badge + MUI, full round-trip lifecycle.