Meridian handbook
Destination platform (engine-dmo)

Promotions

How to declare promotion slots on a site (across the site or per landing page), how members request them and staff approve them, how promoted() and promotionLabel() render paid placements with the label the law requires, and every slot setting, status and check.

engine-dmo

This feature is part of the DMO bundle (engine-dmo).

A promotion slot is a place on a site’s pages where a business’s document can be featured for a period: a home page feature, a featured listing at the top of an index, the first place on a landing page. A site declares its slots; members ask for one from the portal for one of their published documents and a window of dates; staff approve, reject, cancel or extend; and the public site shows each approved promotion while its window holds, always labelled as promoted.

Promotions record what was charged and an invoice reference. Taking payment in the portal is the paid listings module’s.

A minimal slot

config/sites/main/promotions.yaml
label:
  en-GB: Promoted
slots:
  - key: home-feature
    name: { en-GB: Home page feature }
    types: [listing]
    capacity: 2
    days: { min: 7, max: 28 }
    lead_time: 3d
    tiers: { min: gold }
    price: { amount: "90.00", currency: GBP, per: week }
templates/_shared/partials/home-feature.html
{% set items = promoted('home-feature') %}
{% if items %}
<section aria-labelledby="featured-heading">
  <h2 id="featured-heading">{{ t('home.featured') }}</h2>
  <ul>
    {% for item in items %}
    <li data-mrd-promotion="{{ item.promotion.id }}">
      <a href="{{ item.url|url }}">{{ promotionLabel(item) }} {{ item.title }}</a>
    </li>
    {% endfor %}
  </ul>
</section>
{{ impressions() }}
{% endif %}

Run engine-dmo check --fix to write the slot’s id (slot_), then engine-dmo schema plan and schema apply, which records the slot in the database. engine-dmo dev slot add home-feature --types listing --capacity 2 --min-days 7 --max-days 28 --lead-time 3d --min-tier gold --price 90.00 --per week writes the same entry.

The label

Paid placement must be identifiable. promotionLabel(item) prints the site’s label as visible text:

<span class="promotion-label" data-promotion-label id="promotion-<id>-label">Promoted</span>

Print it inside each placement’s link, so it is part of the link’s accessible name. Two checks hold templates to this:

  • engine-dmo check refuses a template that calls promoted() and never calls promotionLabel() (promoted-unlabelled), and a promoted('<slot>') naming a slot the site does not declare (unknown-slot);
  • the accessibility check of a rendered page reports a printed label that is hidden or is not in the accessible name of its placement’s link.

label: in promotions.yaml sets the wording per locale. A locale it leaves out uses the engine’s wording where it has one (Promoted in English, Hyrwyddwyd in Welsh), and Promoted in the site’s default locale otherwise. A label written empty is an error; a label for a locale the site does not enable is warned about.

Slot settings

config/sites/<site>/promotions.yaml:

KeyRequiredDefaultMeaning
labelnothe engine’s wordingThe label per locale
slots[].keyyesLowercase letters, digits and hyphens; unique on the site. What templates name.
slots[].idwritten by check --fixThe stable id (slot_). Promotions are stored against it, so renaming a slot keeps them.
slots[].namenoThe slot’s name per locale, as staff and partners see it
slots[].typesyesDocument types it may promote. Each must be on the site and not a singleton.
slots[].capacityno1How many promotions may be live at once (per page, for a landing slot). At least 1.
slots[].days.minno1The shortest promotion in days
slots[].days.maxnono limitThe longest promotion in days
slots[].lead_timenononeThe earliest a member’s request may start after it is made, such as 3d
slots[].tiersnoevery tier and noneThe tiers whose organisations may request it, as a tier list
slots[].price.amountwith priceA quoted decimal with at most two places, such as "90.00"; a float is refused
slots[].price.currencynothe site’s default_currencyA three-letter ISO 4217 code
slots[].price.pernoplacementweek, day or placement
slots[].scopenositesite: one inventory across the site. landing: one inventory per live landing page.
slots[].landingsnoevery landing with a place over one of the slot’s typesscope: landing only: the landing keys whose pages it sells
slots[].pricesnoscope: landing only: price overrides; see below

The price is shown to the member when they ask and is what paid listings charges; it is not a commitment the engine enforces otherwise. A price per week is charged for each week begun, per day for each day, per placement once.

engine-dmo config schema --file promotions prints the file’s schema.

Slots sold per landing page

A slot with scope: landing sells each live landing page separately: capacity counts per page, a member chooses the page when they ask, and the price can vary by place and category.

config/sites/main/promotions.yaml
slots:
  - key: landing-top
    name: { en-GB: Top of a landing page }
    types: [listing]
    scope: landing
    landings: [by-place]
    capacity: 1
    days: { min: 28 }
    price: { amount: "25.00", per: week }
    prices:
      - { places: [banbury], categories: [stay], amount: "40.00" }
templates/_shared/listing/landing.html (excerpt)
{% for item in promoted('landing-top') %}
<p class="featured" data-mrd-promotion="{{ item.promotion.id }}"><a href="{{ item.url|url }}">{{ promotionLabel(item) }} {{ item.title }}</a></p>
{% endfor %}

