Skip to main content

Directory Theme class reference

41 min read

Dating Theme — Class reference

The 38 classes that make _da behave differently from the shared Directory core. Generated from the theme source.

← Back to Dating Theme features

Attention Attention.php​

Attention — the two member-hub panels that answer "who noticed me?": the people who liked this member on Hit or Miss, and the people who opened their profile. NEITHER PANEL IS NEW DATA. Both facts were already being recorded and neither was ever shown: {@see HitMiss::hitsIn()} has kept an inbox of likers since Hit or Miss shipped, and {@see ListingViews::recentViewers()} has kept logged-in viewers since the analytics table. This class is the surface for them, and the place their entitlements are read. LOCKED MEANS LOCKED. A member whose plan does not include the row gets a padlock and an upgrade button — never the list, and never the empty state either. Answering "nobody has liked you yet" to somebody who simply has not paid to find out is worse than saying nothing: it is a claim about their popularity that the site has no business making, and it reads as "this feature is broken". The gate is therefore the FIRST thing this class asks, before it ever looks at how much data there is. The count is the one thing that does cross the line, and only when it is greater than zero: "3 people like you" is the argument for upgrading. A zero is never shown to a locked member, because a zero is an answer, and answers are what the plan buys. On a site whose plans say nothing about these rows, {@see Entitlements::allows()} is true for everyone and no padlock ever appears — exactly as if this gate did not exist. PRIVACY, DELIBERATELY ASYMMETRIC. Being looked at is not an action a plan can buy: the gate is only ever on the person DOING the looking-back. Nobody's visit is hidden or revealed based on what THEY pay.

API
likesCount()renderLikesPanel()viewers()viewsCount()renderViewsPanel()winkers()winksCount()renderWinksPanel()locked()css()
inc/_da/Attention.php

Boost Boost.php​

Boost — a member lifts their own profile to the top of search for a set window. WHAT A BOOST IS. Not a badge and not a permanent rank: for the next 24 (or 48, or 72) hours this profile sorts above everything else, then it stops, silently. The member spends one of the boosts their plan grants them each month {@see Entitlements::BOOST}, so the allowance is the product and this class is only the mechanism. IT BEATS EVERY SORT, ON PURPOSE. The ordering clause is prepended to whatever the visitor chose — newest, featured, most-viewed, nearest — rather than being a sort option of its own. A boost the visitor can turn off by changing a dropdown is not something anybody would pay for. Within the boosted group the visitor's own sort still decides the order, so a boost lifts a profile to the front without flattening the page behind it. ONE TIMESTAMP, NO CRON. ppt_da_boost_until on the LISTING holds the unix time the window closes. Expiry is a comparison at query time, so nothing has to be swept up afterwards and a boost cannot outlive its window because a scheduled task was missed. IT IS ANNOUNCED ON THE CARD. A live boost puts a BOOSTED pill beside the owner's own badges (_da\ListingCard), which is a deliberate reversal of how this shipped: the first cut kept boosts invisible on the grounds that a member pays for attention rather than for a sign saying they paid. The client's call is that the badge is part of the product — it tells other members the profile is worth a look, and it shows buyers what their money bought. The pill is derived from the same timestamp the ordering reads, so it cannot outlive the window or contradict the position. WHAT IT STILL IS NOT. It re-orders the search page and nothing else — no home-page grid, no related strip.

API
register()supported()hours()sanitizeHours()until()isActive()remaining()anyActive()start()setByAdmin()orderClauses()ajax()
inc/_da/Boost.php

DailyMatches DailyMatches.php​

