Skip to main content

Directory Theme class reference

27 min read

Escort Theme — Class reference

The 30 classes that make _es behave differently from the shared Directory core. Generated from the theme source.

← Back to Escort Theme features

AgencyHub AgencyHub.php​

Agencies get the DIRECTORY hub, not the escort one. The escort theme's own hub (inc/_es/views/account.php) is built around one person's profile: who viewed you, your photos, your verification. An AGENCY (Account\AccountType) has no profile of its own — it runs a roster of adverts — so that frame fits it badly. The shared Atlas hub (inc/Account/views/account.php) is the listing manager every directory-style theme uses: your listings, their stats, enquiries, plan. That is what an agency needs, so this hands the agency's hub request back to the shared view through MembersPage::view()'s ppt_hub_shared_view filter. Every other member — browsing or independent — keeps the escort hub. Only the dashboard swaps; the listing editor stays the escort one (it knows the escort fields). It also FURNISHES the demo agency's page: the one-click "Agency login" (Account\DemoLogin) should land on a My Agency panel that is already filled in — name, where it operates, an introduction, a website and a logo, exactly what the coupon theme's demo store shows — not on an empty "create" form. The roster is the lent adverts (AgencyTax::create backfills them). The page is removed again when the demo member is purged.

API
register()furnishAgency()unfurnishAgency()sharedForAgency()
inc/_es/AgencyHub.php

AgencyTax AgencyTax.php​

Agencies — the escort agencies advertising on this site, as a real taxonomy. The fork has always known WHO is an agency: Account\AccountType asks at sign-up and stamps ppt_account_type = agency on the user, the Member Hub swaps "Your profile" for a roster, and _es\ListingEditor treats deleting one of her listings as removing a person from that roster. What none of it had was a PLACE. An agency existed only as a user account, and authorProfile is switched off for the escort profile (Content\ThemeProfiles' features matrix), so /author// 404s: the agency had no public page, no page a visitor could browse TO, and no way to be found at all. So the agency becomes a listing_agency term, and the term is where the public identity lives — logo, blurb, city and website, edited by the agency herself from the Member Hub. Two surfaces come out of it: /agencies/ the index of every agency (Frontend\Pages' agencies slot) /agencies/<slug>/ one agency: her header + the escorts she advertises (the ordinary listing archive, with a header injected at ppt_category_before_results) THE ACCOUNT OWNS THE TERM, not the other way round. META_OWNER holds the user id and USER_META holds the term id, and both are written together by {@see create()} — one agency per account, and only an account that answered "I'm an escort agency" at sign-up may have one ({@see canCreate()}). That gate is the whole point: a taxonomy anyone can write to is a tag cloud, not a directory of businesses. THE ROSTER IS DERIVED, NEVER TYPED. A listing authored by an agency account is filed under that agency's term on save ({@see onSave}) — the same "one editable source for one fact" rule _cp\StoreTax follows in mirroring coupon_merchant behind the term. There is deliberately no agency picker on the listing editor: the hub roster and the public archive are then two views of one thing (post_author) and cannot disagree. Escort fork only, and only on an ESCORT-flavour site. On a modelling site (Settings ▸ Users ▸ "run this site as a modelling agency") the site itself IS the agency, AccountType::options() removes the agency choice from sign-up, and a public index of rival agencies is exactly the page that site must not have — so {@see available()} closes the whole section, which drops the taxonomy, the rewrite, the /agencies/ slot and the hub panel together. NOT in Ppt\Directory\ (that namespace steps aside the moment a fork owns the profile); it belongs to the escort fork, so it lives in it and is gated with the rest of inc/_es/ by Theme::boot(). Modelled on Ppt_cp\StoreTax, which does the same job for the Coupon Theme's shops.

API
available()register()renderPanel()handleSave()taxonomy()canCreate()forUser()ownerOf()hasAgency()create()save()onSave()ensureDemo()all()agencyOf()logoId()logo()city()website()link()indexUrl()initial()isDemo()archiveHeader()
inc/_es/AgencyTax.php

DemoProfiles DemoProfiles.php​

