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
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
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 } {% 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 checkrefuses a template that callspromoted()and never callspromotionLabel()(promoted-unlabelled), and apromoted('<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:
| Key | Required | Default | Meaning |
|---|---|---|---|
label | no | the engine’s wording | The label per locale |
slots[].key | yes | Lowercase letters, digits and hyphens; unique on the site. What templates name. | |
slots[].id | written by check --fix | The stable id (slot_). Promotions are stored against it, so renaming a slot keeps them. | |
slots[].name | no | The slot’s name per locale, as staff and partners see it | |
slots[].types | yes | Document types it may promote. Each must be on the site and not a singleton. | |
slots[].capacity | no | 1 | How many promotions may be live at once (per page, for a landing slot). At least 1. |
slots[].days.min | no | 1 | The shortest promotion in days |
slots[].days.max | no | no limit | The longest promotion in days |
slots[].lead_time | no | none | The earliest a member’s request may start after it is made, such as 3d |
slots[].tiers | no | every tier and none | The tiers whose organisations may request it, as a tier list |
slots[].price.amount | with price | A quoted decimal with at most two places, such as "90.00"; a float is refused | |
slots[].price.currency | no | the site’s default_currency | A three-letter ISO 4217 code |
slots[].price.per | no | placement | week, day or placement |
slots[].scope | no | site | site: one inventory across the site. landing: one inventory per live landing page. |
slots[].landings | no | every landing with a place over one of the slot’s types | scope: landing only: the landing keys whose pages it sells |
slots[].prices | no | scope: 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.
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" } {% 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
- 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 leastlead_timefrom now, run betweendays.minanddays.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 isrequested. - Staff with
promotions.decideon 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. - Every minute the engine moves approved promotions to
livewhen their window starts and toendedwhen 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.
| Status | Meaning |
|---|---|
requested | Waiting for staff |
awaiting_payment | On 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. |
approved | Booked; its window has not started |
live | Showing now |
ended | Its window ended, or its document was unpublished |
rejected | Refused with a reason |
cancelled | Withdrawn by the member, or cancelled by staff with a reason |
In templates
| Name | What 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.promotion | On each item: id, slot, label, startsAt and endsAt in the site’s timezone |
item.organisationId | The 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
| Who | Permission |
|---|---|
| Members asking for a slot | promotion.request (partner role), and a tier the slot allows |
| Staff deciding | promotions.decide on the site (built-in administrator and partnership-manager) |
Troubleshooting
| Message | Fix |
|---|---|
slot <key> names no types: say what it may promote | Add 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 float | Quote the amount. |
a price needs a currency, and site <site> has no default_currency | Set currency: or the site’s default_currency. |
landings name the pages a slot sells, which only a slot with scope: landing does | Add 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. |
Claims
How a business claims its organisation from the public site, how the claim is verified and scored from weighted signals, how staff review it, what approval does, every claims setting in engine.yaml and the claim commands.
Member reports
What the member report tells a business about its listings, where members read it (portal, member API, PDF, monthly digest), how to configure the digest, the peer comparison and the improvement prompts, scheduled organisation reports for a board, and the report commands.