Daily Matches — each morning, the handful of profiles that best fit what a member said they are looking for (Ppt_da\Matchmaking), shown in the member hub's "Matches" panel and announced once with a bell notification. WHAT A DAILY MATCH IS. A real, published, non-expired profile owned by another member, that this member has not been shown in the last SEEN_DAYS, that scores at least the admin's minimum, and where neither side's Meeting-with rules the other out. Nothing synthetic ever enters the list: the virtual demo listing has no row to query, preview fixtures never reach this class, and a profile unpublished since the morning is dropped again at render time. The bell fires only when a list with at least one real profile was produced — never "someone is interested". ONCE A DAY, ONE PLACE. computeFor() is the only function that writes today's record, and it refuses to recompute (or notify) when a record for today already exists. The cron and the lazy fallback (the hub panel opening on a day the cron has not run — WP-cron is only as regular as the site's traffic) both go through it, so they can never double-notify. STORAGE. User meta, no table: ppt_da_dm_today {date:'Y-m-d', ids:int} today's list ppt_da_dm_seen [pid => ts] shown recently — not repeated for SEEN_DAYS A member pauses their own daily matches from the preferences card on their profile form; the admin switches the whole feature off in Settings ▸ Matchmaking.

API
register()schedule()unschedule()enabled()today()record()seen()forUser()pool()flush()members()computeFor()panelUrl()run()onUserDeleted()renderPanel()
inc/_da/DailyMatches.php

DemoProfiles DemoProfiles.php​

Dating-profile data for seeded demo listings (_da). Tools\SampleData seeds the SHARED listing fields — title, category, city, rating, photo — for every theme profile. That leaves a dating install's own fields empty: the About tiles (age / height / weight / ethnicity), the attribute rows (gender, orientation, nationality…), the matchmaking preferences and the status pills all read fork meta that nothing was writing, so a fresh dating site came up with blank profiles and search filters (Gender / Age / Nationality) with nothing to filter on. This is the one place that fills them, for all nine dating categories. The people here are the SAME members the designs already show on their own home pages, with the ages those cards print, so a seeded site matches the mockup the client picked and the numbers agree wherever both are on screen. NOT a copy of _es\DemoProfiles, even though it started as one. This fork ships no rate card, no services list and no "meeting with" combinations — see apply() — and its status vocabulary is New / Active today / On holiday, not the escort fork's Touring / Taking bookings. Do not sync the two files back together.

API
people()forCardTitle()apply()body()faq()contact()hours()specifications()
inc/_da/DemoProfiles.php

Entitlements Entitlements.php​

Dating entitlements — the rows a membership plan can switch on for the Dating Theme. WHY THIS FILE EXISTS. Core's {@see \Ppt\Account\Entitlements} ships only what core itself enforces (one row: members-only media). Everything else arrives through the ppt_entitlements filter, so a fork declares its own rows and a fork that is not active contributes nothing. This is the dating fork's declaration. EVERY ROW HERE IS ENFORCED. That is the whole point, and the rule for adding another: a row must name a real chokepoint that consults it, or it is decoration on a pricing page. The four below each sit on exactly one call: da_likes HitMiss::vote() a NEW hit; passes and re-votes are free da_winks Winks::send() da_friend_requests Friends::request() the request, never the accept da_gifts Gifts::send() da_who_likes_you Attention::renderLikesPanel() da_profile_views Attention::renderViewsPanel() da_daily_highlights the hub's Matches panel (DailyMatches::renderPanel()) da_advanced_filters Admin\Search::filterLocked(), via ppt_search_premium_locked da_intros Account\Messages::send(), via ppt_message_veto — a FIRST message only; replies in a live thread are never charged da_chat_matches the same veto, for writing to somebody you have matched with da_read_receipts Account\Messages::thread(), via ppt_message_read_receipts da_boost Boost::start() — a MONTHLY allowance, unlike the daily ones THE THREE PANEL ROWS SHOW THE COUNT AND HIDE THE NAMES. A member without them still learns that eleven people liked them; who those eleven are is the thing being sold. A vanished tab teaches nobody anything, and a zero would be a lie. THE GATES FAIL OPEN, and that is deliberate. {@see gated()} answers "is any active plan actually selling this?" and every gate asks it first, so on a site with no plans, no memberships feature, or plans that leave a row at zero, nothing is refused and the features behave exactly as they did before this file existed. A limit appears only once an admin has said, on a plan, what the limit is. QUOTAS ARE PER DAY. Dating limits are daily by convention ("10 likes a day") and the counters are the ones core already keeps — one user-meta row per key holding the current day bucket, reset by being read on a new day, with no cron behind it.

API
metering()freeIntros()introPrice()callsMetering()freeCalls()callPrice()callAffordable()register()rows()filterLocked()messageVeto()messageVetoCta()messageHint()readReceipts()gated()allows()take()upgradeUrl()refusal()
inc/_da/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/_da/Expiry.php

Friends Friends.php​

Friends — the Dating Theme's request/accept friendship graph, surfaced as the "Add Friend" button in the profile sidebar box (templates/parts/single-sidebar-top.php). A FRIENDSHIP IS MUTUAL AND CONSENTED. Unlike Account\Follows (a one-way follow a member can start on anyone, used by the auction/seller forks), a friendship here only exists once the other person has agreed. That is the whole reason this is a separate class rather than a reuse of Follows: on a dating profile "friend" is a relationship the other member gets a say in, not a subscription. THE FOUR STATES a viewer can be in with a target, all derived from the pair of lists below and returned by {@see status()}: '' nothing between them → button reads "Add Friend" 'sent' viewer asked, waiting → "Request sent" (click withdraws) 'incoming' target asked the viewer → "Accept request" (click accepts) 'friends' agreed, both directions → "Friends" (click unfriends) SIMULTANEOUS REQUESTS RESOLVE TO A FRIENDSHIP. If A asks B while B has already asked A, request() accepts instead of queueing a second pending row — two people who both reached for the button have plainly agreed, and leaving them each staring at "Request sent" would be a dead end neither could clear. WHO IS BEFRIENDED. On this fork the listing IS the person, so the edge is between the two USER accounts (the profile's author and the viewer), never the post — two profiles owned by one member are one person to this system, exactly as in {@see HitMiss}. STORAGE. User meta in the shape HitMiss and Account\Follows use — no table, so nothing to install and no migration: ppt_da_friends (on BOTH members) int[] accepted friend user ids ppt_da_friend_out (on the ASKER) int[] ids with a request outstanding ppt_da_friend_in (on the ASKED) int[] ids who have asked, awaiting a reply Every write updates both sides in step, so the two members can never disagree about the state: there is no "pending" that one side cannot see and no friendship recorded on one account only. ACCEPTING WITHOUT A HUB PANEL. The notification pushed on a new request deep-links to the asker's profile listing, where the button has already flipped to "Accept request" — so a request can always be answered even though the member hub has no Friends panel yet. A BLOCKED PAIR CANNOT BEFRIEND. Same guard HitMiss uses: if either has blocked the other in Account\Messages, every write is refused.

