Skip to main content

Directory Theme class reference

92 min read

Coupon Theme — Class reference

The 103 classes that make _cp behave differently from the shared Directory core. Generated from the theme source.

← Back to Coupon Theme features

AdminFeeds AdminFeeds.php​

AdminFeeds — the coupon feed manager (_cp). A themed admin screen (rendered in the theme Chrome, like Admin\Bids / _ht\AdminReservations) listing every coupon feed the site pulls from, with a dry-run preview that fetches and parses a feed WITHOUT writing anything. Registered only under the Coupon profile (activeFork '_cp'); the nav entry is added in Admin\Chrome::sections() under the same gate. A feed here is ONE PER NETWORK, not one per merchant — that is the shape the affiliate networks actually publish (a single Awin or Rakuten response carries hundreds of advertisers), and it inverts the comparison theme's model, where a feed is configured against a merchant that already exists. Merchants are therefore DISCOVERED from feed rows rather than configured ahead of them, and each becomes a listing_store term ({@see StoreTax}) that the Stores screen then owns — the feed supplies the name, a human supplies the logo and blurb. The preview counts how many of a feed's merchants already exist as stores so that cost is visible up front. Fetching and parsing reuse the comparison pipeline wholesale ({@see \Ppt\Compare\FeedFetcher} streams a large, possibly gzipped file to disk; {@see \Ppt\Compare\FeedParser} pulls rows one at a time). Only the field mapping is coupon-specific, and that lives in {@see FeedNetworks}. Preview only, deliberately: the write path (upsert on source id, merchant find-or-create, and the tombstone step that expires codes which drop out of a feed) is the next phase and belongs with Compare\Ingest's run-logging, not bolted onto a screen. Nothing here creates, edits or deletes a listing.

API
register()handleSectors()handlePreviewStep()handleStep()handleLookup()menu()all()get()save()delete()resolveSource()resolveFeedSource()parseRows()rowLabel()sourceLabel()preview()previewBatch()handleSave()handleDelete()handlePreview()handleImport()render()
inc/_cp/AdminFeeds.php

AwinApi AwinApi.php​

AwinApi — the live Awin Promotions API as a feed source (_cp). Every other source this screen handles is a file you can download: a URL goes to download_url(), bytes come back, {@see \Ppt\Compare\FeedParser} reads them. Awin's promotions endpoint is not that. It is a POST, it carries a JSON filter body, it wants a bearer token in a header, and it hands back one page at a time — none of which a GET fetcher can express. Rather than teach the whole pipeline about HTTP verbs, this walks the pages itself and writes the concatenated result to a temp file in exactly the shape the sample feed already has. Downstream nothing changes: the same json parser reads it, the same field map in {@see FeedNetworks} reads the records, the same upsert and the same tombstone run. The API becomes a file, and the file is the thing the pipeline already knows how to eat. publisher in the path is SINGULAR. Awin's transactions endpoints use publishers plural, which is a trap worth stating rather than rediscovering: the plural form returns a 404 that reads like a bad token.

API
isLive()fetch()accounts()sectorCounts()advertiserIdsForSectors()programmeDetails()fetchProgrammes()eachRecord()fetchPages()eachProgramme()
inc/_cp/AwinApi.php

BalmCard BalmCard.php​

Balm's own coupon card (Health & Beauty Coupons, Coupon fork). The row from Balm's home-page code stack, lifted out of the block so the same object is drawn on the search page, on a store page and inside the block itself. It is the widest of the three Health & Beauty cards: a bordered left panel (monogram, coupon kind, and the use-count as a badge), the body, and a right panel holding the code and its button. TWO ELEMENTS OF THE APPROVED MOCKUP ARE DELIBERATELY ABSENT, because a live coupon cannot supply either and a home-page-only flourish that vanishes in search defeats the point of one shared card: - the per-row description paragraph. {@see CardData} carries no description, from a post or from block fields. - the "96% worked" success percentage. Nothing derives it. The badge SHAPE is kept and filled from note/proof — the use-count, which {@see \Ppt_cp\Redeem} does supply — so the left panel still closes on a figure. The CSS is scoped to the card's own root class, never to the block section, because the same rules have to hold on a search page where that section is absent.

API
render()css()
_cp/Cards/BalmCard.php

BellhopBox BellhopBox.php​

Bellhop's own search box (Travel & Hotels Coupons, Coupon fork). Coupon browse pages carry no heading strip, so without this a design has no search of its own over its own results. This is the masthead's field put back at the top of the results it filters, in Bellhop's hand. Store-aware: inside a store it says so, and the store term rides along in the hidden fields so the search stays where the visitor is.

API
render()css()
_cp/Cards/BellhopBox.php

BellhopCard BellhopCard.php​

Bellhop's own coupon card (Travel & Hotels Coupons, Coupon fork). The row from Bellhop's home-page code stack, lifted out of the block so the same object is drawn on the search page, on a store page and inside the block itself. The lead column is a monogram ROUNDEL — the shape the approved mockup carries — showing the store's logo when it has one and its initials when it does not. The discount is therefore not shown as its own element: the offer line carries it, so a live coupon that states its figure in another currency cannot contradict a second figure beside it. The CSS is scoped to the card's own root class, never to the block section, because the same rules have to hold on a search page where that section is absent.

API
render()css()
_cp/Cards/BellhopCard.php

BiscuitCard BiscuitCard.php​

Biscuit's own coupon card (Pets & Pet Care Coupons, Coupon fork). The row from Biscuit's "Latest coupons" stack, lifted out of the block so the same object is drawn on the search page, on a store page and inside the block itself: a 12px-rounded white card with a green-tint monogram and an oat kind chip on the left, the store in green over a bold grotesque title, an expiry / checked / used line, an optional description, and a rounded "Show code" button with a dashed code stub under it (deals get a charcoal button). The CSS is scoped to the card's own root class, never to the block section, because the same rules have to hold on a search page where that section is absent.

API
render()css()
_cp/Cards/BiscuitCard.php

BreezeBox BreezeBox.php​

Breeze's own search box (Summer Coupons, Coupon fork). Coupon browse pages carry no heading strip, so without this a design has no search of its own over its own results. This is the masthead's field put back at the top of the results it filters, in Breeze's hand. Store-aware: inside a store it says so, and the store term rides along in the hidden fields so the search stays where the visitor is.

API
render()css()
_cp/Cards/BreezeBox.php

BreezeCard BreezeCard.php​

Breeze's own coupon card (Summer Coupons, Coupon fork). The row from Breeze's home-page code stack, lifted out of the block so the same object is drawn on the search page, on a store page and inside the block itself. Its shape is the one the approved mockup replicated from the reference: the store's LOGO in the left column where other coupon designs put a discount figure, then the store name, the offer line and the trust row, with the reveal button on the right. The discount figure is therefore not shown as its own element at all: on this design the offer line carries it ("20% off garden furniture"), exactly as the reference does. An earlier pass printed fig_big as a pill before the title when the title did not repeat it, which duplicated the discount whenever a live coupon stated it in another currency - "£50" beside "$50 off orders over $299". The card omits rather than repeats. The CSS is scoped to the card's own root class, never to the block section, because the same rules have to hold on a search page where that section is absent.

API
render()css()
_cp/Cards/BreezeCard.php

ByteCard ByteCard.php​

Byte's own coupon card (Tech & Electronics Coupons, Coupon fork). The centred logo card from Byte's "Top recommended deals" grid, lifted out of the block so the same object is drawn on the search page, on a store page and inside the block itself. Top row: the end date on the left, the usage count on the right. Then the store's round MONOGRAM (or its logo), the store name in teal small caps, the offer, the checked line, a pill button and, when the store has a page, a small "All <store> coupons" link. The figure is not drawn separately — a live coupon's title already carries it — so home page and search show the same card. The CSS is scoped to the card's own root class, never to the block section, because the same rules have to hold on a search page where that section is absent.

API
render()css()
_cp/Cards/ByteCard.php

CabanaBox CabanaBox.php​

Cabana's own search box (Summer Coupons, Coupon fork). Coupon browse pages carry no heading strip, so without this a design has no search of its own over its own results. This is the masthead's field put back at the top of the results it filters, in Cabana's hand. Store-aware: inside a store it says so, and the store term rides along in the hidden fields so the search stays where the visitor is.

API
render()css()
_cp/Cards/CabanaBox.php

CabanaCard CabanaCard.php​

Cabana's own coupon card (Summer Coupons, Coupon fork). The row from Cabana's home-page code stack, lifted out of the block so the same object is drawn on the search page, on a store page and inside the block itself. Its shape is the one the approved mockup replicated from the reference: the store's LOGO in the left column where other coupon designs put a discount figure, then the store name, the offer line and the trust row, with the reveal button on the right. The discount figure is therefore not shown as its own element at all: on this design the offer line carries it ("20% off garden furniture"), exactly as the reference does. An earlier pass printed fig_big as a pill before the title when the title did not repeat it, which duplicated the discount whenever a live coupon stated it in another currency - "£50" beside "$50 off orders over $299". The card omits rather than repeats. The CSS is scoped to the card's own root class, never to the block section, because the same rules have to hold on a search page where that section is absent.

API
render()css()
_cp/Cards/CabanaCard.php

CardData CardData.php​

The normalised coupon card — the one shape every design's card renderer reads. Two producers, one shape. That is the whole point: a design writes its card markup once and it serves the search page, the store page and its own home-page code list, instead of the fork drawing one card on search and the design drawing a different one on the home page. fromPost() a real listing → live data, a post id, reveal-in-place buttons fromFields() a block's flat item{N}_* fields → curated copy, builder field markers, plain links (there is no post to count a reveal against) The figure / checked / ends / peek logic lives here rather than in a card renderer because it is editorial policy, not styling: what counts as a readable discount, what a trust line is allowed to claim, how much of a code may be shown for free. A new design inherits all of it by calling fromPost(); it only decides where the values sit on the card.

API
blank()fromPost()fromFields()figure()checkedLabel()endsLabel()peek()extras()
_cp/Cards/CardData.php

Cards Cards.php​

Which card the active coupon design draws, and the shared plumbing around it. Every coupon design owns its own search card — the results card AND the search box above them — by implementing {@see \Ppt\Designs\Contracts\HasSearchCard}. This is the one place that asks the design which classes those are, and the one place that decides what happens when it does not answer: results card → the design's, else {@see DefaultCard} (the fork's own deal row) search box → the design's, else none (coupon browse pages carried none before) So a design added tomorrow gets its own card by declaring two class names, and a design that declares nothing renders exactly what it renders today. Neither the search template nor the design's home-page block knows which renderer won. Three things are shared rather than left to each design, because they are page mechanics rather than looks: • the results-grid rules — Bootstrap's row-cols sizes every direct child, so a full-width card has to opt out of the column width whichever design drew it; • the redeem pop-up ({@see RedeemModal}) — one delegated listener firing the same nonce-checked {@see \Ppt_cp\Redeem::AJAX} beacon the single page uses, so a reveal counts identically wherever it happens, including on AJAX-refreshed rows; • the "did it work?" prompt the pop-up asks, which feeds the store page's live feed ({@see \Ppt_cp\CodeHistory}); • the once-per-request printing of all of the above. A design's card opts into the pop-up by echoing {@see RedeemModal::attrs()} on its action element and carrying data-ppt-deal="" on its root. Nothing else is required of it — the pop-up is drawn from the attributes in that blob, so every design collects the signal and a design written later collects it for free.

API
register()printModalAssets()reset()card()box()renderPost()renderFields()renderCard()printBox()boxContext()assets()modalCss()modalJs()reportCss()
_cp/Cards/Cards.php

CategoryArt CategoryArt.php​

