Appearance
Promotion Management (Admin)
Flow ID: AD-07 Module(s): eshop Complexity: Medium Last Updated: 2026-04-04
Business Context
Promotions are curated product landing pages used for marketing campaigns. Each promotion has MUI content (name, description, footer), visual customization (header/text colors, banner images), SEO metadata, optional page builder block integration, and an ordered collection of assigned products. Promotions serve as themed product groups displayed on the storefront at /promo/{slug} and can also surface as cart-page cross-sell suggestions.
Entry Points
| Type | Path / Trigger | Controller | Method |
|---|---|---|---|
| Admin | List all promos | ecommercen/eshop/controllers/Adv_promo_admin.php | index() |
| Admin | Create promo | same | add() |
| Admin | Edit promo | same | edit($id) |
| Admin | Delete promo | same | delete($id) |
| Admin | Manage products | same | products($promoId) |
| Admin | Remove single product | same | products_delete($promoId, $id) |
| Admin | Batch remove products | same | batchRemoveFromPromo($promoId) |
| Admin | Reorder products | same | update_products_order() (AJAX) |
| Batch | Assign products to promo | Adv_products_admin.php | batchActionSubmit() put_in_promo |
| Batch | Remove products from promo | Adv_products_admin.php | batchActionSubmit() remove_from_promo |
| REST | Full CRUD | src/Rest/Product/Controllers/Promo.php | index, show, item, store, update, destroy |
Authorization
Access restricted to roles: AUTH_ROLE_ADVISABLE, AUTH_ROLE_ADMIN, AUTH_ROLE_MARKETING, AUTH_ROLE_MEDIA.
SEO fields (meta_title, meta_keywords, meta_description, do_follow) require the metatags permission. Slug editing on update requires the urlFields permission.
Admin Menu
Promo admin appears under the MARKETING group in the admin menu (application/config/admin_menu.php), route promo_admin. A separate cache-clear entry exists at advisable/clearcache/promo.
Routes
Admin routes (application/config/routes.php):
promo_admin → eshop/promo_admin/index
promo_admin/(.+) → eshop/promo_admin/$1Storefront routes (same file):
promo/(.+) → eshop/promo/index/$1REST routes (application/config/rest_routes.php):
GET /rest/product/promo → index (collection)
GET /rest/product/promo/item → item (single by filter)
GET /rest/product/promo/{id} → show
POST /rest/product/promo → store (create)
POST /rest/product/promo/{id} → update
DELETE /rest/product/promo/{id} → destroyData Model
Tables
| Table | Purpose | Key Columns |
|---|---|---|
shop_promo | Master record | id, promo_image, promo_small_banner, view_type (tinyint, default 0), header_color, text_color, do_follow (default 1) |
shop_promo_mui | Translations per language | promo_id FK, slug, name, description, footer_description, meta_title, meta_keywords, meta_description, lang, builder_block_id FK |
shop_promo_products | Product assignments | promo_id FK, product_id FK, order (default 99999) |
Indexes
shop_promo_mui:slug,promo_id, composite(promo_id, lang), composite(slug, lang),builder_block_idshop_promo_products:product_id,promo_id, composite(promo_id, product_id),order
Core Operations
Create Promo (add())
- Run
validation()-- form validation rules for all languages - On submit, upload files via
advuploader->uploadFilesSameFolder()tofiles/promo/ - Build master data:
view_type,promo_image,promo_small_banner,header_color,text_color,do_follow(if metatags permission) - Build MUI data per language:
slug(auto-generated viacreateSlug()),name,description,footer_description,builder_block_id, plus SEO fields if permitted - Insert via
promo_model->add_record($data, $data_mui)-- inserts master then loops MUI - Call
afterAdd($id)hook (empty by default, overridable in client repos) - Redirect to listing with success flash
Edit Promo (edit($id))
- Handle image deletion requests (
del_image,del_small_banner) before loading record - Load record via
promo_model->getAdminRecord($id)-- returns['master' => ..., 'details' => [...]] - On submit, same upload + validation flow as create
- Slug handling: if user has
urlFieldspermission and provides a slug, use it; otherwise auto-generate only if no slug exists yet (preserves existing slugs) - Update via
promo_model->update_record($data, $dataMui, $id)-- usesupdateOrInsertMui()for upsert semantics - Call
afterEdit($id)hook - Redirect to listing
Delete Promo (delete($id))
Cascade delete: removes master row from shop_promo, all translations from shop_promo_mui, and all product assignments from shop_promo_products. Calls afterDelete($id) hook.
Manage Products (products($promoId))
Lists all products assigned to the promo with sortable drag-and-drop UI. Query joins shop_promo_products with shop_product and shop_product_mui for product name and image in the current admin language. Products are ordered by shop_promo_products.order ASC.
Actions available:
- Drag-and-drop reorder:
update_products_order()-- AJAX handler using jQuery sortable; updatesordercolumn for each product position - Remove single product:
products_delete($promoId, $id)-- deletes byshop_promo_products.id - Batch remove:
batchRemoveFromPromo($promoId)-- accepts checkbox-selectedproduct_id[]array, deletes matching rows for the promo
Batch Product Assignment (via Products Admin)
From the product listing (Adv_products_admin), batch actions support:
put_in_promo: Callspromo_model->update_batch_product_promo($products, $promoId)-- inserts each product into the promo if not already assigned (duplicate-safe check)remove_from_promo: Callspromo_model->batchRemoveProductPromo($product_ids, $promo_id)-- bulk delete by product IDs within the promo
Both actions have overridable hooks: afterBatchActionSubmitUpdatePromo(), afterBatchActionSubmitRemovePromo().
Validation Rules
| Field | Rule | Notes |
|---|---|---|
view_type | trim, numeric | Layout variant selector |
header_color | trim | Hex color string |
text_color | trim | Hex color string |
name_{lang} | trim, required, is_unique_mui[shop_promo_mui.name...] | Unique per language; on update uses promo_id exclusion |
description_{lang} | trim | Rich text (TinyMCE) |
footer_description_{lang} | trim | Rich text |
meta_title_{lang} | trim | Metatags permission required |
meta_keywords_{lang} | trim | Metatags permission required |
meta_description_{lang} | trim | Metatags permission required |
slug_{lang} | trim | urlFields permission required; only on edit |
View Templates
| View | Path | Purpose |
|---|---|---|
| List | application/views/admin/promo/list.php | Table with ID, image thumbnail, name, storefront URL, view type, actions (edit/products/delete) |
| Create | application/views/admin/promo/create.php | Multipart form with MUI tabs, general info, SEO (collapsible), descriptions (tabbed: main + footer), builder block selector |
| Edit | application/views/admin/promo/update.php | Same as create with pre-populated values |
| Products | application/views/admin/promo/products.php | Sortable product table with image, name, product ID, checkbox bulk select, individual remove |
View Type System
View types control the storefront template used for promo rendering. Configured via helper functions in application/helpers/main_theme_helper.php:
promoViewTypes()-- display labels (e.g.,0 => 'Default')promoViews()-- template file names (e.g.,0 => 'promo')promoViewFullPage()-- whether the view renders as full-page or within the standard layout
Client repos override these functions to add custom promo layouts (e.g., buy-the-look, suncare campaigns).
Builder Block Integration
When the block builder is enabled, each language can have a builder_block_id linking to a reusable content block. The admin form provides a dropdown selector with create/edit/refresh/clear buttons. When a builder block is assigned, the storefront renders the promoBuilder layout instead of the standard promo template.
Modern Domain Layer
Namespace: Advisable\Domains\Product\Promo
Entity (Repository/Entity.php)
Extends BaseEntity, implements FilterTranslation. Properties: id, promo_image, promo_small_banner, view_type, header_color, text_color, do_follow.
MUI Entity (Repository/MuiEntity.php)
Properties: id, promo_id, slug, meta_title, meta_keywords, meta_description, name, description, footer_description, lang, builder_block_id.
Repository Relations (RepositoryConfigurator.php)
php
'translations' => new Relation(Relation::ONE_TO_MANY, MuiRepository::class, 'promo_id'),
'products' => new Relation(Relation::MANY_TO_MANY, Product\Repository\Repository::class,
'promo_id', 'shop_promo_products', 'product_id'),Service (Service.php)
Implements ReadService with Specification pattern: all(ListRequest) for paginated collections, item(ListRequest) for single match, get($id, $relations) for direct fetch.
WriteService (WriteService.php)
Full CRUD with transactional operations:
create(array $data)-- validates viaWriteData/Validator, inserts master + translations, returns entity with translations loadedupdate(int|string $id, array $data)-- partial update; null fields excluded; translations replaced (delete + re-insert)delete(int|string $id)-- removes translations then master in transaction
WriteData / MuiWriteData
Value objects with OpenAPI schema annotations. Accept both snake_case and camelCase field names. WriteData maps: promoImage, promoSmallBanner, viewType, headerColor, textColor, doFollow. MuiWriteData maps: lang, slug, metaTitle, metaKeywords, metaDescription, name, description, footerDescription, builderBlockId.
Validator
Validates lang is present on each translation. Create and update validation hooks exist but are currently open (no additional rules enforced beyond translations).
ListRequest
Allowed filters: id (exact), viewType (exact), name.{locale} (partial), slug.{locale} (exact). Allowed sorts: id, viewType, name.{locale}.
REST API Layer
Namespace: Advisable\Rest\Product\Controllers\Promo
Controller
Extends HandlesRestfulActions, uses HandlesUploadActions trait. Supports file uploads for promo_image and promo_small_banner to files/promo/. Full CRUD with OpenAPI annotations.
Resources
| Class | Schema Name | Description |
|---|---|---|
Resource.php | ProductPromoResource | id, image (formatted file path), smallBanner, viewType, headerColor, textColor, doFollow (bool), translations, products |
Collection.php | ProductPromoCollection | Array of Resource |
MuiResource.php | ProductPromoMuiResource | id, promoId, slug, metaTitle, metaKeywords, metaDescription, name, description, footerDescription, lang, builderBlockId |
MuiCollection.php | ProductPromoMuiCollection | Array of MuiResource |
The Resource class includes relation handling: translations via MuiCollection, products via Product\Collection.
DI Registration
Domain (src/Domains/Product/container.php): Registers Repository, RepositoryConfigurator, MuiRepository (with NullRelationConfigurator), Service, WriteRepository, MuiWriteRepository, Validator, WriteService.
REST (src/Rest/Product/container.php): Registers Promo controller with service, resource/collection classes, ListRequest, WriteService, and UploadService.
Cart-Page Promo Feature
A special promo can be designated as the "cart promo" via config (application/config/main.php):
php
$config['cart_promo_id'] = 64; // Promo ID to pull products from
$config['cart_promo_limit'] = 8; // Max products to showAdv_order::cartPromo() loads this promo's products (randomized, limited) and renders them as cross-sell suggestions on the cart/checkout page.
Integration Tests
Path: tests/Integration/Domains/Product/Promo/
| Test File | Coverage |
|---|---|
RepositoryTest.php | get(), match() with filters/sorts/pagination, matchOne(), count(), relation loading (translations), MuiRepository queries |
ServiceTest.php | all() with pagination, item(), get() with relations, WriteService create/update/delete, translations CRUD, round-trip lifecycle |
Client Extension Points
| Hook Method | Location | Purpose |
|---|---|---|
afterAdd($id) | Adv_promo_admin | Post-create logic (e.g., cache invalidation, sync) |
afterEdit($id) | Adv_promo_admin | Post-update logic |
afterDelete($id) | Adv_promo_admin | Post-delete logic |
afterProductsDelete($id) | Adv_promo_admin | Post product-removal logic |
afterBatchActionSubmitUpdatePromo() | Adv_products_admin | After batch assign to promo |
afterBatchActionSubmitRemovePromo() | Adv_products_admin | After batch remove from promo |
promoViewTypes() | main_theme_helper.php | Custom view type labels |
promoViews() | main_theme_helper.php | Custom template file names |
promoViewFullPage() | main_theme_helper.php | Full-page vs. wrapped layout |
promoExtra() | Adv_promo (front) | Additional sidebar/data for storefront |
Key Files
| File | Purpose |
|---|---|
ecommercen/eshop/controllers/Adv_promo_admin.php | Admin controller (CRUD + product management) |
ecommercen/eshop/models/Adv_promo_model.php | Legacy model (queries, batch operations) |
ecommercen/eshop/controllers/Adv_promo.php | Storefront controller |
application/views/admin/promo/ | Admin view templates (list, create, update, products) |
application/helpers/main_theme_helper.php | View type configuration functions |
src/Domains/Product/Promo/ | Modern domain layer (Entity, Repository, Service, WriteService, Validator, WriteData, MuiWriteData, ListRequest) |
src/Rest/Product/Controllers/Promo.php | REST controller with OpenAPI annotations |
src/Rest/Product/Resources/Promo/ | REST resources (Resource, Collection, MuiResource, MuiCollection) |
src/Domains/Product/container.php | Domain DI registration |
src/Rest/Product/container.php | REST DI registration |
application/config/routes.php | Admin and storefront route definitions |
application/config/rest_routes.php | REST API route definitions |
tests/Integration/Domains/Product/Promo/ | Integration tests (RepositoryTest, ServiceTest) |
Related Flows
- CF-12 Promotions (Storefront) -- customer-facing promo page rendering
- AD-02 Product Management -- batch assign/remove products to promos
- AD-05 Category Management -- categories provide product context for promo product selection
- AD-29 Builder Admin -- builder block integration for rich promo layouts (see Builder guide)
- CF-35 Page Builder -- storefront rendering of builder-powered promo layouts
- CF-26 Home Page -- promos featured on homepage
- SY-23 MUI Translation Pattern --
shop_promo_muicompanion table stores per-language name, slug, descriptions, SEO fields, and builder block ID - SY-25 File Upload & Storage --
advuploader->uploadFilesSameFolder()handlespromo_imageandpromo_small_banneruploads tofiles/promo/