API
register()friends()sent()incoming()count()incomingCount()status()request()accept()cancel()profileUrl()ajax()label()op()onUserDeleted()
inc/_da/Friends.php

Single-listing gallery — the Dating Theme's OWN gallery. Like the Shop and the Escort Theme, this fork ships ONE gallery rather than a copy of the shared five-style set with a picker on Design ▸ Listings: - styles() lists exactly one style, split, which exists only here. It can never appear in another product line's dropdown, because every fork reads its OWN Gallery class (Ppt\Theme::forkClass('Gallery')) and nothing outside this fork loads this one. - locked() is true, so Admin\views\design.php drops the "Photo gallery style" card entirely and DesignPage::sanitize() refuses to store anything else. - current() ignores a stored gallery_style — one left by an earlier build, or carried in from another product line's saved design — so this holds on EXISTING installs too and not just fresh ones. The layout is the SPLIT PANEL: one tall photo on the left and FOUR smaller ones in a 2x2 grid beside it on the right, with a photo count 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. isFullSpan() is false: the panel sits INSIDE the content column, beside the sticky sidebar, which is where this fork's single.php then draws the "Photos" card. MEMBERS-ONLY items keep their slot but carry no media URL (Media::media() strips it before the gallery ever sees it). They render as a padlock tile — never as an empty frame, which is what the shared layouts did here. The LIVE STREAM is not in the gallery: it lives in the sidebar (Live::panel(), rendered by templates/parts/single-sidebar-top.php). Media is images AND videos in one set (Media::media() types every item), so a video plays inline when it is the hero and carries a play glyph when it is a tile.