CategoryArt — a live picture for a coupon site's category tiles (_cp). The three coupon designs whose category row is a PHOTO row (Tally, Perk, Pleat) ship a square shot per tile: folded jeans for Fashion, a laptop for Tech. On a live site {@see \Ppt\Blocks\Support\Categories::autoFill()} already replaces the tile's name, count and link with the site's real listing_category terms — but not the photo, because its only two picture sources are an admin-chosen term image and the newest listing's own photo, and a coupon site has NEITHER: a code imported from an affiliate feed carries no attachment at all. The tile therefore ended up showing a photograph of jeans captioned "Health & Beauty". A coupon site does have one real, plentiful picture: the logos of the shops whose codes are filed under that category. 1,282 of the 1,303 stores on the reference install resolve one. So a tile with no photo of its own is filled with a mosaic of the logos of the biggest shops in that category — which is also what the tile is actually promising ("284 codes in Fashion" = these brands). The fallback chain, most specific first: 1. an admin-chosen category image → used as the photo, untouched (Categories); 2. the newest listing's own photo → used as the photo, untouched (Categories); 3. store logos in that category → this class, a mosaic; 4. nothing at all → the design's own art stays, so a brand-new coupon site still has a presentable home page. Same gates as its siblings {@see LiveCodes} and {@see LiveGuides}: it runs from Blocks\Renderer via LiveCodes::apply(), so the showroom, the demo-default preview and the builder canvas all keep their curated photography. Escape hatch: ppt_cp_category_art (per term, return an array of URLs or [] to switch it off).

API
apply()logos()mosaic()css()
inc/_cp/CategoryArt.php

CategoryIcons CategoryIcons.php​

CategoryIcons — a retail glyph for a live category tile (_cp). The icon-tile category rows (Breeze, Sorbet, Cabana, Loom, Offpeak, Bellhop, Waypoint) are the icon twin of the problem {@see CategoryArt} solves for photos. Support\Categories::autoFill() gives every tile the site's real term name, count and link, but it only writes item{N}_icon when the term has an ADMIN-CHOSEN icon — and a site imported from a feed has none. The design's own glyph therefore survived beside a category it knows nothing about: a swimsuit captioned "Health & Beauty", a barbecue captioned "Clothing", a ferry captioned "Pharmaceuticals". Two things make this harder than the photo case: 1. Each block ships its OWN small icon set (Breeze has swim/sun/grill/plane/…), not the shared 138-glyph library — and that library is travel and lifestyle vocabulary anyway, with no beauty, health, clothing, electronics or pharmacy glyph in it. There was nothing to match a real retail taxonomy against. 2. On a miss, four of the blocks fell back to reset(self::ICONS) — the FIRST icon in their own set — and three rendered nothing at all. So an owner who set a term icon by hand got every tile wearing the same wrong glyph, which is worse than not setting one. So this class carries a retail vocabulary of its own, matches a live category name to it, and gives the blocks one lookup that can render a key they do not own. Contract, as ever: sample contexts keep the design's curated glyph untouched (the pass runs from LiveCodes, behind PreviewMode); a term with an admin-chosen icon keeps it; a live term with no icon gets the matched retail glyph; anything unmatched gets the neutral price tag rather than a confident wrong picture. Escape hatch: ppt_cp_category_icon (per term name).

API
apply()forName()svg()
inc/_cp/CategoryIcons.php

CodeHistory CodeHistory.php​

Code history — the dated record behind the store page's "Code activity" section (_cp). The fork already counted reveals and clicks, but only as running totals in post meta (coupon_reveals / coupon_clicks) plus one site-wide per-day option. Neither can answer "what did this store's code activity look like over the last year", and the alternative to answering it honestly is drawing a curve out of nothing. So this keeps one row per event, dated, exactly as {@see ListingViews} already does for page views — the same storage shape, the same UTC convention, the same zero-filled-series read. Two families of event share the table: reveal / click a shopper used the code. Logged from the ppt_cp_redeemed action {@see Redeem::record()} fires, so every reveal path — the deal row on search and store pages, the single page — counts identically and nothing had to be re-plumbed. works / fails a shopper reported back. This is NEW: the theme had no worked/didn't-work signal at all, only star reviews. The report is offered the moment a code is revealed, which is the one moment the question is real, and is what the live feed reads. Reports are deduped per shopper per coupon ({@see DEDUPE}) so the feed reflects distinct shoppers rather than a refresh. A shopper is their user id when logged in, and otherwise a salted hash of their IP — the raw address is never stored. Nothing here invents a figure. A store with no rows has no section ({@see StoreActivity}), which is the honest answer for a site that only went live last week.

API
register()table()install()onRedeem()log()nonce()report()verdicts()totalFor()hasData()monthly()feed()reporters()reporterCount()
inc/_cp/CodeHistory.php

CodeWindow CodeWindow.php​

The code box — where a shopper reads the code, while the tab they came from goes to the merchant (Coupon fork). This exists because of one thing browsers will not let a page do: a tab opened from a click is brought to the front, and nothing — blur(), focus() on the opener, reusing a held handle — reliably prevents that. So it stops fighting: the tab that is GOING to be focused is the one given the code, and the tab left behind is sent to the affiliate link. The shopper reads exactly what they clicked for, with the store loaded behind. What that opened tab shows is the page the shopper was already on, rendered again, with the box over the middle of it. It used to be a document of its own — a lone card on an empty grey ground, no header, no site, nothing they recognised — and answering "show me the code" with a blank page is a poor trade for the code. The extra render buys the box a page to sit in front of, and costs nothing else: the site behind it is the site they were reading a second ago. Three decisions make that survive contact with a real browser: 1. A real URL, not a window written into by script. The opener navigates away a fraction of a second later, and a document built by the opener would lose every event listener the moment it did. This page owns its markup, script and nonce. 2. Opened as a plain tab with no feature string. A pop-up blocker's target is a window given a size and chrome flags, or one opened with no click behind it. An earlier version asked for width=470,height=640,menubar=no…, which is precisely the shape that gets blocked. Featureless and inside the gesture, this is what any target="_blank" link already does. 3. Nothing depends on that tab being allowed. When it is refused, the opener sends ITSELF here instead ({@see SOLO}) — a same-tab navigation, which nothing can block. The shopper still gets the code, on this page, with the store one click away; all they lose is the second tab. A coupon site whose codes only appear if the browser cooperates is a coupon site that does not work. {@see PUSH} is the fourth case, and the newest. A DEALS-grid tile carries a link into this site rather than a merchant URL — the block that drew it has the listing's permalink and nothing else — so the opener cannot send itself anywhere useful. It says so, and this page, which does know the merchant, drives the opener there instead. Same outcome, decided one page later. The reveal is counted HERE, server-side. A page load cannot be blocked the way a beacon fired during unload can, so this is the more honest count as well as the simpler one. Never indexed: this URL is a fragment of an interaction, not a page of the site. The page WITHOUT the parameter is the real one and stays indexable.

API
register()url()urlTemplate()maybeArm()robots()printBox()
inc/_cp/CodeWindow.php

CuffBox CuffBox.php​

Cuff's own search box (Fashion & Clothing Coupons, Coupon fork). Coupon browse pages carry no heading strip, so without this a design has no search of its own over its own results. This is the masthead's square white field put back at the top of the results it filters, in Cuff's hand: hard corners, a black Go block. Store-aware: inside a store it says so, and the store term rides along in the hidden fields so the search stays where the visitor is.

API
render()css()
_cp/Cards/CuffBox.php

CuffCard CuffCard.php​

Cuff's own coupon card (Fashion & Clothing Coupons, Coupon fork). The code row from Cuff's home-page "Best codes right now" list, lifted out of the block so the same object is drawn on the search page, on a store page and inside the block itself. Square-cornered and monogram-led, as the approved mockup: a solid coloured square carrying the store's initial (or its logo when the store term has one) on the left, the store name in tracked small caps, the offer title and a meta line of checked · used · ends, then on the right a black "Show code" block with a dashed cobalt scissors tab beside it for codes, or an outlined "Get deal" for deals. The monogram colour is dealt from a hash of the store name (SWATCHES), so a live store gets a stable colour without any field and a curated row the same; the design's cashback tiles use the same hash so a store wears one colour everywhere. Revealed state: the button itself becomes the dashed cobalt code box (.is-shown) and the tab (data-ppt-peek) is removed by the shared listener. The tab never carries any character of the code. The CSS is scoped to .ppt-cpcard--cuff, never to the block section, because the same rules have to hold on a search page where that section is absent.

API
render()css()
_cp/Cards/CuffCard.php

DashCard DashCard.php​

Dash's own coupon card (Sports & Fitness Coupons, Coupon fork). The row from Dash's "Latest coupons" stack, lifted out of the block so the same object is drawn on the search page, on a store page and inside the block itself: a white card with an orange left rule, a charcoal square monogram and a tinted kind pill on the left, the store in spaced orange caps over a bold title, an expiry / checked / used line, an optional description, and an uppercase "Show code" button with the code stub under it (a deal takes the charcoal button). The CSS is scoped to the card's own root class, never to the block section, because the same rules have to hold on a search page where that section is absent.

API
render()css()
_cp/Cards/DashCard.php

DealRow DealRow.php​

DealRow — how a coupon looks in a list (Coupon fork). This was the markup itself until 2026-09-08. It is now the name the rest of the theme calls that markup by, while the drawing is done by whichever card the active design owns: Ppt_cp\Cards\Cards which card the design declared (or the fork default) Ppt_cp\Cards\CardData the listing, normalised into the shape cards read Ppt_cp\Cards\DefaultCard the fork's own deal row — this file's former contents Ppt_cp\Cards\TallyCard Tally's voucher card, its home page's and its search page's, one definition The change is why: every coupon design now owns its own search card, and a design added later gets one by naming two classes rather than by having this file re-measured against its home page. See inc/_cp/Cards/README.md. The seam is kept because the shared search template calls DealRow::render() by name (inc/Directory/templates/search.php, the $ppt_card closure) and PROOF_FLOOR is read by {@see LiveCodes}. Both keep working; neither has to know a design was involved.

API
render()
inc/_cp/DealRow.php

DefaultCard DefaultCard.php​

The fork's own coupon card — what a coupon design gets when it declares no card of its own (Coupon fork). A coupon is not a photograph. The photo card the other forks use reserves its top two-thirds for an image a coupon feed never supplies, which pushed the one thing a shopper is looking for — the discount — off the card entirely. This is the layout every coupon site converges on for the same reason: the discount as a large figure on the left, the store and the offer in the middle, the action on the right. The action is the point of it. Codes and offers have no page of their own, so the code is revealed IN PLACE — no interstitial, no navigation — by the shared listener in {@see Cards}. A curated row (no post id) has nothing to reveal or count, so its button is a plain link instead. Every trust signal is derived from something real by {@see CardData}, and is omitted rather than invented when the data is absent. Colours come from the design's --ppt-* tokens, never hardcoded, so a design without its own card still gets a row in its own palette.

API
render()css()
_cp/Cards/DefaultCard.php

DemoAccounts DemoAccounts.php​

Coupon — what the DEMO member walks into. A coupon site has one kind of member, so the home-demo offers a single "members area" login (the plain test member). Empty, that hub shows nothing worth looking at, so this lends the member three of the seeded demo coupons as their own submissions and saves four more as favourites — the shape a member's hub takes once they have used the site. The rows come from _cp\DemoFeed (Support\Demo-marked listings); DemoLogin lends them and hands them back at purge exactly as it does for any typed member. Gated to the coupon profile by Theme::boot()'s fork-gating (this is Ppt_cp*).

API
register()seedIfEmpty()lendCount()furnishStore()unfurnishStore()furnish()
inc/_cp/DemoAccounts.php

DemoCards DemoCards.php​