Escort-profile data for seeded demo listings (_es). Tools\SampleData seeds the SHARED listing fields — title, category, city, price, rating, photo — for every theme profile. That leaves an escort install's own fields empty: the About tiles (age / height / weight / ethnicity), the attribute rows (gender, orientation, nationality…), the rate card, the services list and the status pills all read fork meta that nothing was writing, so a fresh escort site came up with blank profiles and search filters (Rates / Gender / Age / Nationality) with nothing to filter on. This is the one place that fills them, for all eleven escort categories. The people here are the SAME companions the designs already show on their own home pages — Liora and Delphine come from Celeste and Lucent, Clara and Beatrice from Opal, Sienna and Adaeze from Jetset and Layover — with the ages and rates those cards print, so a seeded site matches the mockup the client picked and the numbers agree wherever both are on screen.

API
people()apply()body()faq()hours()specifications()
inc/_es/DemoProfiles.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/_es/Expiry.php

Single-listing gallery — the Escort Theme's OWN gallery. Unlike most forks, this is not a copy of the shared five-style set (showcase / standard / grid / carousel / tall) with a picker on Design ▸ Listings. The Escort Theme ships TWO purpose-built galleries of its own, so: - styles() lists profile and reel, and NEITHER key exists in the shared set. That is deliberate: a gallery_style carried in from another product line's saved design (all of which store showcase / standard / grid / carousel / tall) can never select one of these, so current() falls back to the default instead of quietly switching a profile to a layout its owner never chose. Do not name a future style here after one of the shared five. - locked() is false as of 2026-09-19 (it was true while this fork shipped one gallery), so Admin\views\design.php draws the "Photo gallery style" card and DesignPage::sanitize stores either of the two keys above — and nothing else. - fullSpanKeys() tells that admin view which of the options is a full-span one, because its own list is hard-coded to the shared set's showcase. THE SPLIT PANEL (profile). A tall portrait hero on the left and FOUR smaller photos in a 2x2 grid beside it, with the photo / video counts on the hero and a "+N" cap on the last tile when the set runs longer. Every tile opens the shared lightbox at its own item, so the whole set is reachable from five visible photos. This replaced an earlier panel whose hero was half the section wide at 16/9. The first photo anybody uploads to a profile is a portrait, so a landscape hero cropped every profile badly — the shape here exists to fix exactly that. The hero takes the NARROW track (1fr against 1.5fr) on purpose: a side tile is (right column - gap) / 2 by (panel height - gap) / 2, so the tiles are only landscape while the right column is wider than the panel is tall. Giving the hero the wide track would flip both. It is not full-span: the panel sits INSIDE the content column, beside the sticky sidebar, which is where this fork's single.php then draws the "Photos" card. THE REEL (reel, THE DEFAULT since 2026-09-19). One row of EVERY photo, scrolled sideways: the strip runs the full width of the page container, each photo keeps its own aspect ratio at one shared height (nothing is cropped, so a portrait is narrow and a landscape is wide), and prev/next arrows page it. There is no hero, no "+N" and no second rank of thumbnails — the whole set IS the strip, which is the point: a visitor sees the shape of the set immediately and scrubs it in one gesture. It IS full-span in this codebase's sense — the CONTENT width, above the two-column layout, the same span the shared set's showcase takes — so single.php draws it there with no "Photos" card around it; a card frame and a numbered heading would box in a strip whose whole point is to run the width of the page. It deliberately does NOT bleed past the container to the viewport edges (owner's call, 2026-09-19): its left and right edges line up with the cards below it. The Photos card in the left column is skipped in this style. The LIVE STREAM is not here: it lives in the sidebar (Live::panel(), rendered by templates/parts/single-sidebar-top.php), so the gallery owns photos and video only. STORIES are not here either — the profile banner's avatar ring is the way into them. Media is images AND videos in one set (Media::media() types every item), so a video plays inline when it is the split panel's hero and carries a play glyph when it is a tile. In the reel a video is always its poster plus a play badge that opens the lightbox — native controls inside a horizontal scroller are a trap on touch, where the drag to scrub and the drag to scroll are the same gesture. MEMBERS-ONLY items keep their slot but carry no media URL — Media::media() strips it before this ever sees it — and render as a padlock panel, never an empty frame.

API
styles()locked()current()fullSpanKeys()isFullSpan()render()css()
inc/_es/Gallery.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/_es/Geocoder.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/_es/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), badges (list), online (bool), id badge is an optional ribbon label (e.g. "New") for callers that need one. badges is the owner's own coloured pills for this profile (see Content\ProfileBadges) and online is the derived "she is here right now" state — the two are different kinds of claim and are drawn together as one pill row at the foot of the card. Escort profiles are looked at, not read: render() draws a portrait poster — photo (or an empty gradient placeholder) edge to edge, LIVE top-left, a diagonal FEATURED banner across the top-right corner, name + city + the badge/ONLINE pill row over a scrim at the bottom — and shows none of the excerpt / rating / price fields the normalized shape still carries for the map popups and other consumers of fromPost().