API
styles()locked()current()isFullSpan()render()css()
inc/_da/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/_da/Geocoder.php

Gifts Gifts.php​

Gifts — the Dating Theme's paid tokens of interest, surfaced as the "Send Gift" button in the profile sidebar box, which opens a picker of the catalogue below. ALSO THE ESCORT THEME'S. Built for dating, wanted by escort (2026-09-13), and there is nothing dating-specific in how it works — so it TRAVELS rather than being cloned: the bootloader asks supported(), which reads the gifts capability, and that capability now names both profiles. Each fork draws its own sidebar surface (_da/ and _es/ templates/parts/single-sidebar-top.php) against this one backend, catalogue and admin screen. On escort no membership plan carries a gifts row (_da\Entitlements does not travel), so Entitlements::allows() fails open and a gift there is gated by credits alone. GIFTS COST CREDITS. This is the fork's revenue feature, not a free gesture: every gift has a price in credits and sending one spends them through Ppt\Credits\Wallet, the same wallet the rest of the theme bills against. A member with too small a balance is sent to top up rather than being refused with a dead end. WHY CREDITS AND NOT A CHECKOUT. A gift is an impulse worth a couple of taps; a Stripe round trip per rose would kill it. Credits are bought once, in a pack, and spent instantly — so the purchase friction happens at most once per member, not once per gift. THE SPEND IS THE GATE. send() debits FIRST and only records the gift if the debit succeeded, so a gift can never appear without having been paid for. If the write that follows fails there is a refund path (Wallet::refund) — but the ordering means the failure mode is a refunded member, never a free gift. WHAT THE RECIPIENT GETS. A notification naming the sender and the gift, and a row in their received list. Gifts are NOT anonymous: the point is to be noticed, and an anonymous paid gift is indistinguishable from harassment. ICONS ARE REAL SVG, NEVER EMOJI. Every catalogue entry ships an inline line-icon in the theme's stroke style, so a gift renders identically on every platform and sits with the rest of the sidebar's iconography. OR THE ADMIN'S OWN PICTURE. A site owner who wants a photographed rose rather than a line drawing uploads one per gift in PremiumPress ▸ Gifts (the media library). The image REPLACES the icon wherever that gift is drawn — picker tile, received wall, admin row — and the icon stays on the row as the fallback: remove the image, or delete the attachment, and the glyph is back. mark() makes that image-or-icon decision in one place so no surface can disagree with another. STORAGE. User meta, no table: ppt_da_gifts_in (on the RECIPIENT) array of {u:int, g:string, t:int}, newest first, capped at INBOX_MAX. ppt_da_gifts_out (on the SENDER) the same shape — their own sending history. Gifts STACK, unlike winks: five roses from one admirer are five gifts, because each one was paid for. A BLOCKED PAIR CANNOT EXCHANGE GIFTS, and no credits are taken when the guard trips.

API
register()registerSetting()sanitize()catalogue()imageUrl()mark()gift()fromPrice()enabled()supported()balance()topUpUrl()received()sent()sentTo()sentCount()send()ajax()onUserDeleted()
inc/_da/Gifts.php

HitMiss HitMiss.php​