DemoCards — what a coupon search page shows in the showroom (_cp). A coupon site's search results are COUPONS. Everywhere else in the theme a design preview borrows the niche's curated demo listings, and those are product-shaped by definition — a photograph of a thing, a category chip and a price. Rendered through the shared listing card on this fork they read as a product catalogue, which is the one thing the coupon search surface must never look like: products have their own page ({@see Products}), and the search page is where codes and deals live. So on _cp the showroom builds its own rows, in the coupon card's own shape ({@see Cards\CardData}), from two sources in this order: 1. The previewed design's own code list. Whatever its home page shows, the search page shows — same copy, same store names, same card class. That is the point of {@see Cards\Cards}: one card definition, home page AND search. 2. The niche's merchants, as offers. A design ships a handful of curated rows (Tally ships five), which is a thin results page. The niche pool behind every other fork's demo search already names ~19 shops with a headline saving each ("Up to 40% off"), so those become the rest of the page — the store's name, its saving as the figure, its blurb behind the card. Nothing here ever runs on live data. The search template only asks for these rows while it is in preview AND the site has no coupons at all ({@see TypeFilter::hasAny}), so a real catalogue always answers for itself. A merchant that carries no shipped logo gets a monogram, exactly as an unknown merchant from a real feed does.

API
cards()storeCards()categoryCards()
inc/_cp/DemoCards.php

DemoCategory DemoCategory.php​

DemoCategory — the showroom's categories, and the pages behind them (_cp). The sibling of {@see DemoStore}, built for the same reason and on the same rules: a coupon install seeds no listings at all, so /categories/ showed its empty state in a design preview while the rest of the design was full of offers, and there was nothing to click. WHERE THE CATEGORIES COME FROM — and this is the part worth keeping: they are NOT a fixed shipped list like the twenty stores are. Every coupon niche already ships nineteen categories through {@see SampleListings}, and they are the previewed design's OWN subject: Loom (fashion) has Denim, Knitwear and Coats & outerwear; Offpeak (travel) has Flights, Rail travel and City hotels. Reading them from the niche means the categories page matches the design being previewed instead of showing a shop's departments under a travel theme, and it means a new niche gets its categories for free — nothing to add here when one is added. THREE GATES, all required — identical to DemoStore, for identical reasons: 1. a REAL term with that slug always wins; 2. {@see Preview::isPreview()} — a live site 404s an invented URL as before; 3. the slug must match one of the previewed niche's categories. Nothing here writes: no terms, no posts, no options.

API
register()active()all()shipped()url()claim()serve()cards()title()
inc/_cp/DemoCategory.php

DemoFeed DemoFeed.php​

DemoFeed — the Coupon Theme's demo data, and the sample feeds it is built from (_cp). 10 stores, 10 coupons, 10 deals and 6 products. The stores are real terms carrying their shipped logo, website and blurb; every coupon, deal and product is filed under one of them, so each store page has something on it and no store appears from nowhere. Two jobs, deliberately in one place: 1. A downloadable example of the format. A site owner setting up their first feed has nothing to compare their file against. This writes a real, valid CSV in the shape {@see FeedNetworks} expects, so "what should my file look like" is answered by a button. 2. Installable demo content. The same files imported through the ordinary pipeline — no special path, so what an owner sees demoed is exactly what their own feed will do. Reached from Feeds ▸ Sample feed, from Sample Data, from the setup wizard's "Add sample listings" box and from a design switch — the last three through {@see \Ppt\Tools\SampleData}, which hands the coupon profile here instead of seeding its generic niche rows. Everything it creates is TAGGED ({@see FLAG_META} + {@see Demo}) so removal is exact. That is the whole design constraint here: demo content that cannot be removed cleanly is worse than no demo content, because it silently mixes with a real catalogue. Remove deletes only what carries the tag — a coupon imported from a real network is never touched, and a store the owner already had is used but never restyled or deleted. The rows are CURATED, not generated: ten fixed shops means every offer can be worded for the shop it belongs to, so a travel site never says "free delivery" and a pizza chain never offers "15% off skincare". The file is byte-identical on every build, so a re-import updates in place instead of churning.

API
register()storeRows()couponCsv()productCsv()path()write()isInstalled()count()install()remove()handleDownload()handleInstall()handleRemove()
inc/_cp/DemoFeed.php

DemoStore DemoStore.php​

DemoStore — /stores/<slug>/ for a shop that exists only in the showroom (_cp). A coupon install seeds no stores ({@see StoreSeed::uninstall}), so in a design preview the Stores index draws the twenty shipped shops as dressing rather than terms ({@see \Ppt\Frontend\Pages::demoStoreRows}). Those tiles have to lead somewhere: the archive rewrite /stores// exists on this profile, but the TERM behind it does not, so every tile pointed at the deal archive instead — click Nike, land on a page of everybody's offers. That is a dead end dressed up as a link, and the one rule this index has always had is that it must never dead-link. So the slug is served: the request is claimed before WP can 404 it, the page renders through the ordinary search template with the SAME store header a real store gets ({@see StoreTax::renderHead}), and the results are that shop's own showroom offers ({@see DemoCards::storeCards}). Nothing is written — this is demo TEMPLATE, not demo DATA, the distinction the whole no-demo-content rule on this fork turns on. THREE GATES, all required, in this order: 1. a REAL term with that slug always wins — the moment an owner has a store, its own archive answers and nothing here fires; 2. {@see Preview::isPreview()} — a live site 404s an invented URL exactly as before, so no demo shop is ever a public page on somebody's real site; 3. the slug must match a SHIPPED store — an unknown slug still 404s.

API
register()active()claim()serve()header()body()bodyHtml()cards()title()shipped()rows()url()
inc/_cp/DemoStore.php

DiodeCard DiodeCard.php​

Diode's own coupon card (Tech & Electronics Coupons, Coupon fork). The centred, portrait card from Diode's tabbed coupon grid, lifted out of the block so the same object is drawn on the search page, on a store page and inside the block itself. The lead is the store's WORDMARK — its name set in the display face inside a ruled box, or its logo when the store has one — over a red discount figure, the offer, the checked line and a full-width button. The button becomes the dashed code box on reveal, so there is no separate tear stub on this design. The CSS is scoped to the card's own root class, never to the block section, because the same rules have to hold on a search page where that section is absent.

API
render()css()
_cp/Cards/DiodeCard.php

Entitlements Entitlements.php​

Coupon-site entitlements — what a membership plan grants a BUSINESS member on the Coupon Theme, and the gate that enforces it. WHY THIS EXISTS. A coupon site has two completely different supply models and the theme now serves both. A feed-driven voucher site imports its codes from Awin or a crawl and has no members posting at all. A LOCAL OFFERS site is the opposite: the supply IS the businesses, and the thing being sold is the right to post. This class is the second model's meter — "an annual tier that allows 12, 50 or unlimited coupons" — expressed as one row on core's {@see \Ppt\Account\Entitlements} registry so it is edited in the ordinary plan grid and read through the ordinary {@see \Ppt\Account\Membership} resolver. SLOTS, NOT SUBMISSIONS. The row is a PERIOD_NONE quota: a standing figure compared against a live count, never an allowance that is used up. "12 coupons" therefore means twelve live at once, and an offer that expires or is taken down frees its slot the moment it does. That is both what a business owner assumes the words mean and the only reading with no year-boundary to get wrong — there is no bucket to reset, no renewal date to align a counter to, and nothing to reconcile when a plan changes mid-term. It also means consume() is never called here: {@see liveCount()} IS the usage figure, derived from the posts themselves, so it cannot drift out of step with what is actually on the site. WHAT OCCUPIES A SLOT. Published, pending and scheduled coupons that have not expired. - pending counts because a submitted offer awaiting approval is a claim on a slot: leave it out and a member could queue fifty submissions and have them all land at once when an admin approves the batch. - draft does NOT count. An unfinished coupon nobody has submitted is not an offer on the site, and counting drafts would let a member paint themselves into a corner with abandoned ones they would then have to hunt down and delete. - an EXPIRED coupon never counts, whatever its status. This is the point of slots. THE GATE FAILS OPEN, like _so's and the dating fork's: it only refuses once some ENABLED plan actually expresses a limit ({@see gated()}). A coupon site that has not configured memberships at all behaves exactly as it did before this class existed, which is what stops the feature landing on existing installs as a lockout.

API
register()rows()sampleSet()gated()liveCount()slotLimit()slotsLeft()slotBlock()canAdd()upgradeUrl()
inc/_cp/Entitlements.php

Expiry Expiry.php​

PPT — listing expiry / "Listing lifetime". The Settings ▸ Listings "Listing lifetime" (days) governs how long a listing stays live. 0 = never expires. A value > 0 stamps an expiry timestamp on each listing when it's published; a daily cron then runs the configured "On expiry" action (listings_expiry_action: nothing / draft / pending / trash) once the time is up. The expiry timestamp meta is SHARED with the pricing-plan expiry (PricingPlans::LISTING_EXPIRES_META). A listing that carries a pricing plan is governed by that plan's own duration (set in the editor / at checkout), so the global lifetime only applies to listings WITHOUT a plan. Either way, the cron here enforces whatever expiry timestamp a listing ends up with, and both editors show the time remaining.

API
register()metaKey()schedule()unschedule()onTransition()applyLifetime()runCheck()timestamp()remaining()dateLabel()
inc/_cp/Expiry.php

FeedIngest FeedIngest.php​

Coupon feed INGEST — the run that turns feed rows into listings. fetch → parse → normalise → upsert → tombstone, mirroring {@see \Ppt\Compare\Ingest} because coupons need the same two freshness guarantees a price feed does: • every listing this run touched is stamped with the run id; • after the run, every listing from this network that the run did NOT touch is expired — a network never says "this code is dead", the code simply stops appearing, so absence IS the expiry signal. Without the second half a coupon site fills up with codes that quietly stopped working, which is the one thing that destroys trust in it. Identity is ppt_cp_source_id + ppt_cp_source_network, so a nightly re-run updates in place rather than duplicating. The whole network's existing ids are read once into a map at the start of a run, not looked up per row — a meta_query per row is what turns a 500-row feed into a timeout. Coupons are listings ({@see Listings::TYPE}), distinguished by coupon_record_type: code when the row carries a voucher code, offer when it is a link-only promotion. product is reserved for the product-feed phase, which is not wired here. Stores need no handling: writing coupon_merchant files the listing under the matching listing_store term and creates it on first sight ({@see StoreTax}). Runs are time-budgeted and resumable. A coupon feed is hundreds of rows and normally finishes in one pass, but a run that would exceed the budget saves its offset and reports done => false rather than dying half-applied — and a half-applied run must NOT tombstone, or every row it never reached would be expired.

API
typeOf()typeLabel()nextRun()cachedFile()budgetSeconds()register()runFeed()runAll()token()tokenUrl()handleToken()
inc/_cp/FeedIngest.php

FeedNetworks FeedNetworks.php​

Coupon feed NETWORKS — what each affiliate network calls the same seven things. Every network publishes the same idea (merchant, title, discount, code, destination, expiry, terms) under different names and in a different container, so the only thing that varies between them is a field map. That map lives here; nothing else in the coupon import knows a network exists. Two things no network gives us cleanly, and which {@see discount()} therefore has to derive from text: • Rakuten has a promotiontypes taxonomy but no numeric amount; • Awin has neither — the discount is only ever stated in the offer title. So the same parser runs for every network, seeded by a type hint where one exists.

API
all()get()choices()kindOf()isScrape()isAwinApi()membershipChoices()membershipEarns()membershipShort()membership()sectorList()regionCodes()providers()sniffFormat()sniffXmlItem()providerOf()scrapeChoices()productChoices()couponChoices()normalise()discount()normaliseProduct()money()inStock()
inc/_cp/FeedNetworks.php

Single-listing gallery styles. The admin picks one of four layouts on the Design ▸ Listings tab (stored in the ppt_design option via Branding), and the single-listing template renders the chosen layout for the listing's images. standard — large hero photo + thumbnail strip (click to swap) [default] grid — all photos in a tiled grid (first one featured) carousel — a swipeable/scroll-snap slider with prev/next tall — full-width photos stacked vertically (good for portraits)

API
styles()locked()current()isFullSpan()render()css()
inc/_cp/Gallery.php

GarlandCard GarlandCard.php​