On a landing page, promoted('landing-top') reads the page it is on. Elsewhere, pass the page: promoted('landing-top', landing) where landing is a landing context (it reads landing.pageKey). Anywhere else a landing slot shows nothing.

Each entry of prices sets the amount for pages whose place is in places (the place’s path on the site, or its last segment: banbury or cotswolds/banbury) and whose category is in categories (the category value). An empty list matches every place or category. The first match wins; a page no rule matches costs price. prices needs a price, whose currency and unit it uses.

Only landings with a place are sold this way: a slot naming none, or a site with no such landing over the slot’s types, is an error. scope: landing needs the directory module, so engine-cms refuses it.

When a landing page stops being live while one of its promotions is running, the promotion is paused: it stops showing, the business reads why on its timeline, and staff see it on the admin’s attention list to extend or credit. It resumes when the page is live again.

How a promotion proceeds

  1. A member whose role grants promotion.request, at a tier the slot allows, asks from the portal’s promotions screen (/organisations/{org}/promotions) for one of the organisation’s documents that is published on the site and of a type the slot takes, with a start and an end. The form shows the dates already taken. The request must start at least lead_time from now, run between days.min and days.max, and fit the slot’s capacity in that window; a window the approved promotions already fill is refused with the clash named. The promotion is requested.
  2. Staff with promotions.decide on the site work the Promotions queue (under Members). Approving checks capacity again with the slot locked, so two approvals cannot both take its last place, and records the amount, currency and an invoice or order reference if given. Rejecting and cancelling need a reason the business reads.
  3. Every minute the engine moves approved promotions to live when their window starts and to ended when it ends. A promoted document that is unpublished or trashed ends its promotions at once.

Staff can also create an approved promotion directly for a document of an organisation; the slot’s types, length and capacity apply, but not the lead time or tiers. They can extend a booked or live promotion’s end (within capacity, not the maximum length), and a member can withdraw their own request.

stateDiagram-v2
  [*] --> requested : a member asks
  [*] --> approved : staff create one
  requested --> awaiting_payment : checkout started
  awaiting_payment --> requested : order cancelled
  requested --> approved : staff approve
  awaiting_payment --> approved : paid
  requested --> rejected : staff reject
  awaiting_payment --> rejected : staff reject
  requested --> cancelled : withdrawn or cancelled
  awaiting_payment --> cancelled : withdrawn or cancelled
  approved --> live : window starts
  approved --> cancelled : staff cancel
  live --> cancelled : staff cancel
  live --> ended : window ends, or<br/>document unpublished

A paused promotion (its landing page stopped being live) keeps its status and simply stops showing until the page is live again.

StatusMeaning
requestedWaiting for staff
awaiting_paymentOn a site that sells the slot through paid listings at checkout: the partner has started paying. Payment approves it; an abandoned or cancelled order returns it to requested.
approvedBooked; its window has not started
liveShowing now
endedIts window ended, or its document was unpublished
rejectedRefused with a reason
cancelledWithdrawn by the member, or cancelled by staff with a reason

In templates

NameWhat it gives
promoted('<slot>')The documents of the slot’s live promotions, published in the request’s locale and on the site, oldest start first, at most the slot’s capacity
promoted('<slot>', landing)The same for one landing page, for a slot sold per landing page
item.promotionOn each item: id, slot, label, startsAt and endsAt in the site’s timezone
item.organisationIdThe organisation that bought it
promotionLabel(item)The label as a span, described above

Each item is an ordinary entry, so item.title, item.url and the type’s fields are there. A page is never varied by who is signed in: a promotion is the same for every visitor. The live set is cached until the next start or end in the slot, so a cached page shows a promotion from its start and not after its end without a purge.

Mark each placement with data-mrd-promotion="{{ item.promotion.id }}" and print {{ impressions() }} once on the page, and its views are counted as promotion_impression, a figure of its own in the business’s member report that never inflates the listing’s impressions.

Templates shared between sites

check holds every template on a site’s chain to that site’s slots. A template in a shared root that calls promoted('home-feature') is an error on any site sharing it that does not declare home-feature. Declare the slot on each such site (with the types the site has), or move the call into a template under the site’s own root.

Permissions

WhoPermission
Members asking for a slotpromotion.request (partner role), and a tier the slot allows
Staff decidingpromotions.decide on the site (built-in administrator and partnership-manager)

Troubleshooting

MessageFix
slot <key> names no types: say what it may promoteAdd types:.
type <type> is not on site <site>Use a type offered on the site (sites: in its schema file).
write the amount as a quoted decimal such as "150.00", never a floatQuote the amount.
a price needs a currency, and site <site> has no default_currencySet currency: or the site’s default_currency.
landings name the pages a slot sells, which only a slot with scope: landing doesAdd scope: landing or remove landings.
slot <key> is sold per landing page, but no landing with a place on site <site> lists ...Declare a place and category landing on the route of one of the slot’s types.
promoted() renders paid placements and this template never prints promotionLabel()Print {{ promotionLabel(item) }} inside each placement’s link.