Hit or Miss — the Dating Theme's verdict control, drawn over the main photo of the single-listing gallery (Ppt_da\Gallery::render()). WHAT A VOTE IS. A hit is a MATCH SIGNAL, not a public rating. There is deliberately no score, percentage or vote count anywhere on the front end: the member is told somebody liked them, and when the liking runs both ways the pair are told it is mutual and pointed at a conversation. Nobody is ever shown a number that says how many people missed them. WHO IS VOTED ON. On this fork the listing IS the person, so a vote is stored against the listing's AUTHOR, not the post — two profiles owned by one member are one person to this system, and a mutual match resolves between the two accounts. STORAGE. User meta, the same shape Account\Follows uses for followers/following — no table, so no migration and nothing to install: ppt_da_hits (on the VOTER) int[] user ids this member hit ppt_da_misses (on the VOTER) int[] user ids this member missed ppt_da_hits_in (on the TARGET) int[] user ids who hit this member The third is a denormalised inbox so "who hit me" and the mutual test are one read each rather than a scan of every user. vote() always writes the pair together. A vote is CHANGEABLE, never additive: hit-then-miss moves the id from one list to the other and withdraws it from the target's inbox, so a withdrawn hit cannot leave a stale match behind. NOT RENDERED for a signed-out visitor's click target (they get a link to sign in instead), on your own profile, or inside a page-builder / preview canvas — see render()'s guards and Support\EditorCanvas.