Garland's own coupon card (Gifts & Flowers Coupons, Coupon fork). The row from Garland's "Latest coupons" stack, lifted out of the block so the same object is drawn on the search page, on a store page and inside the block itself: a square-cornered white card with a round plum monogram and a small-caps kind label on the left, the store in spaced small caps over a serif title, an expiry / checked / used line, an optional description, and a plum "Show code" button with a gold-edged code stub under it. The CSS is scoped to the card's own root class, never to the block section, because the same rules have to hold on a search page where that section is absent.

API
render()css()
_cp/Cards/GarlandCard.php

Geocoder Geocoder.php​

Bulk geocoder — fills in lat/lng for existing listings that have an address but no coordinates (e.g. listings created before the editor's map picker existed), so they get a precise map marker and the "Distance from me" feature. Uses the site's Maps provider (Settings ▸ API keys): Google / Mapbox geocoding APIs (their key), or OpenStreetMap Nominatim (keyless, rate-limited to ~1 req/sec with an identifying UA). A small box on the PPT Listings screen runs it in batches over AJAX. Listings that can't be geocoded are flagged (_ppt_geo_failed) so they aren't retried forever.

API
register()geocode()pendingCount()ajaxBatch()assets()box()
inc/_cp/Geocoder.php

HomeRail HomeRail.php​

HomeRail — the widget column a coupon design's home page draws beside its code list. The Gifts & Flowers designs (Posy / Garland / Sprig, 2026-09-11) were approved with the fork's own sidebar WIDGETS as their right-hand column — the same six types the search page draws, skinned per design — rather than a hand-built <aside> of flat fields. This helper is the one place that list is resolved so the three code blocks share the behaviour instead of copying it: 1. the owner's saved home rail ({@see Widgets::forContext()} for home) when they have placed anything there on PremiumPress ▸ Widgets; 2. otherwise the DESIGN's six, passed in by the block from its own editable fields. Nothing here writes the ppt_cp_widgets option: switching designs must not rewrite an owner's widget list, and a design's defaults are its own, not the site's. The widgets are drawn through their block classes directly rather than through a nested {@see \Ppt\Blocks\Renderer::render()} pass: the calling block is itself inside that renderer, whose per-block overlay state (LiveCodes::$slotIds) a nested pass would reset under it. The widget types read their own data (top codes, stores, expiring, categories) and need none of the overlays.

API
render()untoken()css()
inc/_cp/HomeRail.php

HostingRows HostingRows.php​

The eight curated coupon rows the three Hosting & Software Coupons designs (Uptime / Stack / Ping, Coupon Theme, _cp) open with. One body, three heroes: the designs share this copy so a customer switching between them keeps the same sample rows, and so the copy is edited in one place. Each design's own ``_codes block still owns its fields — this is only the default text those fields are filled with. Row shape: store, initials, title, amount, unit, kind, code, verified, flag, note (the used-count — LiveCodes overlays a live site's real count into _note), ends, desc.

API
rows()
inc/_cp/HostingRows.php

HuddleCard HuddleCard.php​

Huddle's own coupon card (Sports & Fitness Coupons, Coupon fork). The row from Huddle's "Latest coupons" stack, lifted out of the block so the same object is drawn on the search page, on a store page and inside the block itself: a square- cornered white card with a navy-tint square monogram and a small-caps kind label on the left, the store in spaced navy caps over a heavy grotesque title, an expiry / checked / used line (an imminent expiry in the kit red), an optional description, and a navy "Show code" button with a dotted code stub under it. The CSS is scoped to the card's own root class, never to the block section, because the same rules have to hold on a search page where that section is absent.

API
render()css()
_cp/Cards/HuddleCard.php

JoltCard JoltCard.php​

Jolt's own coupon card (Tech & Electronics Coupons, Coupon fork). The row from Jolt's "Featured discounts" stack, lifted out of the block so the same object is drawn on the search page, on a store page and inside the block itself. The lead column is a tinted FIGURE BLOCK — the discount as a big number over a small unit word — with the store's monogram as its stand-in when a live coupon states no figure. The action is a yellow "Show code" button with a dashed tear tab beside it; a deal is a solid blue "Get deal". The CSS is scoped to the card's own root class, never to the block section, because the same rules have to hold on a search page where that section is absent.

API
render()css()
_cp/Cards/JoltCard.php

ListingActions ListingActions.php​

Per-listing visitor actions shown on the single-listing section nav: - Add to favorites — toggles the listing in the member's saved list (user meta ppt_favorites); the account page shows them. Logged-in only; guests are routed to sign-in. - Report — flags the listing to the site admin. The reason is stored as a comment on the listing (type ppt_report, a custom approval status so it stays hidden from the front-end and the normal comment-moderation tabs) and also emailed to the admin. Reports are reviewed in the admin Comments screen's dedicated "Reports" tab. Open to guests and members.

API
register()favIds()favHas()favToggle()favListingIds()ajaxFav()ajaxReport()assets()favButton()reportButton()
inc/_cp/ListingActions.php

ListingCard ListingCard.php​

The canonical listing card — the single, theme-wide way a business/listing is shown as a card. Every directory surface (search results, single-page "related", and the live listing-grid blocks) renders through here, so a listing looks the same everywhere and the demo-vs-live data split lives in ONE place. Data-source agnostic: fromPost() maps a real listing_type record and fromSample() maps a curated demo row into the same normalized shape, which render() draws. The markup is built on the theme design tokens, so the one card automatically adopts each design's colours/fonts. Its CSS is enqueued site-wide (assets/css/listing-card.css) — the card only emits markup. Normalized shape: name, link, img, cat, rating, desc, desc_long, price, city, dist, featured (bool), highlighted (bool), badge (string), id badge is an optional ribbon label (e.g. "New") for callers that need one; when empty it falls back to "Featured" if featured is set.

API
fromPost()fromSample()render()
inc/_cp/ListingCard.php

ListingEditor ListingEditor.php​

Listing editor — the shared brain behind the two listing-edit screens: - Admin : PPT-styled screen at ?page=ppt_listings&edit=<id> (Chrome shell), rendered by Admin\Listings when the edit param is present. - Member : front-end screen at /account/listing/<id>/ inside the Member Hub, rendered by Account\MembersPage for a listing the member owns. Both POST to admin-post.php (action ppt_listing_save) and run through the one save() below, so the field set, sanitising and persistence live in a single place. The mode (admin|member) decides which extras are honoured: admins get status, author, slug, featured/verified and the "edit only" custom fields; members get a friendly subset scoped to their own listing. Fields = core (title, description, category, tags, featured image, gallery, FAQ) + every field defined in the Custom Fields admin (Admin\Fields / option ppt_fields), stored as post meta keyed by the field key — which is what the single-listing template already reads.

API
register()newUrl()handleNew()submissionsOpen()memberCanAdd()addBlockReason()businessUrl()canEdit()adminEditUrl()memberEditUrl()applicableFields()locationKeys()coordKeys()mapPickerReady()mapConfig()locationFields()couponValue()couponBadge()recordTypeOf()couponExpired()socialNetworks()socialValue()socialIcon()hoursDays()
inc/_cp/ListingEditor.php

ListingFaq ListingFaq.php​

Single-listing FAQ. Reads per-listing FAQ items from the faq post meta (an array of ['q' => …, 'a' => …]). A live listing shows only its own saved FAQ — when it has none the section is omitted rather than filled with fabricated entries. Demo/preview mode supplies sample FAQ via DemoContent so a fresh design isn't empty. Populate a listing's own FAQ by saving faq meta, inject a site-wide default set via the ppt_listing_faq_default filter, or adjust the final list with the ppt_listing_faq filter.

API
items()
inc/_cp/ListingFaq.php

ListingHours ListingHours.php​

Business hours display for the single-listing sidebar. Reads the business_hours meta written by the listing editor (ListingEditor::HOURS_META) and renders a Google-Business-style day list with an "Open now / Closed now" badge computed in the site's timezone. Times are formatted with the site's time format.

API
has()isOpenNow()renderSidebar()
inc/_cp/ListingHours.php

ListingLocation ListingLocation.php​

Single-listing "Location" section — the richer location block: the full formatted address, an interactive map (ListingMap, precise when the listing has coordinates), a "Get directions" button, and a "Distance from you" control (browser geolocation → straight-line distance to the listing). Self-contained: it prints its own scoped CSS + JS once, so single.php just calls render().

API
address()coords()has()render()
inc/_cp/ListingLocation.php

ListingMap ListingMap.php​

The single-listing "Location" map. Uses the site's Maps provider setting (Settings ▸ API keys — the same ppt_maps_provider filter the search map reads). When the listing has stored coordinates (lat/lng, set by the editor's map picker) the map centres on them precisely and drops a named marker; otherwise it falls back to geocoding the address string: google / mapbox(fallback) → a keyless Google Maps embed osm → a Leaflet map (marker from coords, else Nominatim) Map libraries load from their CDNs — the owner-approved front-end map exception.

API
render()
inc/_cp/ListingMap.php

ListingPricing ListingPricing.php​

Single-listing pricing plans. A listing owner enters one or more plans / packages on the submission form (a name, a price, and a short description); they're stored in the pricing_plans post meta as a list of ['name' => …, 'price' => float|null, 'desc' => …]. The LOWEST priced plan is mirrored into the searchable price meta (Ppt_cp\SearchPage::PRICE_META) by the editor on save, so the price range filter, the price sort, and the result-card price all work — that's the link between "enter your pricing" and "find listings by price". Prices are stored in the site's base currency; render() converts + formats via Ppt\Content\Currencies for the viewer's chosen currency.

API
plans()demoPlans()has()lowestPrice()normalise()render()
inc/_cp/ListingPricing.php

ListingSections ListingSections.php​

PPT — configurable single-listing sections. The admin (Design ▸ Listings ▸ "Listing page sections") gets a sortable list of on/off toggles for the optional feature blocks a listing page can carry — Booking, Business hours, Maps & Location, FAQ, Reviews. Turning one OFF removes it from BOTH the single listing page and the listing submission/edit form; the order of the list controls the order the (main-column) sections appear in. Config lives in the shared design option (Branding::OPTION = ppt_design) under the listing_sections key: { order:[keys…], enabled:{key:0|1} }. Anything not saved yet defaults to ON, in the catalog order — so existing sites are unchanged until the admin edits the list.

API
supported()catalog()config()order()enabled()orderedAmong()sanitize()
inc/_cp/ListingSections.php

ListingTiles ListingTiles.php​

The tile row at the top of the coupon listing form: Category and Store. Both were stacked chip-and-button fields — Category on the shared modal picker, Store on a bespoke inline panel that dropped a list of pills into the form. They are the same kind of decision ("which existing thing does this deal belong to"), so they now read as one row of tiles: icon, label, and either the chips that are set or the word "Select". Clicking a tile opens the SHARED category picker modal (assets/js/category-picker.js) — searchable, with a Clear All / Continue footer — so adding and editing choices is the same gesture on both. Store stays SINGLE-select (a deal belongs to one shop; StoreTax::assign writes one term), which the shared picker already supports via data-cat-multi="0". That also retires the inline store panel and its script: the modal replaces both. The two hidden-input shapes differ and the order in the markup is load-bearing: category[] an ARRAY — the picker writes one hidden input per chip, and the empty backstop can sit anywhere (PHP drops the empty member). coupon[store] a SCALAR — last value posted wins, so the "nothing selected" backstop is printed BEFORE the chip box, never after it.

API
hasStores()categoryTile()storeTile()assets()
inc/_cp/ListingTiles.php

ListingViews ListingViews.php​

PPT listing analytics — records a "hit" every time a single listing page is viewed, and serves that data back to the listing owner as a graph in the member area (/account ▸ My listings ▸ the analytics icon). Each view stores the listing id, the timestamp (UTC), and — when the visitor is logged in — their user id + display name, so an owner can see who has been looking. Bots/crawlers are skipped so counts reflect real visitors. The owner (and admins) can read the analytics for a listing over the AJAX endpoint, which returns a daily series (for the chart), rolling-window totals (7/30/60/90 days) and a list of recent named viewers. Storage: a custom table {prefix}ppt_listing_views (dbDelta on init).