API
fromPost()storyVideo()isOnline()fromSample()render()
inc/_es/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()protectOwnProfile()newUrl()handleNew()submissionsOpen()memberCanAdd()canEdit()adminEditUrl()memberEditUrl()applicableFields()locationKeys()coordKeys()mapPickerReady()mapConfig()locationFields()socialNetworks()socialValue()socialIcon()hoursDays()hoursValue()normTime()bookingValue()bookingCapacity()bookingBlackout()
inc/_es/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/_es/ListingFaq.php

ListingHours ListingHours.php​

Availability 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/_es/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/_es/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/_es/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_es\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/_es/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, Availability, 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/_es/ListingSections.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/_es/ListingViews.php

Live Live.php​

Go Live — browser broadcasting for the Escort Theme (_es). A port of Ppt_vt\Live for the "one profile = one channel" model: the member's own profile listing IS the channel, so there is no LIVE listing format to create — any profile owner can open the studio on their listing and broadcast. The flow: 1. The owner opens the Member Hub's "Go Live" section, which lists the listings they own with one button each, and picks one. That opens the studio screen at /account/live/<id>/ (Ppt\Account\MembersPage::liveUrl) — inc/_es/views/ live-studio.php + the shared assets/js/go-live.js, alone on the page. 2. "Go Live" → ACTION_START mints a per-listing live input on the provider (once, then reused so the watch URL is stable), flips state to ON, and returns the per-input WHIP ingest URL so the browser can publish its camera over WebRTC. 3. Visitors on the profile page get the live player, rendered by Gallery::render() into the right half of the gallery card (Ppt_es\Gallery::livePanel); ACTION_STATUS (public, transient-cached) reports whether a broadcast is connected and hands back the HLS manifest — the live stream while on air, the auto-generated recording once it ends. 4. "End" → ACTION_STOP flips state to ENDED; the replay takes over the same player. SECURITY: the provider API token never leaves the server. ACTION_START returns only the per-input WHIP capability URL and the public HLS URL, and only to a signed-in member who passes ListingEditor::canEdit() on that listing. Status is a read-only public reconcile behind a short cache. WHY THIS IS A COPY, NOT A CALL: inc/_vt/ is excluded from an Escort ZIP by themes/build-theme.ps1 (one fork's folder per product), so referencing _vt\Live would fatal in the shipped theme. Ppt\Stream* IS fork-agnostic and shared, so the provider layer is reused as-is. The browser assets (go-live.js, live-watch.js, hls.min.js, live.css) live in assets/ and ship with every product, so this fork reuses their data-vt-live* DOM contract verbatim rather than forking a second copy.

API
register()enabled()available()isOn()ajaxStart()ajaxStop()ajaxStatus()enqueueStudio()watchAssets()watchable()panel()playerMarkup()poller()panelCss()
inc/_es/Live.php

LiveChannel LiveChannel.php​

Escort (_es) live-broadcast state for a profile. ONE PROFILE = ONE CHANNEL. Unlike the Video Theme — where "live" is one of several listing FORMATS (Ppt_vt\VideoDetails::LIVE) and a listing either is or isn't a stream — every escort profile here is potentially a channel, so there is no format to record. This class holds only the broadcast lifecycle for a listing: input the provider's live-input uid, minted once and reused for every later broadcast on that profile (so the watch URL never changes) state idle | live | ended started unix time the current/last broadcast began (feeds "live now" ordering) recording the provider uid of the auto-recorded replay of the last broadcast whip SERVER-ONLY ingest capability URL — never exposed except to the owner's own studio over an authenticated AJAX response Deliberately a plain static helper, not a Service: nothing here hooks WordPress. Ppt_es\Live owns the endpoints, Ppt_es\LiveNow the /live/ directory.

API
input()setInput()state()setState()isOn()started()recordingUid()setRecordingUid()hasChannel()reset()
inc/_es/LiveChannel.php

LiveChat LiveChat.php​

Live chat — the room that runs alongside a broadcast on the Escort Theme (_es). One room per profile, alive only while that profile is on air. The broadcaster reads and moderates it from the studio (/account/live/<id>/, beside the Broadcast card); viewers get a floating dock on the profile page, bottom-right, that opens itself when the stream starts and folds away when it ends. WHY A NEW ROOM AND NOT AN EXISTING SURFACE: Account\Messenger is a 1:1 DM thread and Qanda is threaded comments on a listing. A live chat is neither — it is many people in one ephemeral room, ordered by arrival, gone when the stream is. So it gets its own table, {prefix}ppt_es_live_chat, created by dbDelta exactly as Account\Messages does. TRANSPORT: WordPress on Plesk has no websockets, so the room is polled — fast (5s) while the stream is on air, slow (30s) while a channel sits waiting for one, which is the same two-speed idea Live::poller() already uses for the player itself. Every fetch is "give me everything after id N", so a quiet room costs one tiny indexed query. LIFETIME: the room is per-broadcast. LiveChannel::setState() wipes it on the way INTO a broadcast and on the way OUT of one, so a replay never carries last night's chat and the table cannot grow without bound. Mutes live and die with the room for the same reason (post meta, cleared alongside the messages). RULES: posting needs a signed-in member (guests read only — on this fork every message has to have an account behind it), the stream has to actually be on air, the poster must not be muted, and one message every couple of seconds. The profile's owner can delete any message in their own room and mute anyone in it for the rest of the broadcast. Nothing here is ever echoed as HTML: bodies are stored stripped and the client writes them with textContent.

API
register()table()install()available()dockable()hostable()onAir()roomIdentity()identityMarkup()isHost()post()since()delete()clear()heartbeat()watching()clearWatchers()mutedIds()isMuted()mute()unmute()ajaxFetch()ajaxPost()ajaxDelete()
inc/_es/LiveChat.php

LiveNow LiveNow.php​

"Live now" directory for the Escort Theme (_es). /live/ — a grid of every profile broadcasting right now, most-recently-started first, each card linking to that profile's page. Wrapped in get_header()/ get_footer() so it adopts the active design's chrome automatically. "Currently live" reads the stored broadcast state (LiveChannel::STATE_META), the same source the listing card's LIVE badge trusts — kept fresh by Live::ajaxStart/ Stop and reconciled against the provider whenever a viewer opens a profile page. A port of Ppt_vt\LiveNow. It drops that fork's Unlisted-visibility clause (an escort profile has no unlisted state) and its Block-Explorer live-data flags (the "Live now" home blocks are _vt's). Registered in Theme.php's _es list, so the route only exists on this profile.

API
register()url()rewrite()queryVar()maybeRender()isCurrent()current()
inc/_es/LiveNow.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()gallery()videos()media()counts()
inc/_es/Media.php

Preferences Preferences.php​

Escort Theme (_es) — "My matching options": what a BROWSING member is looking for. The dating fork pairs two people, so it needs a matcher: a scoring engine, weights, a daily pick, feedback tuning, an entitlements gate (Ppt_da\Matchmaking, ~1,100 lines). An escort site does none of that. Nobody is matched WITH anybody — a visitor is looking for a kind of person, and the site's job is to put those people in front of them. So this is a FILTER, not a matcher (client's call, 2026-09-08): four preferences, stored on the user, turned into a WP_Query argument list. Where the answers are used: - the Member Hub's "New on the site" row becomes "People you might like" and shows profiles that fit (inc/_es/views/account.php) - the same values prefill the search page, so "show me more" lands on a real search A Service for exactly one reason: the panel needs somewhere to save to. Everything else here is storage plus pure functions, so the hub view and the harness can call it directly.

API
register()handleSave()appliesTo()get()save()options()isSet()progress()queryArgs()searchUrl()summary()renderForm()
inc/_es/Preferences.php

ProfileSpecs ProfileSpecs.php​

Escort (_es) profile facts — the structured data behind the profile page. An adult/companionship listing is a PERSON, not a product: the page opens with a banner and an avatar, and the body is a set of physical/preference attributes plus what the advertiser offers and what it costs. None of that fits the shared "custom fields" list, so this fork owns it — the same shape _cd\VehicleSpecs gives the Car Dealer theme its vehicle facts. Four groups, all optional (a profile shows only what has been filled in): - STATS the four headline tiles (age / height / weight / ethnicity) - ATTRIBUTES the two-column fact list (gender, orientation, hair, tattoo, …) - BADGES the hero pills for the KINDS of work offered (escort, cam, …) - SERVICES what the advertiser is into (optionally priced) + the rate card Height and weight are stored in metric (cm / kg) and rendered "163 cm / 5'4"" and "60 kg / 132 lbs" so one stored value serves both audiences. Money runs through Content\Currencies::price(), the theme's one conversion chokepoint, so a rate card follows the visitor's selected currency like every other price on the site. BADGES are a real TAXONOMY (self::TAX_OFFER, listing_es_offer), seeded once with the defaults below. That is the one part of this model a site owner will want to extend — every directory has its own vocabulary for what people advertise — so it gets a normal Listings ▸ Offers admin screen, term editing, and search filtering for free rather than a list hard-coded in PHP. Seeding only ever touches an EMPTY taxonomy, so an admin who prunes the defaults is never overruled. Everything else here is a fixed vocabulary (a person's eye colour is not a site setting). The taxonomy is the only reason this is a Service: registered in Theme::$services for register(). The editor card is called from inc/_es/views/listing-form.php and the save from _es\ListingEditor::save().

API
register()relatedCount()defaultFields()lockedFields()fieldSeeds()seedFields()registerTaxonomy()fieldTaxonomy()taxonomyFields()seedFieldTaxonomies()singleTaxonomy()defaultStatuses()seedStatus()clearsOvernight()statusIds()statusPills()defaultMeeting()seedMeeting()defaultGenders()seedGender()genderTaxonomy()genderIdsFromForm()genderOptions()genderIds()
inc/_es/ProfileSpecs.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/_es/Reviews.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 | popular | newest | oldest | rating | price_low | price_high | title paged pagination

API
register()shapeQuery()nearCoords()nearText()hasNearFilter()radiusKm()distanceFrom()distanceLabel()radiusClausesMain()radiusClauses()featuredOrderClauses()popularOrderClauses()isListingContext()postType()isMapTakeover()categoryTax()terms()taxQueryFromRequest()listingTaxonomies()favOnly()favIds()hasActiveFilters()matchingIds()termCounts()
inc/_es/SearchPage.php

ShellRail ShellRail.php​

Escort Theme rail data for app-shell designs (Designs\Contracts\ShellRail): the "browse by city" list with LIVE counts, and the standard quick links that the fork's own pages answer (online now, new this week, video profiles, live now). Cities come from the published listings' city meta, grouped and counted; when the site has none yet (a fresh install, or the design showroom) the design's own demo list keeps the rail populated. City links open the search page with the near filter (the fork's text location match), and the row for the city being browsed is marked active.

API
archive()currentCity()cities()links()
inc/_es/ShellRail.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()demoBookingProvider()template()related()
inc/_es/SingleListing.php

Status Status.php​

Escort (_es) advertiser status — the admin side of the ProfileSpecs::TAX_STATUS taxonomy, plus the nightly sweep that stops a time-bound status going stale. The vocabulary itself (Touring / Taking bookings / On holiday / New) is seeded by ProfileSpecs and edited on the ordinary Listings > Status screen. This class adds the one thing a plain taxonomy cannot express: a status that is only true for today. A site owner ticks "Clears overnight" on a term, and at site-midnight the sweep takes that term off every listing carrying it. Nothing seeded ships with the flag on — none of the four defaults is a daily claim, and the two statuses that WOULD be ("available today", "active") are deliberately not in this taxonomy at all: the hours card and the presence heartbeat already answer those, and a checkbox beside a live signal can only contradict it. The flag exists for the vocabulary a site owner adds themselves.

API
register()schedule()sweep()addField()editField()saveField()
inc/_es/Status.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/_es/TermMeta.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/_es/Uploads.php