API
register()hits()misses()hitsIn()hitCount()choice()isMutual()vote()messageUrl()ajaxVote()onUserDeleted()loginUrl()render()css()js()
inc/_da/HitMiss.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/_da/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), boosted (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()isOnline()fromSample()render()
inc/_da/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()canEdit()adminEditUrl()memberEditUrl()applicableFields()locationKeys()coordKeys()mapPickerReady()mapConfig()locationFields()socialNetworks()socialValue()socialIcon()hoursDays()hoursValue()normTime()bookingValue()bookingCapacity()bookingBlackout()bookingTz()
inc/_da/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/_da/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/_da/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/_da/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/_da/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_da\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/_da/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/_da/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/_da/ListingViews.php

Live Live.php​

Go Live — browser broadcasting for the Escort Theme (_da). 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/_da/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 Live::panel() into the sidebar (templates/parts/single-sidebar-top.php) — this fork's gallery is the shared five-style photo set and carries no live half; 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/_da/Live.php

LiveChannel LiveChannel.php​

Escort (_da) 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_da\Live owns the endpoints, Ppt_da\LiveNow the /live/ directory.

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

LiveChat LiveChat.php​

Live chat — the room that runs alongside a broadcast on the Escort Theme (_da). 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_da_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/_da/LiveChat.php

LiveNow LiveNow.php​

"Live now" directory for the Escort Theme (_da). /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 _da list, so the route only exists on this profile.

API
register()url()rewrite()queryVar()maybeRender()isCurrent()current()
inc/_da/LiveNow.php

Matchmaking Matchmaking.php​

Matchmaking — the Dating Theme's preference-based match score. WHAT THE SCORE IS. A member says who they are looking for (the "Who I'm looking for" card on their profile form, plus the "Interested in" tile they already fill in) and every other profile is scored against that: "how well does this person fit what I asked for". The number shown is TWO-WAY — the average of how well they fit my preferences and how well I fit theirs — so a 95% is someone who would plausibly pick me back, not just someone I would pick. When only one side has set anything the score is one-way, and when neither has, there is no score at all. WHAT IT IS NOT. It is never a public rating. Only the signed-in viewer sees the number, computed against THEIR profile; nobody is shown how they score with the world, nobody's preferences are shown to anyone else (the "Why you match" card explains the viewer's own side only), and the copy everywhere says "based on your preferences" — it is a filter made visible, not a promise. That keeps it on the right side of the FTC action against Match.com, and of Hit-or-Miss's own rule that no vote count or popularity number is ever rendered. HOW IT IS COMPUTED. Six criteria, each answering 0..1 or "skipped": gender their gender term maps into my Meeting-with terms (women↔female …) age inside my range = 1, falling to 0 over AGE_FALLOFF years outside it distance within my radius = 1, falling to 0 at twice the radius interests shared Interests terms / min(count(mine), 3), capped at 1 smoking "non-smokers preferred": No 1 · Occasionally 0.5 · Yes 0 drinking "non-drinkers": No 1 · Socially 0.5 · Yes 0; "social ok": Yes 0 A criterion I never set, or that cannot be answered (no age on their profile, no coordinates on either), is SKIPPED — it leaves the denominator rather than counting as a miss, so an incomplete profile is scored on what is known. Criteria are weighted (Settings ▸ Matchmaking); weight 0 removes one entirely. STORAGE. Preferences are ONE post meta on the member's dating listing (on this fork the listing IS the person): ppt_da_prefs, an array. Gender is deliberately NOT in it — "Interested in" already asks that question, and asking twice would let the two answers disagree. The admin's weights and daily-match settings are their own registered option, like the Gifts catalogue, so a lazily-loaded settings panel can never wipe them. Everything is memoised per request: the viewer's own profile is resolved once and every card on a search page is then six cheap comparisons against cached meta.

API
register()fieldExists()savePrefsFor()ajaxPrefs()renderPrefsPanel()onUserDeleted()reasons()feedbackAll()feedback()feedbackIds()tune()setFeedback()clearFeedback()ajaxFeedback()feedbackMessage()verdict()supported()enabled()registerSetting()sanitize()defaultWeight()matchFields()criteria()taxKey()
inc/_da/Matchmaking.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/_da/Media.php

OneProfile OneProfile.php​

Dating Theme (_da): one profile per member. On a dating site the member IS their profile, so there is nothing to "add" once they have one. This service answers the two hooks the listing editor exposes: - ppt_listing_member_can_add → false once the member owns a profile, which hides every "Add profile" CTA (hub rail button, mobile FAB, the My Profiles panel button, the footer's "Add a listing" link — all of them read ListingEditor::memberCanAdd()). - ppt_listing_new_existing → the id of the profile they already own, so a direct hit on the add-listing action (a stale link, a bookmark, another tab) opens that profile's editor instead of minting another "Untitled profile" draft. "The profile they own" is the newest PUBLISHED one (Matchmaking::profileFor(), the same rule Friends and the match panels use), else the newest pending/draft — a member half-way through their first profile is sent back to finish it, not offered a second. Member mode only: the admin's Add New in wp-admin still creates freely. The same "member IS their profile" rule decides the member's AVATAR: the hub, the rail, messages, live chat and every get_avatar surface show the profile's own photo (its featured image), falling back to an account avatar only while the member has no profile picture yet (e.g. straight after sign-up, before the wizard photo has been copied over). Because the picture is derived, the hub's "upload a photo" control is off on this fork (ppt_avatar_editable → false) — the photo is changed on the profile, never on the account.

API
register()avatarUrl()canAdd()existing()profileFor()reset()
inc/_da/OneProfile.php

ProfileSpecs ProfileSpecs.php​

Escort (_da) 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 ("My interests" on this fork — see registerTaxonomy() for why the key still says offer) are a real TAXONOMY (self::TAX_OFFER, listing_da_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 ▸ Interests 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/_da/views/listing-form.php and the save from _da\ListingEditor::save().

API
register()relatedCount()defaultFields()lockedFields()fieldSeeds()seedFields()registerTaxonomy()fieldTaxonomy()taxonomyFields()seedFieldTaxonomies()singleTaxonomy()defaultSmoking()defaultDrinking()seedSmoking()seedDrinking()defaultStatuses()dailyStatusSlugs()seedStatus()clearsOvernight()statusIds()statusPills()defaultMeeting()seedMeeting()defaultGenders()
inc/_da/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/_da/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/_da/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/_da/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/_da/SingleListing.php

Status Status.php​

Dating (_da) member 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 (New / Active today / On holiday) 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. Exactly one seeded term ships with the flag on — "Active today", the only daily claim in the set. It stays honest beside the DERIVED signals rather than contradicting them: the hours card answers "available now" and the presence heartbeat answers "online", both live; this one only says "I looked in today", and the sweep guarantees it cannot still be saying so tomorrow. Anything else a site owner adds can carry the flag too.

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

VideoCall VideoCall.php​

Private 1:1 video calls between two members (_da). NOT the same feature as Ppt_da\Live, and the distinction is the whole reason this file exists. Live is BROADCASTING — one profile publishes to an audience through Cloudflare Stream (WHIP in, HLS out), with LiveChat as the room beside it. Every byte of that media goes through a provider, is recorded, and is meant to be watched by strangers. A dating site needs the opposite: two people, nobody else, nothing stored. So the media here is PEER-TO-PEER WebRTC and never touches this server or any provider. What the server does is the one thing two browsers cannot do for themselves — introduce them. That is all this class is: a signalling relay. TRANSPORT. WordPress on Plesk has no websockets, so signalling is POLLED, the same two-speed idea LiveChat already uses: fast (1s) while a call is being set up or is ringing, slow (10s) while a member is merely reachable. Every fetch is "give me everything after id N" against one indexed key, so an idle site costs one small query per member per ten seconds and a call costs a handful more for the few seconds it takes to connect. Once connected, polling drops back to the slow lane: the media flows directly between the two browsers and the server has nothing left to say. TWO TABLES: {prefix}ppt_da_calls one row per call — who, whom, state, when {prefix}ppt_da_call_signals the SDP offer/answer and ICE candidates, deleted the moment the call ends Signals are transient by design. They are useless after the handshake, they describe the members' network addresses, and keeping them would be both a growing table and a privacy leak — so ending a call wipes them, exactly as LiveChannel::setState() wipes a chat room. Nothing about the CONVERSATION is ever stored: no recording, no transcript, not even a duration. PRIVACY AND CONSENT, which on this product line is not a footnote: · a call must be ACCEPTED — there is no auto-answer and no way to open somebody's camera without them pressing a button; · blocking works — Account\Messages::isBlocked() is checked both ways, so a member who blocked somebody cannot be rung by them; · a ringing call expires by itself after RING_TIMEOUT, so a missed call cannot sit on somebody's screen; and · either side hanging up ends it for both, and wipes the signalling. TURN. Peer-to-peer reaches most pairs of browsers through STUN alone, but two members both behind symmetric NAT (a lot of mobile networks) can only connect through a TURN relay. Without one configured those calls simply fail to connect, which is why iceServers() reads optional TURN credentials from Settings and the admin screen says plainly what happens when they are absent. STUN is free and defaulted; TURN is not, and pretending otherwise would mean shipping a feature that works on the developer's network and not on a phone.

API
register()enabled()allowed()canCall()iceServers()table()signalTable()install()call()finish()ajaxStart()ajaxPoll()ajaxAnswer()ajaxDecline()ajaxEnd()ajaxSignal()person()assets()render()button()
inc/_da/VideoCall.php

Winks Winks.php​

Winks — the Dating Theme's one-tap "I noticed you" ping, surfaced as the "Send Wink" button in the profile sidebar box. WHAT A WINK IS. The lightest possible opener: no message to compose, no credits to spend, no reply expected. It exists so a member who is not ready to write the first message still has something to send, and so the recipient gets a real signal rather than an anonymous view count. IT IS NOT A LIKE. {@see HitMiss} records a private verdict that can produce a match; a wink is a public-to-the-recipient greeting that says who sent it and never feeds matching. The two are deliberately separate signals. RATE LIMITED, PER PAIR. One wink to the same person per {@see COOLDOWN} (24h). The limit is what keeps a wink a greeting rather than a way to hammer somebody's notification bell — a second tap inside the window is refused, with the time left reported so the button can say so instead of failing silently. STORAGE. User meta, no table: ppt_da_winks_out (on the SENDER) array<int targetId, int timestamp> — last wink sent to each person; drives the cooldown. ppt_da_winks_in (on the RECIPIENT) array of {u:int, t:int}, newest first, capped at INBOX_MAX — who winked and when. A BLOCKED PAIR CANNOT WINK. Same guard as HitMiss and Friends.

API
register()sentMap()received()cooldownLeft()waitLabel()canSend()send()ajax()onUserDeleted()
inc/_da/Winks.php