API
register()table()install()maybeRecord()total()countSince()series()recentViewers()ajaxData()
inc/_cp/ListingViews.php

LiveCodes LiveCodes.php​

LiveCodes — puts the site's real coupons into a design's code list (_cp). The coupon designs author their home-page code list as curated demo rows — "Northvale Home, 20% off, Verified 06:40 today, Confirmed by 214". That is right in the showroom and wrong the moment a site has its own feed running: the home page goes on advertising shops that do not exist while the real coupons sit on the search page. The shared {@see \Ppt\Blocks\Support\LiveItems} overlay already solves this for ordinary card grids, but it works off a generic vocabulary (title / image / price / location). A coupon row is none of those — its fields are item{N}_amount, _store, _code, _kind, _verified, _flag — so those keys pass straight through it untouched. This is the same idea in the coupon dialect, and it runs as one render-time pass in {@see \Ppt\Blocks\Renderer}, exactly like LiveItems and HowLinks: every coupon design's code list goes live at once, with no per-block wiring, and designs written later are covered for free. It follows LiveItems' contract to the letter: • sample context (showroom, demo-default preview, builder) → curated rows, untouched; • live site with published coupons → the real ones win; • live site with nothing published → curated rows stay, so a new install still has a presentable home page rather than a hole above the fold. The stats block ("Today's numbers") is overlaid too, and for a stronger reason than tidiness: shipped as demo copy it reads "Live codes 1,482 / Average saving £14.20" on a site with twenty coupons. Those are invented figures presented as fact about the site the visitor is on. Every value here is counted from real listings, and a figure with nothing behind it is dropped rather than guessed.

API
appliedIds()apply()hasStoreIdentity()tokens()fill()
inc/_cp/LiveCodes.php

LiveGuides LiveGuides.php​

LiveGuides — puts the site's real blog posts into a coupon design's guides row (_cp). Three coupon designs close their home page with an editorial three-up — "Saving guides" on Tally and Perk, "Saving notes" on Pleat. Each card is a photograph, a section tag, a title, a standfirst and a byline, authored in defaults() as curated demo articles. That is right in the showroom and wrong on a customer's site: the cards used to fall back to the SEARCH page, so a visitor who clicked "The price-drop refund almost nobody claims" landed on a list of coupons, and the site's own posts never reached the home page at all. {@see \Ppt\Blocks\Support\LiveItems} deliberately skips these blocks — they are category "text", not "listings", and filling an article card from the coupon pool would headline a discount code as a guide. This is the same overlay idea in the blog dialect, and it runs as one render-time pass in {@see \Ppt\Blocks\Renderer} right after {@see LiveCodes}, so every guides row goes live with no per-block wiring. It follows LiveItems' contract to the letter: • sample context (showroom, demo-default preview, builder) → curated rows, untouched (they link to the blog index — see indexUrl() — never to search); • live site with published posts → the newest posts win: title, permalink, excerpt, featured image, category, author, uploaded avatar, reading time; • live site with nothing published → curated rows stay, still linking to the blog index, so a new install has a presentable home page rather than a hole. These are homepage DESIGNS: the owner decides in the builder whether this row belongs on their page, and the render must not overrule that by deleting it. A post without a featured image keeps the design's own photograph for that slot; an author without an uploaded photo drops the avatar (every block prints it conditionally) rather than showing a stranger's face beside a real byline.

API
indexUrl()apply()
inc/_cp/LiveGuides.php

LoomBox LoomBox.php​

Loom's own search box (Fashion & Clothing Coupons, Coupon fork). Coupon browse pages carry no heading strip, so without this a design has no search of its own over its own results. This is the masthead's pill field put back at the top of the results it filters, in Loom's hand: a sand pill with the sage Search button inset on its right. Store-aware: inside a store it says so, and the store term rides along in the hidden fields so the search stays where the visitor is.

API
render()css()
_cp/Cards/LoomBox.php

LoomCard LoomCard.php​

Loom's own coupon card (Fashion & Clothing Coupons, Coupon fork). The row from Loom's home-page "Today's trending fashion codes and deals" grid, lifted out of the block so the same object is drawn on the search page, on a store page and inside the block itself. Logo-led, as the approved mockup: the store's LOGO in the left column where other coupon designs put a discount figure, then the store name, the offer line and the trust row, with a sage pill button on the right that carries a blank dashed code tab with a scissors mark for codes, or reads "Get deal" for deals. The discount figure is not shown as its own element: on this design the offer line carries it ("20% off everything…"), and printing it twice would duplicate the discount whenever a live coupon stated it in another currency. The card omits rather than repeats. The tab never carries any character of the code. Revealed state: the shared listener removes the tab (data-ppt-peek) and the button becomes the dashed code box (.is-shown), reclaiming the tab's gutter. The CSS is scoped to .ppt-cpcard--loom, never to the block section, because the same rules have to hold on a search page where that section is absent.

API
render()css()
_cp/Cards/LoomCard.php

Media Media.php​

Listing images — the single, WordPress-native resolver for the PPT image system. A listing's photos are its WP featured image (primary) plus any image attachments parented to it (the gallery). No legacy ?imgid refs, no image-URL meta strings — everything flows through the media library.

API
primary()uploaded()gallery()videos()media()counts()
inc/_cp/Media.php

MembershipLapse MembershipLapse.php​

Stop paying, and your coupons expire. WHY THIS EXISTS. A membership tier that meters live coupon slots only means something while somebody is paying for it. Before this class, a lapsed business could not post anything NEW — Entitlements::slotLimit() resolves a lapsed member to the free tier (or to nothing) so the gate closed correctly — but every coupon they had already published stayed live for ever. Buy one year, post fifty offers, never renew, keep fifty offers. {@see \Ppt\Account\MembershipExpiry} says so in its own docblock: it emails, and "never changes their assigned plan". WHAT "EXPIRED" MEANS HERE. Exactly what it means for any other coupon on the site: {@see \Ppt\Payments\CheckoutFlow::expireListing()}, the same handler the listing expiry cron runs, honouring the owner's own Settings ▸ Listings ▸ "On expiry" choice — draft, pending, trash, "mark expired (keep page)", or nothing. This deliberately invents no second notion of expiry: a coupon that went because its own date passed and one that went because its business stopped paying end up in the same state, which is the state the owner asked for. It also never rewrites coupon_expiry. That date is the BUSINESS'S data — what they told shoppers about their own offer — and post-dating it to force an expiry would corrupt it and could not be undone on renewal. IT ENFORCES THE CEILING, NOT "LAPSED YES/NO". The rule is "no more live coupons than your plan allows", applied to whatever plan you are on NOW. That covers lapsing (the ceiling drops to the free tier's, usually 0, so everything goes) and it covers a DOWNGRADE for free: a business moving 50 → 12 has its excess retired instead of sitting over its allowance indefinitely. The newest offers are kept, because they are the ones most likely still worth showing. WHEN IT RUNS. On the existing daily membership-expiry cron, at a later priority than the emails, so there is no second schedule to keep alive and a member is told before (or as) their offers come down. A renewal clears the per-user flag so the next lapse is processed afresh, exactly as MembershipExpiry::rearm() re-arms its reminders.

API
register()onRenewed()run()enforce()liveIds()wasRetired()
inc/_cp/MembershipLapse.php

MemberStore MemberStore.php​

One store per business member — the shopfront a submitting business owns. WHY. {@see StoreTax} makes a store a real listing_store term, and the term is where the branding lives (logo, blurb, website). That was built for a site whose stores are big brands an ADMIN curates on the Stores screen. The moment businesses submit their own offers (memberListings, 2026-09-19) the question changes: which store does Rosa's Pizzeria's coupon belong to? The answer here is that a business member OWNS exactly one store term — theirs — and every coupon they post is filed under it automatically. That gives the business a real public page at /stores/rosas-pizzeria/ carrying their logo, their blurb and every live offer they have, which is the shopfront a local-offers site is selling. It also removes the whole class of problems the alternatives have: a member cannot file a coupon under somebody else's brand, cannot invent "Rosa's" / "Rosas" / "Rosa's Pizzeria" as three stores, and does not have to wait for an admin to add their business before they can post. OWNERSHIP IS RECORDED TWICE, deliberately: term meta _ppt_store_owner => user id the authority user meta ppt_cp_store => term id the index The term meta is what {@see ownerOf()} trusts; the user meta only makes "which store is mine" a single lookup instead of a meta_query on every editor render. {@see storeFor()} verifies the index against the authority on every read and repairs it when they disagree, so a term deleted underneath a member (or an admin reassigning one on the Stores screen) can never leave a member pointed at a store that is not theirs. ADMIN-OWNED STORES ARE UNTOUCHED. A store with no _ppt_store_owner is an ordinary curated/imported one and behaves exactly as it always has — which is what lets a single site run an imported catalogue and local businesses side by side.

API
register()ownerOf()isMemberOwned()owns()storeFor()storeIdFor()defaultName()ensureFor()saveBranding()attach()has()renderPanel()panelCss()handleSave()attachLogo()usage()
inc/_cp/MemberStore.php

OffpeakBox OffpeakBox.php​

Offpeak's own search box (Travel & Hotels Coupons, Coupon fork). Coupon browse pages carry no heading strip, so without this a design has no search of its own over its own results. This is the masthead's field put back at the top of the results it filters, in Offpeak's hand. Store-aware: inside a store it says so, and the store term rides along in the hidden fields so the search stays where the visitor is.

API
render()css()
_cp/Cards/OffpeakBox.php

OffpeakCard OffpeakCard.php​

Offpeak's own coupon card (Travel & Hotels Coupons, Coupon fork). The row from Offpeak's home-page code stack, lifted out of the block so the same object is drawn on the search page, on a store page and inside the block itself. The lead column is the DISCOUNT FIGURE — the shape the approved mockup carries — with the store's monogram as its stand-in when a live coupon states no figure. The CSS is scoped to the card's own root class, never to the block section, because the same rules have to hold on a search page where that section is absent.

API
render()css()
_cp/Cards/OffpeakCard.php

PageChrome PageChrome.php​

PageChrome — the coupon browse pages' ground and full-width bands (_cp). Two things that must be true on EVERY coupon search and store page, whether or not it found anything: 1. The page ground. The coupon designs sit their code lists on a faint grey with white cards on top. This lived in {@see DealRow}'s stylesheet at first, which was wrong: that only prints when a deal row renders, so a store with no coupons — or an empty search — fell back to white and looked like a different site. 2. Full-width bands. The store strip and the store header sit inside the shared template's .container, but read as full-bleed bands: white, edge to edge, with a rule under them. They break out with symmetric negative margins and restate the container measure inside, so their content still lines up with the results below. The ground colour is a DESIGN token, --ppt-cp-ground. A design sets it and wins; a design that sets nothing gets a value mixed from its own line colour, which lands on the right shade for the palettes shipped so far. Nothing here is a fixed hex, so a dark coupon design restyles the page without touching this file.

API
register()styles()
inc/_cp/PageChrome.php

PerkBox PerkBox.php​

Perk's own search box card (Classic Coupons, Coupon fork). Coupon browse pages carry no heading strip — it was taken out on 2026-09-07 because the site header already holds a search box and the strip repeated it. This is Perk putting its own search back over its own results: the cream pill bar from its masthead, on the toasted band, with the tangerine Search button. Store-aware, because a search box on a store page that quietly searched the whole site would be a trap: inside a store it says so, and the store term rides along in the hidden fields so the search stays where the visitor is.

API
render()css()
_cp/Cards/PerkBox.php

PerkCard PerkCard.php​

Perk's own coupon card (Classic Coupons, Coupon fork). The voucher card from Perk's home-page code stack, lifted out of the block so the same object is drawn on the search page, on a store page and inside the block — one card definition rather than two that have to be kept in step. Everything visual is Perk's: the 96px discount column with its CODE/DEAL chip, the offer line in Fraunces, the tangerine "Exclusive" pill, and the folded-corner gold reveal button — which on press BECOMES the dashed cream code box the mockup draws, rather than sitting next to a separate peek stub. Two Perk details the shared card shape carries cleanly: the gold note band under the row (rendered only when a curated row supplies one — nothing is invented on live data) and the hairline footer, which holds the store on a search page and the Terms link always. One departure from the approved mockup, on purpose: the mockup's card title opened with the discount repeated in tangerine ("20% off orders over £70 at Ovenbird"). A live coupon's title already contains that phrase, so prefixing it would read "20% off 20% off everything at Ovenbird" on every real card — and a lead shown on the home page but not in search would defeat the point of one shared card. The discount stays in the figure column, where it is larger and always right. The CSS is scoped to the card's own root class, never to the block section, because the same rules have to hold on a search page where that section is absent.

API
render()css()
_cp/Cards/PerkCard.php

PetRows PetRows.php​

The eight curated coupon rows the three Pets & Pet Care Coupons designs (Scamp / Pounce / Biscuit, Coupon Theme, _cp) open with. One body, three heroes: the designs share this copy so a customer switching between them keeps the same sample rows, and so the copy is edited in one place. Each design's own ``_codes block still owns its fields — this is only the default text those fields are filled with. Row shape: store, initials, title, amount, unit, kind, code, verified, flag, note (the used-count — LiveCodes overlays a live site's real count into _note), ends, desc.

API
rows()
inc/_cp/PetRows.php

PingCard PingCard.php​

Ping's own coupon card (Hosting & Software Coupons, Coupon fork). The row from Ping's "Latest coupons" stack, lifted out of the block so the same object is drawn on the search page, on a store page and inside the block itself: a 14px-rounded white card with a ROUND blue-tint monogram and a pill kind chip on the left, the store in blue over a heavy grotesque title, an expiry / checked / used line, an optional description, and a pill "Show code" button with a dashed code stub under it (deals get a navy pill). The CSS is scoped to the card's own root class, never to the block section, because the same rules have to hold on a search page where that section is absent.

API
render()css()
_cp/Cards/PingCard.php

PleatBox PleatBox.php​

Pleat's own search box (Fashion & Clothing Coupons, Coupon fork). Coupon browse pages carry no heading strip, so without this a design has no search of its own over its own results. This is the masthead's square-cornered field put back at the top of the results it filters, in Pleat's hand: a bordered white field with the oxblood Search button flush on its right. Store-aware: inside a store it says so, and the store term rides along in the hidden fields so the search stays where the visitor is.

API
render()css()
_cp/Cards/PleatBox.php

PleatCard PleatCard.php​

Pleat's own coupon card (Fashion & Clothing Coupons, Coupon fork). The code row from Pleat's home-page "Codes live right now" stack, lifted out of the block so the same object is drawn on the search page, on a store page and inside the block itself. Figure-led, as the approved mockup: a serif discount figure in the oxblood brand on the left with its unit word and a CODE / DEAL chip under it, the store name in tracked small caps, the offer title, a meta line of checked · used · ends, and on the right the outlined "Reveal code" button whose dashed-divided tail reads as the tear-off part of a ticket. Revealed state: the button itself becomes the dashed code box (.is-shown) — there is no peek stub; the tail is part of the button. The shared listener replaces the button's text with the code, which drops the two inner spans, so the revealed box is plain text on purpose. The CSS is scoped to .ppt-cpcard--pleat, never to the block section, because the same rules have to hold on a search page where that section is absent.

API
render()css()
_cp/Cards/PleatCard.php

PoiseCard PoiseCard.php​

Poise's own coupon card (Health & Beauty Coupons, Coupon fork). The row from Poise's home-page "Working right now" pair of columns, lifted out of the block so the same object is drawn on the search page, on a store page and inside the block itself. The lead column is the store MONOGRAM on a slate tile, with the discount figure taking the tile when a live coupon states one. The action is a dashed tablet whose upper line is the code and whose lower line is the instruction — the shape the approved mockup carries — so the button and the code box are one object rather than two. The CSS is scoped to the card's own root class, never to the block section, because the same rules have to hold on a search page where that section is absent.

API
render()css()
_cp/Cards/PoiseCard.php

PosyCard PosyCard.php​

Posy's own coupon card (Gifts & Flowers Coupons, Coupon fork). The row from Posy's "Latest coupons" stack, lifted out of the block so the same object is drawn on the search page, on a store page and inside the block itself: a rounded white card with the store's monogram and a small kind pill on the left, the store name in rose over the title, an expiry / checked / used line, an optional description, and a pill "Show code" button with the code stub tucked under it. The CSS is scoped to the card's own root class, never to the block section, because the same rules have to hold on a search page where that section is absent.

API
render()css()
_cp/Cards/PosyCard.php

PounceCard PounceCard.php​

Pounce's own coupon card (Pets & Pet Care Coupons, Coupon fork). The row from Pounce's "Latest coupons" stack, lifted out of the block so the same object is drawn on the search page, on a store page and inside the block itself: a square-cornered white card with a slate-tint serif monogram and a small uppercase kind label on the left, the store as an uppercase slate line over a serif title, an expiry / checked / used line, an optional description, and a square "Show code" button with a dashed code stub under it. The CSS is scoped to the card's own root class, never to the block section, because the same rules have to hold on a search page where that section is absent.

API
render()css()
_cp/Cards/PounceCard.php

ProductCard ProductCard.php​

ProductCard — a coupon-site product (_cp). Products are the one record type that looks nothing like a coupon: a photo and a price rather than a discount figure and a code. So whatever card the active design declared for its coupons, a product is drawn here, in one of two shapes: row (default) — a results list: keyword searches and category pages, where a product sits between coupons and deals ({@see \Ppt_cp\TypeFilter::includesProducts()}), and /products/. Laid out on the same three columns as the fork's deal row (figure | offer | action), with the photo in the figure column, so it reads as one more result rather than a stray tile. tile — a grid: the "Products from {store}" section under a store's offers ({@see \Ppt_cp\Products::storeSection()}). Every link goes to the product's own page on this site, never straight out to the merchant: that page is where the price, the details and the "Buy from {store}" button live, and where the out-click is counted (user-directed 2026-09-22 — a coupon opens its code, a product opens its listing). No redeem attributes are printed, so the code pop-up never claims a product. Self-contained on purpose. Until 2026-09-22 this adapted the Shop fork's card (\Ppt_sp\ListingCard), which is fine on the dev install where every fork is present — but a client ZIP ships only its own fork (build-theme.ps1 drops every other inc/_xx), so on a real Coupon Theme site that class did not exist, this returned '', and /products/ and every store's Products section were a column of empty cells with a heart in each. Colours, radii and the display face come from the design's --ppt-* tokens, so the card takes on whichever design is active.

API
reset()render()css()
_cp/Cards/ProductCard.php

Products Products.php​

Products — the /products/ browse surface (_cp). Products were kept OUT of the coupon results and given their own page (user-directed, 2026-09-08). They are a different kind of thing to shop for: a shopper scanning voucher codes is not also scanning trainers. That held until a site whose members post only products found its search answering "No coupons found" for things it sells, so since 2026-09-22 a keyword search and a category page list products among their results as well ({@see TypeFilter::includesProducts()}). The empty search and the store pages still keep them apart, and this page still lists them on their own. The route resolves to the ordinary search endpoint with a marker query var, rather than a post-type archive — the listing CPT is registered has_archive => false, and routing through search means the page inherits the whole existing apparatus (template, sidebar, widgets, pagination, AJAX refresh) instead of reimplementing it. Everything about WHICH listings appear is owned by {@see TypeFilter}, so there is one place that knows how a record type turns into a meta clause.

API
register()image()priceText()wasText()storeSection()queryVar()route()isView()url()hasAny()onStatus()heading()robots()
inc/_cp/Products.php

Redeem Redeem.php​

Coupon redemption tracking (Coupon fork). Records two lightweight signals from the single coupon page — a "reveal" (the shopper unmasked the code) and a "click" (the shopper hit "Go to store") — as running counters in post meta (coupon_reveals / coupon_clicks). No new tables and no rewrite rules: the page fires a nonce-checked beacon to admin-ajax, mirroring how ListingViews serves its analytics endpoint. These counts feed the "used N times" social proof on the single page and give the owner a rough sense of which deals convert. They are best-effort (a blocked beacon just means an uncounted reveal), never used for access control.

API
register()nonce()reveals()clicks()record()count()lastDays()hasDayData()
inc/_cp/Redeem.php

RedeemModal RedeemModal.php​

The redeem pop-up — what a shopper sees when they take a coupon card's action (Coupon fork). Until now a card's "Show code" swapped the button's own label for the code and left the shopper looking at a row, and a code-less "Get deal" was a plain link straight out. Neither gave the code a moment of its own: nowhere to read the offer, nowhere to copy from, nowhere to say whether it worked, and — on the deal — nothing at all to come back to. This is that moment. One dialog, drawn over whichever page the card was on, holding the offer, the code, the way out to the store, and the "did it work?" question. Which window gets what is the whole design, and it is the opposite of the obvious arrangement. A window opened from a click is brought to the front and no page can prevent it — so the window that WILL be focused is given the code, and the tab left behind is sent to the affiliate link. See {@see \Ppt_cp\CodeWindow}. The shopper ends up reading exactly what they clicked for, with the store loaded behind it. What that opened tab shows is THIS page again, with the box drawn over the middle of it. It used to be a bare document of its own — one card on an empty ground — and the shopper who clicked "Show code" was answered by a page that looked like nothing they had been reading. Now the site is still there behind the box, and the box is the only new thing in front of it. That means this class draws the dialog on ONE path only: when the browser refuses the window, or a row has no listing behind it to open one for. A refusal must never leave the shopper with neither the code nor the store, so the same dialog appears in the page instead, with a plain link out. It is a fallback, not the main road — which is why it no longer navigates the tab anywhere. A design's DEALS grid opts in the same way, through {@see dealAttrs()} — its rows are block fields rather than cards, and the listing behind a row on a live site is found from the block's own slot map. See tilePid() in behaviour(). Every design's card opts in by putting {@see attrs()} on its action element — that blob carries the offer, so nothing here has to know a card's class names, and a design written tomorrow gets the dialog by echoing one expression. The listener is delegated from the document, so rows injected by the AJAX search refresh work without rebinding. What it deliberately does NOT do: • invent a cashback line, a saving figure or a shopper count — a coupon site's trust signals are derived from real data or omitted, the rule the cards already follow ({@see CardData}); • claim the code was applied for you. It is copied to the clipboard (best effort) and shown; the shopper pastes it.

API
wanted()reset()attrs()dealAttrs()css()pageCss()demoDestination()js()
_cp/Cards/RedeemModal.php

Reviews Reviews.php​

Listing reviews — built on native WordPress comments so they moderate and store like any comment, but stamped with the comment type "review" (the identifier that distinguishes them from ordinary blog comments in the admin) and a 1–5 star rating saved as comment meta. Reviews are always enabled on the listing CPT. Rendered in the single-listing "Reviews" (summary + breakdown + cards) and "Add Review" (star input + comment form) sections.

API
register()open()stampType()saveRating()onCommentChange()onStatusChange()syncPostRating()count()all()stats()stars()renderSummary()renderList()renderForm()
inc/_cp/Reviews.php

ScampCard ScampCard.php​

Scamp's own coupon card (Pets & Pet Care Coupons, Coupon fork). The row from Scamp's "Latest coupons" stack, lifted out of the block so the same object is drawn on the search page, on a store page and inside the block itself: a generously rounded white card with a rounded-square amber-tint monogram and a pill kind chip on the left, the store in amber over a bold rounded-sans title, an expiry / checked / used line, an optional description, and a pill "Show code" button with a dashed code stub under it. The CSS is scoped to the card's own root class, never to the block section, because the same rules have to hold on a search page where that section is absent.

API
render()css()
_cp/Cards/ScampCard.php

Scrape Scrape.php​

Scrape — a website URL as a feed source (_cp). Not every shop publishes a feed. Small independents, and plenty of larger shops outside the affiliate networks, publish nothing at all — but almost all of them mark their product pages up with schema.org data so Google can read them. The comparison theme already has a reader for exactly that ({@see \Ppt\Compare\Scraper}), so this adapter points it at the coupon site's product pipeline rather than writing a second one. The whole job here is translation: the crawler speaks the comparison theme's offer vocabulary (raw_title, offer_url, image_url), and this turns each offer into the canonical product columns — the same shape a generic product CSV arrives in. Everything past that point is the ordinary import: {@see FeedNetworks::normaliseProduct()}, the same upsert, the same tombstone, the same Preview dry run. PRODUCTS ONLY, and deliberately so. There is no structured-data vocabulary for a voucher code — nobody marks one up, because there is nothing for a search engine to do with it — so a crawler cannot find coupons however hard it looks. A coupon still needs a real feed or a hand-written listing.

API
rows()
inc/_cp/Scrape.php

SearchPage SearchPage.php​

Server-rendered search / archive for the listing post type — the clean PPT rebuild of DT10's PPT\Custom\Search\SearchPage. Runs the normal WordPress main query (WP_Query) via official hooks (pre_get_posts + template_include) so it is SEO-friendly and every core filter applies. Contexts it owns: - keyword search (/?s=…) - the listing post-type archive (/listing/) - listing category / tag archives Filters (GET, all optional, combinable): s keyword tax-listing_category category term id price1 / price2 min / max price (numeric, price meta) sort featured | newest | oldest | rating | price_low | price_high | title paged pagination

API
register()shapeQuery()applyCappedTotal()wasCapped()totalLabel()nearCoords()radiusKm()distanceFrom()distanceLabel()radiusClausesMain()radiusClauses()featuredOrderClauses()isListingContext()postType()isMapTakeover()categoryTax()terms()taxQueryFromRequest()listingTaxonomies()hasActiveFilters()matchingIds()termCounts()priceMetaQuery()sortOptions()
inc/_cp/SearchPage.php

SheenCard SheenCard.php​

Sheen's own coupon card (Health & Beauty Coupons, Coupon fork). The row from Sheen's home-page "Featured Discounts" stack, lifted out of the block so the same object is drawn on the search page, on a store page and inside the block itself. The lead column is the store MONOGRAM on a lacquer tile — the shape the approved mockup carries — with the discount figure taking the tile when a live coupon states one, so the column is never empty. The CSS is scoped to the card's own root class, never to the block section, because the same rules have to hold on a search page where that section is absent.

API
render()css()
_cp/Cards/SheenCard.php

SingleListing SingleListing.php​

Single-listing page for the listing_type post type — the clean PPT rebuild of DT10's PPT\Custom\Single\SingleListing. Owns template_include for singular listings and renders its own token-styled template inside the normal main loop. No legacy $CORE / membership plumbing; standard WordPress access rules apply.

API
register()robots()template()related()
inc/_cp/SingleListing.php

SnipBox SnipBox.php​

Snip's own search box card (Classic Coupons, Coupon fork). Coupon browse pages carry no heading strip, so this is Snip putting its own search back over its own results: the white rounded bar from its plum masthead, with the plum Search button inside its right edge. Store-aware: inside a store it says so, and the store term rides along in the hidden fields so the search stays where the visitor is.

API
render()css()
_cp/Cards/SnipBox.php

SnipCard SnipCard.php​

Snip's own coupon card (Classic Coupons, Coupon fork). The code row from Snip's home-page "This week's codes" grid, lifted out of the block so the same object is drawn on the search page, on a store page and inside the block. It is a ROW on purpose: the reference (RetailMeNot) draws vertical tiles, but {@see Cards::gridCss()} gives a coupon one full-width line on search, and a tile at full width is a broken card. The row reads the same two-up on the home page and one-up in search. Anatomy: a store monogram column on the left (a coloured rounded square with the store's initial, or its logo when the store term has one), the body — the discount figure in the brand colour with its unit word, the offer title, a meta line of CODE/DEAL chip · "Checked …" · ends — and a right-hand action column: the plum pill button (outlined for a deal) with the "Used N times" proof line under it. An "Exclusive" flag hangs from the card's top edge as a berry tab. Revealed state: the button itself becomes the dashed code box (.is-shown) — no peek stub, same as Perk. The monogram colour is derived from the store name, so a live store gets a stable colour without any field, and a curated row the same. The CSS is scoped to .ppt-cpcard--snip, never to the block section, because the same rules have to hold on a search page where that section is absent.

API
render()css()
_cp/Cards/SnipCard.php

SorbetBox SorbetBox.php​

Sorbet's own search box (Summer Coupons, Coupon fork). Coupon browse pages carry no heading strip, so without this a design has no search of its own over its own results. This is the masthead's field put back at the top of the results it filters, in Sorbet's hand. Store-aware: inside a store it says so, and the store term rides along in the hidden fields so the search stays where the visitor is.

API
render()css()
_cp/Cards/SorbetBox.php

SorbetCard SorbetCard.php​

Sorbet's own coupon card (Summer Coupons, Coupon fork). The row from Sorbet's home-page code stack, lifted out of the block so the same object is drawn on the search page, on a store page and inside the block itself. Its shape is the one the approved mockup replicated from the reference: the store's LOGO in the left column where other coupon designs put a discount figure, then the store name, the offer line and the trust row, with the reveal button on the right. The discount figure is therefore not shown as its own element at all: on this design the offer line carries it ("20% off garden furniture"), exactly as the reference does. An earlier pass printed fig_big as a pill before the title when the title did not repeat it, which duplicated the discount whenever a live coupon stated it in another currency - "£50" beside "$50 off orders over $299". The card omits rather than repeats. The CSS is scoped to the card's own root class, never to the block section, because the same rules have to hold on a search page where that section is absent.

API
render()css()
_cp/Cards/SorbetCard.php

SprigCard SprigCard.php​

Sprig's own coupon card (Gifts & Flowers Coupons, Coupon fork). The row from Sprig's "Latest coupons" stack, lifted out of the block so the same object is drawn on the search page, on a store page and inside the block itself: a near-square white card with a square mint monogram and a small-caps kind label on the left, the store in spaced green small caps over a Bodoni title, an expiry / checked / used line, an optional description, and a eucalyptus "Show code" button with a dashed code stub under it. The CSS is scoped to the card's own root class, never to the block section, because the same rules have to hold on a search page where that section is absent.

API
render()css()
_cp/Cards/SprigCard.php

StackCard StackCard.php​

Stack's own coupon card (Hosting & Software Coupons, Coupon fork). The row from Stack's "Latest coupons" stack, lifted out of the block so the same object is drawn on the search page, on a store page and inside the block itself: a generously rounded white card with a rounded terracotta-tint monogram and a pill kind chip on the left, the store in terracotta over a bold geometric title, an expiry / checked / used line, an optional description, and a rounded "Show code" button with a dashed code stub under it (deals get an espresso button). The CSS is scoped to the card's own root class, never to the block section, because the same rules have to hold on a search page where that section is absent.

API
render()css()
_cp/Cards/StackCard.php

StoreActivity StoreActivity.php​

"Code activity" — the history section on a store page (_cp). Printed on ppt_category_before_results at priority 15, which lands it under the store header (10) and above the design's search box (20), so the box stays glued to the results it filters — see {@see Cards::register()} for why that ordering matters. Two panels, both read straight off {@see CodeHistory}: the chart a year of this store's code uses, by month. The month in progress is drawn dashed so a half-finished month does not read as a slump. the live feed what shoppers reported back — "worked" / "didn't work" — newest first, each line linking to the code it is about. The whole section hides when there is nothing behind it.* A store below {@see CodeHistory::FLOOR} events renders nothing at all rather than a flat line at zero, which is the same rule the deal row already applies to "used N times". Nothing on this page is estimated, projected or seeded: every pixel is a row in the events table. That is also why a brand-new install shows no section — there is genuinely no history yet, and drawing one would be the only dishonest thing this file could do.

API
register()render()
inc/_cp/StoreActivity.php

StoreBrands StoreBrands.php​

StoreBrands — fills in what a coupon feed leaves out about the shop. A promotions feed carries an advertiser's id and name and nothing else, so an imported store arrives as a name and a monogram: no logo, no website, no words, no category. On a site with 1,300 of them that is not a store section, it is a list of strings. The network's PROGRAMME CATALOGUE has the rest — logoUrl, displayUrl, description, primarySector, currencyCode — for every advertiser it lists, joined or not. Two requests fetch the lot ({@see AwinApi::fetchProgrammes()}); this walks it and writes the parts each store is missing. THREE RULES THIS FOLLOWS, ALL DELIBERATE: 1. EMPTY FIELDS ONLY. A sync never overwrites a logo, website or description that is already set, because the one that is already set may have been written by hand and there is no way to tell. Re-running is therefore always safe. $force exists for "give me the network's version back", and is never the default. 2. MATCH ON ID, NEVER ON NAME. The store carries the advertiser id the import stamped on it ({@see StoreTax::META_NET_ID}). Names collide and drift. 3. THE CATALOGUE NEVER ENTERS MEMORY WHOLE. It is 13.5 MB and 21,246 records; json_decode on it peaks at 190 MB, which is a fatal error on any 128 MB host. Categories come from the brand's sector, because the promotions themselves have none — Awin returns an empty categories on every promotion this account can see. A store's sector is the only category the data actually supports, so a store's coupons share it.

API
wanted()nameKey()text()step()apply()categorise()
inc/_cp/StoreBrands.php

StoreSeed StoreSeed.php​

The twenty stores the Coupon Theme USED to install, and the pass that removes them. A coupon install seeds no sample data of any kind — the same rule that turns demo listings off for this profile ({@see \Ppt\Content\ThemeProfiles::seedsDemoData()}) — so the starter set of twenty recognisable shops is gone. A coupon site now starts with an empty Stores screen and an empty /stores/ page; both carry their own empty state, and the store strip on the browse pages renders nothing until real stores exist. Two things stay behind: 1. {@see uninstall()} — a site installed under an earlier build already has the twenty in its database, so the theme removes them once, on the next admin page load. It matches the shipped SLUGS and deletes them whether or not they were edited: this is a removal, not a migration. Deleting a store never deletes its deals — a coupon filed under one loses its store, and is filed again under a store of its own merchant's name the next time it is saved (StoreTax::onMetaWrite). 2. {@see stores()} — the canonical list, still the source of the merchant NAMES in the sample feed ({@see DemoFeed}), which is opt-in and whose import creates its own bare terms. Nothing here creates a store any more. LOGO_DIR remains the one folder Admin\Stores will accept a shipped logo path from. Names, blurbs and logos come from the DT10 store library (framework/data/_stores.php); the logos are shipped in assets/img/_cp/stores/.

API
stores()uninstall()
inc/_cp/StoreSeed.php

StoreStrip StoreStrip.php​

StoreStrip — the row of store chips above coupon search results (_cp). A shortcut to the shops people actually come looking for, sat at the top of every search. Rendered on the shared ppt_category_before_results hook (the same seam {@see StoreTax::archiveHeader()} and the comparison table use), so the shared search template needs no coupon branch of its own. NOT the design's own block. Tally ships one (ppt_blocks/_cp/category/TallyStores) and this is measured against it, but calling that class from here would tie the search page to a single design — every other coupon design would get nothing. This draws the same thing from listing_store terms using the design's --ppt-* tokens, so it restyles itself per design and works where no such block exists. Suppressed on a store archive: the page is already about one store, and offering a shortcut to five others at the top of it is an exit, not navigation.

API
register()render()
inc/_cp/StoreStrip.php

StoreTax StoreTax.php​

Stores — the shops a coupon belongs to, as a real taxonomy. A coupon has always carried its shop as free-text meta (coupon_merchant, written by the listing editor, the importer, the API and the demo seeder alike). Free text cannot be browsed: it has no page, no logo, no count, and "ASOS" / "Asos" / "asos " are three different shops as far as a query is concerned. So the shop becomes a listing_store term, and the term is where the branding lives — logo, blurb and website — edited on the themed Stores screen (\Ppt\Admin\Stores). Two public surfaces come out of it: /stores/ the index of every store (Frontend\Pages' stores slot) /stores/<slug>/ one store: its header + its live deals (the ordinary listing archive, with a header injected at ppt_category_before_results) THE TERM IS THE SOURCE OF TRUTH, and coupon_merchant is kept in step behind it so every existing renderer (the deal card, the single page, search) keeps working with no change: ListingEditor::couponValue() reads the term first and falls back to the meta. The reverse direction is covered too — a write to coupon_merchant from a path that knows nothing about stores (importer, REST, seeder) attaches the matching term, creating it on first sight — so an imported catalogue still populates /stores/. Coupon Theme only. NOT in Ppt\Directory\ (that whole namespace steps aside the moment a fork owns the profile); it belongs to the coupon fork, so it lives in it and is gated with the rest of inc/_cp/ by Theme::boot().

API
available()register()unseed()taxonomy()all()storeOf()nameOf()logoId()logo()remoteLogo()shippedLogo()logoAsset()url()content()link()indexUrl()initial()assign()existingTermIdForName()termIdForName()onMetaWrite()onTermRenamed()backfill()robots()
inc/_cp/StoreTax.php

TallyBox TallyBox.php​

Tally's own search box card (Classic Coupons, Coupon fork). Coupon browse pages carry no heading strip — it was taken out on 2026-09-07 because the site header already holds a search box and the strip repeated it. What that left behind was a results page a design had no hand in: the visitor's only way to search from inside a store was the header. This is Tally putting its own search back, in its own hand — the bordered single-field bar from its hero, at the top of the results it filters. It is the same object as the hero's, deliberately: a visitor who searched from the home page meets the identical control on the results page. The 2px ink border is Tally's signature and is what separates it from the filter panel's hairline cards. Store-aware, because a search box on a store page that quietly searched the whole site would be a trap: inside a store it says so, and the store term rides along in the hidden fields so the search stays where the visitor is.

API
render()css()
_cp/Cards/TallyBox.php

TallyCard TallyCard.php​

Tally's own coupon card (Classic Coupons, Coupon fork). This is the voucher card from Tally's home-page code stack, lifted out of the block so the same object can be drawn on the search page, on a store page and inside the block itself. Before this existed, the block drew Tally's card and the search page drew the fork's default row: two cards for one design, which had to be kept in step by measuring one against the other. Everything visual is unchanged from the block — 132px figure column with a hairline divider, the store monogram, the discount in Archivo at 38px, the CODE chip with a jade tick, and the code stub peeking out from behind the button. The one behaviour that is new comes for free from the shared card contract: on a real coupon the button now reveals the code in place instead of navigating, because the card is given a post id. A curated row still links, having nothing to reveal. The CSS is scoped to the card's own root class, never to the block section, because the same rules have to hold on a search page where that section is absent.

API
render()css()
_cp/Cards/TallyCard.php

TermMeta TermMeta.php​

Per-term IMAGE + ICON for the listing taxonomies. Adds two custom fields to the native WordPress term add/edit screens (Categories, Tags and any custom listing taxonomy): a media-library image picker and a swatch picker over the theme icon library. The values are stored as term meta and consumed by the live-category blocks (via Blocks\Support\Categories) so an admin can give each category a branded photo and icon instead of the auto-derived listing photo / fallback glyph. Admin-only UI; the read helpers (image()/imageId()/icon()) are safe to call anywhere. Taxonomies are resolved live from the listing post type, so new custom taxonomies get the fields automatically.

API
register()hookTaxonomies()taxonomies()imageId()image()icon()assets()addFields()editFields()save()
inc/_cp/TermMeta.php

TonicCard TonicCard.php​

Tonic's own coupon card (Sports & Fitness Coupons, Coupon fork). The row from Tonic's "Latest coupons" stack, lifted out of the block so the same object is drawn on the search page, on a store page and inside the block itself: a softly rounded white card with a round teal-tint monogram and a kind pill on the left, the store in deep teal over a serif title, an expiry / checked / used line, an optional description, and a pill "Show code" button with a dashed code stub under it. The CSS is scoped to the card's own root class, never to the block section, because the same rules have to hold on a search page where that section is absent.

API
render()css()
_cp/Cards/TonicCard.php

TypeFilter TypeFilter.php​

TypeFilter — the only filter on a coupon browse page (_cp). Replaces the shared filter panel (country / price / rating) with checkboxes for Coupons and Deals, plus Products on the pages that list them (a keyword search or a category page, {@see self::includesProducts()}). Price and rating mean nothing on a voucher code, and the one thing a shopper actually wants to narrow is which KIND of offer they see. All three are checked by default, and "everything checked" is expressed as no query argument at all — so the default URL stays clean and an unfiltered page runs no meta_query. The clause shapes mirror {@see FeedIngest::typeOf()} exactly, including its fallback for a listing with no coupon_record_type (a code present means Coupon, otherwise Deal). Filtering on the stored meta alone would hide every listing on an install that has never run a feed — the same trap the admin table's filter documents.

API
register()types()includesProducts()selected()renderPanel()showsDemoCards()counts()countType()hasAny()filterQuery()clause()
inc/_cp/TypeFilter.php

Uploads Uploads.php​

Listing media uploads — the AJAX backend for the Uppy uploader on the listing editor (replacing the WordPress media frame). Uppy's XHRUpload posts one file per request to ppt_listing_upload; each becomes a normal WordPress attachment (so Media::gallery()/Media::videos() and the single-listing template keep working). Files are left unattached (post_parent = 0) until the listing form is saved, which parents the final set (ListingEditor::save()). A companion ppt_listing_media_remove deletes a freshly uploaded, not-yet- saved attachment when the user removes it in the editor, so abandoned uploads don't pile up in the media library.

API
register()imageMimes()videoMimes()ajaxUpload()ajaxRemove()ajaxPoster()ajaxPersist()
inc/_cp/Uploads.php

UptimeCard UptimeCard.php​

Uptime's own coupon card (Hosting & Software Coupons, Coupon fork). The row from Uptime's "Latest coupons" stack, lifted out of the block so the same object is drawn on the search page, on a store page and inside the block itself: a square-cornered white card with a navy mono-type monogram in mint and a small mono kind chip on the left, the store in mono green over a bold sans title, an expiry / checked / used line, an optional description, and a green "Show code" button with a dashed mono code stub under it (deals get a navy button). The CSS is scoped to the card's own root class, never to the block section, because the same rules have to hold on a search page where that section is absent.

API
render()css()
_cp/Cards/UptimeCard.php

WaypointBox WaypointBox.php​

Waypoint's own search box (Travel & Hotels Coupons, Coupon fork). Coupon browse pages carry no heading strip, so without this a design has no search of its own over its own results. This is the masthead's field put back at the top of the results it filters, in Waypoint's hand. Store-aware: inside a store it says so, and the store term rides along in the hidden fields so the search stays where the visitor is.

API
render()css()
_cp/Cards/WaypointBox.php

WaypointCard WaypointCard.php​

Waypoint's own coupon card (Travel & Hotels Coupons, Coupon fork). The row from Waypoint's home-page code stack, lifted out of the block so the same object is drawn on the search page, on a store page and inside the block itself. The lead column is the DISCOUNT FIGURE — the shape the approved mockup carries — with the store's monogram as its stand-in when a live coupon states no figure. The CSS is scoped to the card's own root class, never to the block section, because the same rules have to hold on a search page where that section is absent.

API
render()css()
_cp/Cards/WaypointCard.php

WidgetData WidgetData.php​

WidgetData — the data the coupon sidebar widgets draw, and the rules that hold live content apart from showroom dressing. Lifted verbatim out of {@see SidebarWidgets} when the four hard-coded boxes became blocks ({@see \Ppt\ppt_blocks_cp\sidebar}). Nothing here changed in the move — the queries and the gates are the ones that were already proven, and they are in one class so a widget block never grows its own copy of them. THREE RULES hold demo apart from live, and all three are load-bearing: • a fallback is reached only when the widget's own real source came back EMPTY, so demo dressing can never stand in front of a catalogue, however small; • {@see demoAllowed()} additionally requires {@see Preview::isPreview()} — the same gate {@see DemoStore} serves its pages under, so a live site stays exactly as bare as its data says it is; • nothing is written. Demo TEMPLATE, not demo DATA — the distinction this whole fork's no-demo-content rule turns on.

API
beginSamples()endSamples()sampling()onStorePage()demoAllowed()demoRows()demoUsed()mostRevealed()topCodeRows()storeRows()expiringRows()categoryRows()currentStore()storeStats()storeRating()similarStores()editorPick()productChoices()resetPickChoices()pickLabel()initial()
inc/_cp/WidgetData.php

WidgetRail WidgetRail.php​

WidgetRail — works out which page this is, and prints the owner's widgets for it. The one renderer for every surface. It hangs on: • ppt_search_sidebar — fired by the shared search template inside its filters aside. _cp is locked to the sidebar_right layout ({@see \Ppt\Admin\Search}), so on this fork that hook always fires on a browse page. • ppt_cp_sidebar — the fork's own seam, on the single-deal page and the blog. ══ WHICH PAGE IS THIS ═══════════════════════════════════════════════════════════ {@see \Ppt\SocialProof\Targeting::pageContext()} already answers this correctly for home / search / single / blog, and it is reused rather than copied — including the part that matters most: it tests {@see \Ppt\Frontend\HeaderResolver::realFrontPage()}, NEVER is_front_page() / is_home(). Every virtual route (/faq/, /stores/, /blog/, cart, checkout, every Pages slot) is served by a rewrite that leaves the main query empty, so WordPress reports both as TRUE on all of them. One case is added on top: store. It has to be tested BEFORE search, because a store archive is also a listing archive and would otherwise be classified as one. And it must use the {@see WidgetData::onStorePage()} pair — is_tax() is FALSE on a demo store page, since {@see DemoStore} unsets the taxonomy query var to stop WP 404ing a term that does not exist.

API
register()enabled()output()context()render()
inc/_cp/WidgetRail.php

Widgets Widgets.php​

Widgets — the owner's sidebar panel list for the Coupon Theme (_cp). The list mechanics — rows, contexts, positions, normalising, sanitising, the picker's thumbnails — moved to {@see \Ppt\Content\WidgetList} on 2026-09-11, when the Job Board line adopted the same screen. None of it was coupon-specific; what IS coupon-specific is here, and nothing else changed: the class name, the option and every public method a caller uses are exactly what they were. What this line owns: • store as a page type — the one context {@see \Ppt\SocialProof\Targeting::PAGES} has no word for, and the reason CONTEXTS exists rather than reusing that constant ({@see WidgetRail::context()}); • per-store targeting, a list of store SLUGS on each row; • sample coupon data behind the picker's thumbnails, so a data-driven widget is not a blank card there.

API
contextLabels()defaults()
inc/_cp/Widgets.php

WidgetShell WidgetShell.php​

WidgetShell — the box every coupon sidebar widget is drawn in, and the base CSS they share. The markup and the .ppt-cpw* rules are the ones {@see SidebarWidgets} shipped before the four hard-coded boxes became blocks, moved here unchanged so a design's existing sidebar CSS keeps matching. Every rule is token-driven (--ppt-brand, --ppt-card, --ppt-line), so a widget belongs to whichever coupon design is active rather than to one reference site. WHY THE BASE CSS IS GUARDED. {@see \Ppt\Blocks\Renderer::render()} concatenates every block's css() with no de-duplication, so a shared base returned by four widgets would be emitted four times. The guard means whichever widget renders first carries it, exactly once — in the rail AND when a single widget is dropped into a page by the builder. Same idiom SidebarWidgets::styles() already used; {@see resetCss()} exists for harnesses that render several passes in one process.

API
open()close()resetCss()count()css()
inc/_cp/WidgetShell.php