Skip to main content

Account

42 min read

Account

Shared across every PremiumPress product. 39 classes in inc/Account.

AccountEdit AccountEdit.php​

PPT account editing — the AJAX save layer behind the Member Hub's Account tab. Each editable row (name, display name, email, phone, residential address, password) posts here; every change is validated and written with WordPress-native APIs (wp_update_user / user meta / wp_set_password). Returns the fresh display value so the row updates in place without a reload.

API
register()nonce()notSet()maskEmail()displayName()handle()renamePastComments()maybeConfirmEmail()cleanPhone()addressParts()formatAddress()
inc/Account/AccountEdit.php

AccountType AccountType.php​

What KIND of account this is — asked once during sign-up, read forever after. The escort fork had a single shape of member: an advertiser. Sign-up asked her gender, her age, what she offers and for a photo, and the Member Hub handed her a "My Profile" group over her own listing. Two other people use the same site and got that same treatment: an AGENCY, which advertises other people rather than itself, and a CLIENT, who advertises nobody and is only here to look. An agency was asked its own date of birth; a client was given a profile listing she never wanted. So the wizard asks, in one step, right after the age gate, and this class owns everything that answer decides: - the vocabulary (self::options) — the three choices and their wording - the QUESTIONS each type is then asked (self::flowChanges), applied to the profile's step list by Account\SignupSteps::keys() - the WORDING those questions use (self::overrides) — an agency picks an agency name and uploads a logo, not a working name and a profile photo - whether finishing sign-up seeds a profile listing at all (self::seedsProfile) The Member Hub then reads self::forUser() to decide what the dashboard is FOR: a roster ("My Escorts") for an agency, a browse-only hub with no advertiser groups for a client, and the profile hub the fork already had for an independent escort. See inc/Account/views/parts/hub-data.php. Deliberately a plain static class with no hooks: sign-up, the hub, the listing editor and Account\AccountEdit all need the answer, and none of them should have to reach into the sign-up wizard's private meta map to get it.

API
flavour()offersModelling()isModelling()rosterLabels()asks()options()offered()only()isType()label()shortLabel()forUser()effectiveFor()set()stored()clear()current()flowChanges()applyToFlow()overrides()seedsProfile()isAgency()isClient()
inc/Account/AccountType.php

AddressLookup AddressLookup.php​

Postcode → address lookup for the Member Hub's residential address row, backed by OpenStreetMap's Nominatim (keyless, free). WHAT THIS CAN AND CANNOT DO. Free OSM data has no house-level delivery-address list: that is what Royal Mail's PAF (and the paid APIs built on it) sells. So this returns the STREETS / localities inside a postcode plus the town, county and country, which is enough to confirm the postcode is real and fill most of the form — the member still types their house number. Anything promising a full "pick your house" dropdown for free is quietly using a paid source. The UI is built around that limit rather than hiding it: manual entry is always one click away and never blocked by a failed lookup. Nominatim's usage policy caps this at ~1 request/second with an identifying User-Agent and asks that results be cached, so: - the call is made SERVER-side (a browser-side call would leak an unidentified UA per visitor and be uncacheable), - successful results are cached for a month (postcodes do not move) and empty results for a day (so a typo is not re-asked on every keystroke), - each user is throttled to one uncached upstream call per second, - the endpoint is wp_ajax_ only (never wp_ajax_nopriv_), so logged-out traffic can never turn the site into an open proxy against Nominatim.

API
register()nonce()defaultCountry()handle()
inc/Account/AddressLookup.php

AuthorProfile AuthorProfile.php​

Public member profile — a dedicated seller/author page rendered on the WordPress author archive (/author//, the URL every seller card already links to via get_author_posts_url()). So instead of the generic blog-post archive, visiting a member's author page shows their profile: avatar, name, location, follower count, Message / Follow actions, bio, and a grid of their listings — split into "Upcoming" and "Past / Sold" on auction sites (fork _at), a single listings grid elsewhere. The page renders inside the normal theme chrome (header/footer + design tokens), so it inherits the active design's brand colour and looks of a piece with the Member Hub (its own --pf-* palette mirrors member-hub.css's --hub-*). Follow uses the existing Ppt\Account\Follows graph and follows.js (both already loaded site-wide), so the button and live follower count work with no extra JS.

API
register()closeArchive()template()assets()listings()reviewsEnabled()reviews()stars()reviewAvatar()location()initials()demoUrl()demoPersonUrl()demoPerson()demoPersonBio()queriedUser()
inc/Account/AuthorProfile.php

Avatar Avatar.php​

PPT member avatars — lets a logged-in member upload a profile photo from the Member Hub (click their avatar). The photo is stored as a normal media-library attachment; its id is kept on user meta. url() resolves the sized image, and a get_avatar_data filter makes the uploaded photo show wherever WordPress renders an avatar (comments, reviews, etc.). Falls back to initials when none is set.

API
register()nonce()has()editable()url()handleUpload()store()handleRemove()filterAvatarData()
inc/Account/Avatar.php

Collections Collections.php​

Buyer collections — named boards a shopper saves assets into ("Website heroes", "Client X"), on top of the flat "general collection" (the existing Favorites list, Ppt\Directory\ListingActions). On the Stock Photo Theme the Save control becomes a dropdown: the general collection + any named collections + "New collection". Stored as user meta (ppt_collections): a list of {id, name, created, items:[pid]}. All mutations go through here (idempotent add/remove, bounded sizes). A sibling to Ppt\Account\Follows / SavedSearch. The general collection is NOT duplicated here — it stays the ListingActions favorite so the existing heart/Favorites tab keep working; this class only owns the named collections. The Marketplace uses the same boards as named wishlists (owner, 2026-09-28): "Saved lists", "New list", the general collection reads "Favorites", and there is no credit-licence action (bulkAvailable() needs the Stock Photo classes). self::word() holds the per-fork wording.

API
register()active()word()all()count()get()create()rename()delete()toggleItem()removeItem()has()ajaxList()ajaxCreate()ajaxToggle()ajaxRename()ajaxDelete()ajaxRemoveItem()bulkAvailable()bulkQuote()ajaxLicenceQuote()ajaxLicenceAll()assets()renderPanel()
inc/Account/Collections.php

CommentVotes CommentVotes.php​

Listing Q&A comment voting — thumbs up / down with per-user de-duplication. Members validate a question (or a reply) with an up or down vote so useful questions rise. One vote per member per comment: clicking your current vote removes it; clicking the opposite side moves it. Guests may not vote (they see the counts and are prompted to sign in) — the AJAX handler rejects logged-out requests, so exposing the nonce to guests is harmless. Storage lives on the comment (comment_meta): two lists of voter user-ids ({@see VOTERS_UP} / {@see VOTERS_DOWN}) that are authoritative, plus a cached integer tally ({@see UP_META} / {@see DOWN_META}) kept in step for cheap reads. Rendering is done by Ppt\Directory\Qanda (the shared Q&A thread), which calls {@see counts()} and {@see nonce()}; this class owns only storage + the endpoint. Registered under Ppt\Account\ (not Ppt\Directory) so it boots on every profile — the Directory services step aside when a fork is active, and every comment profile (Classifieds / Auction / Freelancer / …) is a fork. The endpoint is inert unless a real vote arrives, so it needs no theme-profile gate.

API
register()handle()counts()nonce()
inc/Account/CommentVotes.php

DemoFurnish DemoFurnish.php​

What a typed DEMO member finds in their hub — the theme-independent part. The home-demo's one-click logins (Account\DemoLogin) exist to show a prospective buyer of the theme what the members area looks like in use. DemoLogin already lends an ADVERTISER type (independent / agency, owner / agent, employer …) a share of the seeded listings; this furnishes the rest of the story with the shared machinery every fork has, so no fork needs its own code to look lived-in: - both sides exist: provisioning a browser (CLIENT) provisions the independent advertiser too, and vice versa, so there is someone to have favourited, followed, messaged and reviewed; - the BROWSER has favourites (the advertiser's listings plus a few showroom rows), follows the advertiser, has a three-message inbox thread with them about one listing, and has left a 5-star review on it (when the fork's reviews are the comment-based kind — Ppt`\Reviews with a reviewcomment type); - a fork adds what only it has — job applications, order rooms — on theppt_demo_furnish_pairaction, or opts out of all of this with theppt_demo_furnish_generic` filter when it builds its own story (Micro Jobs does). Everything is recorded on both members (self::META) and removed by unfurnish() when DemoLogin purges the accounts at go-live.

API
register()furnish()unfurnish()
inc/Account/DemoFurnish.php

DemoLogin DemoLogin.php​

PPT — throwaway front-end demo member, for the pre-launch DEMO state. While the theme is in demo mode (no active design — the same rule Ppt\Frontend\Preview::isDemo() / Social::isDemo() use), the branded Member Login screen is pre-filled with the credentials test / test so a visitor can sign straight into the themed members area and explore the buyer/member experience without hunting for a login. This class owns the matching account so those credentials actually work: - a plain subscriber named test (password test), created on demand the first time the demo login screen is shown, and tagged with a marker user-meta so we only ever manage the account WE created (never a real user who happens to be named test). - auto-removal of the account the moment a real design is activated (go-live), so a live site never carries a test / test login. MULTISITE: wp_users is GLOBAL, so one test row serves the whole network. The account a subsite needs may therefore already exist — created by a sibling subsite, by an older install, or by a real person — carrying a password that is NOT test, which is why a network used to advertise pre-filled credentials that could never authenticate. Provisioning is built around that: resolveAccount() takes the first candidate login (test, test2, …) that is free or safely ours, ensurePassword() re-stamps the advertised password on the account we manage, and attachRole() joins it to whichever subsite is being viewed. The login name the card shows is per-site (see loginName()), never assumed to be the constant. The wp-admin read-only counterpart is Ppt\Admin\DemoAccess (the one-click demo admin preview); this is deliberately the front-end members-area equivalent. TYPED demo members (2026-09-20): on a fork that asks what kind of account a member is (Account\AccountType — Micro Jobs buyer/freelancer, Jobs employer/candidate, Real Estate seeker/owner/agent, Escort browsing/independent/agency) the home-demo offers one ONE-CLICK login per type (self::loginUrl / handleMemberLogin). Each is its own throwaway account, test_<type>, carrying the same marker as the plain test member, so it is refused on a live site and purged at go-live the same way; advertiser types are lent a share of the seeded listings (lendContent) so their hub is furnished, and hand them back at purge. A DEMO SITE NEEDS NO LISTINGS (owner's ruling, 2026-10-02). Every public page in demo mode is virtual - design previews, the ?ppt_demo= single pages, search and archives all draw sample cards. Only the members area reads real rows, so each demo account builds the few it needs (createRows / createRowsFor), demo-marked and deleted at purge. The one exception is PrivateLet, whose public /book/ page needs its single bookable property (autoSeed, AUTOSEED_PROFILES).

API
register()blockOnLiveSite()isDemoMode()loginName()hasAccount()demoUserId()ensureAccount()loginUrl()landingUrl()productNiches()offeredTypes()handleMemberLogin()isDemoSession()ensureTypedAccount()autoSeed()ownedHere()createRowsFor()landingDesignKey()siblingDesignKeys()purge()onDesignAdded()onDesignUpdated()
inc/Account/DemoLogin.php

Entitlements Entitlements.php​

Entitlements — the machine-readable rows a membership plan can switch on. WHY THIS EXISTS. Admin\Memberships has always stored a plan's features as a plain array of DISPLAY STRINGS: the seeded sample plan literally says "Up to 5 listings" while nothing anywhere counts a member's listings. The only per-plan flag that ever granted anything was the single boolean lockedMedia. So a site could describe a Free / Silver / Platinum / Diamond table but could not enforce one. This class is the registry of what a plan is ALLOWED TO SAY. Each row is one line of that table — "See who likes you", "Send one free intro a day" — with a type the admin grid knows how to edit and the gates know how to read. Plans store their answers under their own caps key; the answers are read back through Account\Membership, which stays the ONE place that resolves "may this user do X". WHO REGISTERS ROWS. Core ships only what core enforces. Every fork adds its own through the ppt_entitlements filter, the same way SocialProof\Kinds lets each product line declare its own kinds — so nothing dating-specific lands in this file and a fork that isn't active contributes nothing. TWO ROW TYPES, THREE BEHAVIOURS: bool on or off. "Access to advanced filters" quota N per day or per month; "Send one free intro a day" -1 (UNLIMITED) means no ceiling. "Send unlimited intros" A periodic ALLOWANCE ("5 free Super Likes a month", "a monthly Boost") is the third behaviour and is deliberately a monthly quota rather than a grant into the credits ledger: Credits\Wallet keeps ONE balance per user, so credits issued as a Boost could be spent on a gift and vice versa. A row may instead declare topup, which lets the gate fall through to the wallet at a fixed price once the period's allowance is gone — "5 free a month, buy more after that" — reusing the existing spend idiom without making the allowance itself fungible. COUNTERS. One user meta row per key, holding the current period bucket and its count: ppt_ent_<key> => array('b' => '2026-09-07' | '2026-09', 'n' => 3) A changed bucket resets the count when it is next read, so there is no cron to schedule and no history to prune. An unlimited row never writes at all.

API
all()get()has()groups()normalise()planValue()coerce()bucket()metaKey()used()record()reset()resetAll()describe()cell()
inc/Account/Entitlements.php

Feedback Feedback.php​

PPT — user-to-user feedback (buyer ⇄ seller reputation). After a transaction completes, the two parties rate each other once (1–5 stars + a comment). Ratings are mutual-blind: neither side sees the other's until BOTH have left theirs, or a configurable window (Settings ▸ feedback_window_days) elapses — the eBay model, which stops tit-for-tat retaliation. Until then the author may still change their own stars and comment (a grace period — see update()); once revealed it is final. The rated party may post one public reply. A member's aggregate score surfaces on their public author profile and on the seller card of a listing. This is deliberately distinct from listing REVIEWS (the per-fork Reviews classes, which rate the item via WP comments). Feedback rates the person, keyed to a sale. Shared across forks (non-fork namespace so Theme::boot()'s fork mutual-exclusion never filters it). It self-gates to the profiles that have a real two-party transaction via ThemeProfiles::supportsFeedback() (auction, classifieds) AND the admin's feedback_enabled toggle. The buyer/seller pair per sale is resolved by transactionFor(), which branches by profile: - auction → lot auction_status === 'sold' + auction_winner_id (+ its ppt_orders row) - classifieds → listing meta ppt_sold + ppt_sold_buyer (written by _ct\MarkSold) Storage mirrors Account\Messages: a versioned custom table created via dbDelta on init.

API
register()active()table()install()perOrder()listingOf()rowKey()transactionFor()roleIn()submit()update()canEdit()reply()windowDays()maybeReveal()listingScore()revealSweep()panelUrl()refreshAggregate()score()hasRated()received()given()pendingFor()
inc/Account/Feedback.php

Follows Follows.php​

Follow a seller — a persisted, user-to-user follow graph surfaced on the seller card (auction lots today; reusable by any fork). Logged-in members follow a listing's author; guests are routed to sign-in. Storage is a bidirectional edge kept in user meta (mirrors the favourites pattern in each fork's ListingActions rather than adding a table): - ppt_followers on the SELLER — int[] of follower user ids (authoritative). - ppt_following on the FOLLOWER — int[] of seller user ids (convenience index for an "accounts you follow" listing; never the source of truth for state). The follower count and the "am I following?" check both read the authoritative followers list, so the button state can never disagree with the count. The convenience index only powers the reverse lookup (who a member follows), which a full followers list scan would otherwise make expensive. On user deletion both sides are pruned from the deleted user's own two lists — bounded work, no full-table scan — so counts stay honest.

API
register()followers()following()count()isFollowing()toggle()ajaxToggle()onUserDeleted()assets()
inc/Account/Follows.php

Handles Handles.php​

Member handles — the public @name, and the WordPress username behind it. WHY THIS EXISTS. Both places that create an account used to derive the username from the email address: mark.andyami@gmail.com became the user markandyami, which the profile then prints as @markandyami and puts in /author/markandyami/. That is two problems at once. It publishes a piece of someone's private email to every visitor — a member who signed up as firstname.lastname@work.com has just published their full name and employer. And it hands out half of their credentials: a login name that reconstructs the email address is an invitation to credential stuffing, since the same string is very often the account name elsewhere too. A handle should come from what the member has already agreed to show in public — the name they typed on the way in — and nothing else. Never the email, never anything derived from it. Uniqueness is a short random suffix rather than a counter: mark2 tells the next visitor there is a mark1, and walking the numbers enumerates the membership. Four characters from an unambiguous alphabet is enough for a site of any realistic size, and it reads like a handle rather than a database row.

API
make()base()
inc/Account/Handles.php

HubIcons HubIcons.php​

Member Hub icon set — the Lucide-style stroke glyphs the hub chrome draws (rail groups, section links, top-bar buttons, notification rows). Shared so the dashboard (views/account.php) and the listing editor (views/listing-edit.php) can never drift apart on an icon: both render their rail from this one map.

API
svg()
inc/Account/HubIcons.php

HubPrefs HubPrefs.php​

Member Hub per-user preferences — the shell state a member chooses once and expects to find again. Today that is the navigation rail: expanded (labelled, 268px) or collapsed (the compact icon rail). The choice used to live in localStorage, which made it per-browser — the same member got a different hub on their phone, on a second machine, or after clearing site data. It is now user meta, so it follows the account. The default for a member who has never touched the toggle — a first log-in — is CLOSED: the hub opens compact and the member expands it if they want the labels. Sites that prefer the opposite can flip it with the ppt_hub_rail_default filter. Both hub screens (the dashboard, inc/Account/views/account.php, and the listing editor, inc/Account/views/listing-edit.php) render the stored state onto server-side, so there is no flash of the wrong width, and post back here when the member toggles it. Colour mode (data-hub-mode) is deliberately NOT stored here — light/dark is a per-device choice that tracks where you are reading, not who you are.

API
register()rail()railIsOpen()setRail()nonce()handleRail()
inc/Account/HubPrefs.php

Invoices Invoices.php​

PPT members area — Invoices / billing. Two things: • A members-area "Invoices" panel (rendered into /account#invoices): the member's account balance (paid to date + anything still due) and a table of their orders, each linking to a printable invoice. • A standalone printable invoice at /account/invoice/<orderId>/ (owner or admin only) — an HTML page styled for print, with a "Print / Save as PDF" button. Orders are the existing ppt_orders records (see Admin\Orders + Payments\CheckoutFlow); this class only reads them.

API
register()rewrite()queryVar()invoiceUrl()maybeRender()company()record()ordersFor()balance()gatewayLabel()statusLabel()renderTab()
inc/Account/Invoices.php

LockedMedia LockedMedia.php​

Members-only media, guarded at WordPress's own doors. A fork's Media::media() decides what the GALLERY shows: a locked photo or clip keeps its slot, loses every URL, and is drawn as a padlock tile. That is the right answer for the page, and it is not the whole answer — because WordPress publishes the same attachment through several other doors that never pass through the theme's gallery: 1. The REST media route. GET /wp-json/wp/v2/media?parent=<listing id> returned source_url for every members-only photo on a listing, to a signed-out visitor, in one request. That is the entire paid set, enumerable from the listing id. 2. The attachment permalink (/listing///, or ?attachment_id=N), which serves — or on a modern install redirects straight to — the original file. This service shuts both, for every fork, on the ONE fact that marks a file private: the _ppt_media_locked meta the listing editor writes onto the attachment. The entitlement question is not re-answered here — Membership::canSeeLocked() owns it, so the owner, an editor and a paid-up member keep working exactly as they do in the gallery, and a change of rule there changes both places at once. WHAT THIS DOES NOT DO. The file itself still sits at a public uploads URL, as every WordPress upload does. Closing these doors removes the way to LOOK IT UP; it does not make a correctly guessed path 404. Doing that means keeping locked originals out of the guessable tree and serving them through a signed route (the pattern in Ppt_ph\Download), which changes how files are stored and how a CDN in front of the site must be configured — a deliberate, separate step, not something to do quietly underneath an existing site's media library.

API
register()isLocked()isGated()filterRestQuery()filterRestItem()guardAttachmentPage()
inc/Account/LockedMedia.php

LoginPage LoginPage.php​

PPT branded login + register — rendered directly ON wp-login.php (hijacked via the login_init hook, no redirect). Colours follow the brand tokens, so the card re-skins with the admin's primary/secondary. Real WP auth + registration, plus the enabled (or, in demo mode, example) HybridAuth social buttons. Only the login/register views are taken over; logout, password reset and the interim-login modal are left to WordPress.

API
register()siteLostPasswordUrl()url()perSiteRegistration()onLoginInit()
inc/Account/LoginPage.php

MemberFields MemberFields.php​

Settings ▸ Sign-up & account fields — which questions sign-up asks, and which rows a member can change on their Account page. Two rule sets, one home, so the three places that must agree can't drift: - Sign-up (/join/): each question on this site type's list is Required, Optional (the step gets a "Skip for now" link) or Off (the step is not asked). Read by SignupSteps::keys() (Off drops the step) and SignupSteps::def() (sets req, which both join.php's Skip link and SignupWizard's server-side validation obey). - Account page (Member Hub ▸ Account): each row is Editable, Read-only (the stored value shows, with no way to change it) or Hidden. Read by parts/hub-panels.php and ENFORCED by AccountEdit::handle(), so a hand-made request can't edit a locked row. Locked items never take an admin value: email / password / terms / verify are how an account exists and is reached, adult and dob are the legal age gates on dating and escort sites, and acctype decides which later steps exist at all. Email and password stay editable on the Account page for the same reason — they are how a member signs in.

API
signupSteps()signupQuestion()signupDefault()signupState()signupOff()signupRequired()profileFields()profileState()canEdit()shows()sanitizeSignup()sanitizeProfile()
inc/Account/MemberFields.php

MemberLanguage MemberLanguage.php​

PPT — the member's own display language. The header's language picker has always been a COOKIE (ppt_lang): a choice made by a BROWSER, not by a person. Clear site data, pick up a phone, or sign in on a second machine and the site is back in the owner's language — so a member who reads Spanish on a site published in English had to re-pick it every single time. So the preference now lives on the ACCOUNT, as user meta, and the cookie becomes the per-device echo of it rather than the record of it. While signed in the stored preference wins; the header picker (and the sidebar rail's copy of it) WRITES the same preference when a signed-in member uses it, so the two surfaces can never disagree — pick Français in the header, and the account row says Français on the next device. Only what THAT MEMBER sees changes. Signed-out visitors, and members who have chosen nothing, still get the site's own language, and wp-admin is untouched (Frontend\HeaderTools::filterLocale() returns early there) so this can never strand an administrator in a language they cannot read. The setting only appears when the site actually offers a choice — 2+ languages ticked in Settings ▸ Language ▸ "Languages offered". Whether the header's picker is switched on is a SEPARATE question and deliberately not part of the gate: an owner can let members read the site in their own language without putting a flag dropdown in the header. Deliberately a plain static class with no hooks, like Account\AccountType: the hub view, Account\AccountEdit and Frontend\HeaderTools all need the answer, and the resolution order has to live in exactly ONE place or the header pill and the page underneath it drift apart. NOTHING on the read path may call __(). forUser() is reached from the locale filter, which runs before init — translating there re-opens the WP 6.7 "_load_textdomain_just_in_time" notice. Only the display helpers at the bottom (followLabel/displayValue), which the hub view and the AJAX handler call long after init, are allowed to translate.

API
offered()available()forUser()effectiveFor()set()syncCookie()clearCookie()siteLocale()label()followLabel()displayValue()
inc/Account/MemberLanguage.php

Membership Membership.php​

Membership entitlement — the ONE answer to "may this user do X?". Nothing else in the theme resolved this before: the plan meta Admin\Memberships writes was only ever read by the expiry cron, the account page and the admin user filters. Every gate should come through here so the rule is stated once. WHAT A PLAN GRANTS lives in Account\Entitlements — a registry of typed rows (bool, or a quota per day/month) that forks add to. This class answers questions about a USER against that registry; Entitlements answers questions about the registry itself and owns the usage counters. Keeping the split means a gate never has to know whether a row is a switch or an allowance: it calls can() or consume() and is told. THE FREE TIER IS A REAL PLAN. A signed-in member with no membership, or whose membership has lapsed, resolves to the first enabled plan priced 0 — so what a non-paying member gets is edited in the same admin grid as every paid tier and is never hardcoded here. A site with no £0 plan simply grants such members nothing. SIGNED-OUT VISITORS GET NOTHING. can() and friends are false at user id 0 without consulting a plan at all; the sign-in prompts that already exist (HitMiss's login link, Frontend\ContactGate) are what a visitor sees, not an upgrade pitch.

API
isActive()plan()effectivePlan()freePlan()can()limit()used()remaining()consume()sells()mediaPlans()gateAvailable()gateSellableTo()upgradeUrl()unlocksMedia()canSeeLocked()
inc/Account/Membership.php

MembershipAudience MembershipAudience.php​

WHO memberships are sold to — an on/off switch per account type. The escort fork sells one thing with a plan: unlocking members-only photos and videos. That is a BROWSING member's benefit. An escort and an agency are here to advertise, not to buy access to each other's galleries, so a pricing table in their dashboard is an upsell for something they will never want — and, worse, a padlock they cannot pay their way past. So the Memberships admin screen carries three switches (Settings ▸ Memberships, rendered only where Account\AccountType::asks() is true), and this class is the one answer every membership surface asks: showsToUser() may this member be SOLD a plan — the cards, the compare table, the Wallet ▸ Membership tab, /memberships/, the checkout viewsPlan() may this member SEE a membership at all — the above, OR they are holding a paid plan already and must never lose sight of it ungated() this member cannot buy, so nothing may be locked behind buying DEFAULTS: browsing members ON, escorts and agencies OFF (client's call, 2026-09-08). THREE CASES THAT ARE DELIBERATELY NOT HIDDEN, because each would read as a bug: - A SIGNED-OUT visitor has no type yet and is very likely a prospective client. /memberships/ and its footer link stay public; the route registration in Frontend\Pages therefore stays global, and only a signed-in member of an off type is steered away from the page. - A member with NO stored type — every member an existing site already had before the sign-up question existed. Sign-up treats unanswered as the fullest (solo) flow, but doing that here would switch memberships off for a whole live site's back catalogue overnight. Unanswered is therefore always shown. Filter ppt_membership_audience_type if a site wants otherwise. - A member of an off type who HOLDS A PAID PLAN — bought before the switch flipped, or assigned in Users. They keep the Membership tab and the plan box; what they lose is the cards and every "Choose membership" CTA. Nobody is quietly billed for something they can no longer see. On every profile that does not ask the question (dating, shop, booking, the whole rest of the theme) every method here is a flat yes and nothing changes.

API
scoped()map()showsTo()showsToUser()viewsPlan()holdsPaidPlan()ungated()save()rows()hiddenMemberCount()
inc/Account/MembershipAudience.php

MembershipCheckout MembershipCheckout.php​

Membership checkout — buying a plan. Until now a membership could only be ASSIGNED, by an admin, in PremiumPress ▸ Users. The public pricing table's buttons pointed at /account?plan=`` and nothing anywhere read ?plan=, so on a live site nobody could ever reach a paid tier. Ppt\Admin\ Memberships said as much in its own header: "checkout … [is a] separate follow-on phase". This is that phase. A sibling of Ppt\Credits\Checkout rather than of Payments\CheckoutFlow, because what is being bought belongs to a USER, not to a listing: same ppt_gateway_start / ppt_gateway_verify contracts, same shared ppt_orders CPT, same /checkout/ + /callback/ virtual pages, but keyed on the buyer. Order refs are prefixed MEMBER- so Frontend\Pages routes callbacks back here. WHAT COMPLETION DOES. Writes the plan id and its expiry to the two user meta keys the whole theme already reads (Memberships::USER_META / EXPIRES_META), re-arms the expiry reminder cron, and fires ppt_sync_membership_features — the membership analogue of CheckoutFlow's ppt_sync_plan_features hook, so anything that needs to react to a tier change has one documented place to do it. UPGRADING NEVER SHORTENS A PLAN. A member with time left who buys again is extended from the LATER of now and their current expiry. Anything else would let somebody pay money and end up with less than they had. A £0 PLAN NEVER TOUCHES A GATEWAY. It is granted on the spot — there is nothing to charge, and sending somebody to a payment form for nothing would be absurd.

API
register()buyUrl()callbackUrl()isMembershipRef()planFor()expiryFor()grant()totals()handlePay()markPaid()orderContext()renewOrder()endSubscription()activeSubscriptionOrder()subscription()handleCancel()handleManage()renderSubscriptionControls()renderCheckout()renderCallback()
inc/Account/MembershipCheckout.php

MembershipExpiry MembershipExpiry.php​

PPT — membership expiry reminders + lapse notice. Mirrors Directory\Expiry's reminder/expiry-email pattern, but for a member's assigned plan (Admin\Memberships::USER_META / EXPIRES_META — set from the admin's Users editor, see Admin\Users::maybeSave()). Admin\Memberships is currently plan-management only ("member-access enforcement" is a documented follow-on phase), so this service only emails the member on the same schedule the listing-expiry cron uses — it never changes their assigned plan.

API
register()schedule()unschedule()rearm()runCheck()
inc/Account/MembershipExpiry.php

MembersPage MembersPage.php​

PPT members area — the logged-in user's account dashboard at /account. A standalone app shell (left nav + content), brand-coloured via the design tokens. Login-gated: visiting it while logged out bounces to the login page with a redirect back here.

API
favoritesCount()favoritesUrl()listingsUrl()projectUrl()orderUrl()liveUrl()liveStudioClass()liveStudioView()register()view()url()rewrite()queryVar()maybeRender()
inc/Account/MembersPage.php

Messages Messages.php​

PPT private messaging — member-to-member direct messages, stored in a custom table and driven entirely by AJAX (no page reloads). Powers the Member Hub's Messages tab. SECURITY - Every endpoint is logged-in only (wp_ajax_ with no nopriv counterpart) and nonce-checked. A member can only ever read/write their OWN conversations — threads are keyed on the sorted user pair and every query is scoped to the current user id, so no thread can be read by a third party. - Bodies are stored as plain text (sanitize_textarea_field on input) and escaped on output, so message content can never inject markup.

API
register()enabled()table()install()threadExists()threadKey()send()isBlocked()hasBlocked()block()unblock()clearThread()conversations()thread()unreadCount()ajaxThreads()ajaxOpen()ajaxSend()ajaxUsers()ajaxUnread()ajaxLatest()ajaxBlock()ajaxDelete()toastScript()
inc/Account/Messages.php

Messenger Messenger.php​

Floating popup messenger — a docked chat window that lets logged-in members message each other from ANY page (listing singles, author profiles, etc.) without leaving for the Member Hub. HOW IT OPENS - Every "Send a message" / contact-seller control across the theme already links to …?msgto=#messages (the hub deep link). This service prints a tiny script that intercepts clicks on ANY such link (and on any [data-ppt-msg=""] element) for logged-in members and, instead of navigating away, slides open this popup pre-loaded with that member's conversation. Guests are never affected — the popup isn't printed for them, so their contact links still route to sign-in as before. BACKEND - Reuses Messages entirely (same custom table, nonce and ppt_msg_* AJAX endpoints). This service is purely the front-end window: markup, styling and the client that talks to those endpoints. It adds no new endpoints and no new capabilities. WHERE IT PRINTS - wp_footer, logged-in members only, and NOT on the Member Hub itself (that page has its own full-size Messages tab).

API
register()render()
inc/Account/Messenger.php

PlaceLookup PlaceLookup.php​

Town / city suggestions for the sign-up wizard's location step, backed by OpenStreetMap's Nominatim (keyless, free). WHY THIS EXISTS SEPARATELY FROM Account\AddressLookup. That one answers "what is at this postcode?" for a member filling in a delivery address, and it is deliberately wp_ajax_ only — a logged-out endpoint would turn the site into an open proxy against Nominatim. The sign-up wizard needs the opposite: on the dating forks the location question is asked at step 3, LONG before the account exists, so the lookup has to answer logged-out traffic. Rather than weaken the address endpoint, this is a narrower one: it returns places, never addresses, and it is fenced in on four sides. 1. It only exists while sign-up does. Registration closed, or the wizard filtered off, and the endpoint refuses everything — there is no reason for an anonymous lookup on a site nobody can join. 2. A nonce, printed only on the location step itself. 3. Caching first: hits for a month, misses for a day. Towns do not move, and a typo must not re-ask upstream on every keystroke. 4. Throttling per IP: one uncached upstream call a second (Nominatim's own policy) and a burst ceiling per ten minutes, so a script gets cache or nothing. The neat part is what it does on the way out: every suggestion's coordinates are written into the SAME transient Ppt\Account\SignupWizard::storeCoords() reads. So when the member submits the place they picked, the wizard's own geocode is a cache hit — no second upstream call, and no need to trust (or even accept) coordinates posted by the browser. The client sends text; the server always decides the numbers.

API
register()nonce()url()handle()
inc/Account/PlaceLookup.php

Presence Presence.php​

Member online-presence — shows which members are currently active by ringing their avatar in green. A member counts as "online" when their last-active timestamp is within a short window (default 3 minutes, {@see window()}). Tracking is a single user-meta timestamp (ppt_last_active), refreshed two ways: - server-side on any authenticated request, throttled so it writes at most once every 30s (a page view / wp-admin activity keeps you "on"); - a lightweight JS heartbeat (presence.js) while a tab is open, so a member reading one page for a while still shows as online. The green ring is applied site-wide by filtering get_avatar (comments, reviews, member cards, chrome, …): every avatar gets a data-ppt-user hook, and online ones also get the ppt-online class for the initial paint. presence.js then live-toggles that class as the heartbeat reports who has come or gone, so rings appear/disappear without a reload. Templates that render an avatar directly (not via get_avatar) opt in with {@see markup()}. No new table: the timestamp rides on user meta, and it is pruned on user delete.

API
register()window()lastActive()isOnline()touch()lastLogin()onLogin()touchCurrent()attrs()ajaxPing()filterAvatar()assets()onUserDeleted()
inc/Account/Presence.php

ProfileViews ProfileViews.php​

Profile views — "who's viewed my profile", as a hub panel ANY fork can switch on. WHY THIS FILE EXISTS. The fact was already being recorded and, on every fork but dating, was never shown: {@see \Ppt\Directory\ListingViews} has logged signed-in viewers since the analytics table shipped, and the escort hub's "Since you were here" card has been telling members "5 people looked at your profile this week" with nothing behind the sentence to click — {@see \Ppt_da\Attention} is the only surface for it and it is dating-only ($attShow is gated on $isDatingHub in hub-data.php). This is the same panel with the dating out of it, so the sentence has somewhere to land on every fork that owns listings. ALL THEIR ADVERTS, NOT THE FIRST ONE. Dating has exactly one profile per member ({@see \Ppt_da\OneProfile}), so Attention could look up "the" profile and be done. Nobody else does: an independent escort may run more than one advert and an AGENCY runs a whole roster, and a count read off $listings[0] under-reports every one of them. Every query here spans every listing the member owns, and a viewer who opened two of them appears ONCE, captioned with the most recent. NO PADLOCK, DELIBERATELY. The dating panel is plan-gated because dating sells memberships to the people being looked at. Escort memberships are sold per account type and the advertisers are not the ones buying ({@see \Ppt\Account\MembershipAudience}), so a gate here would lock a feature nobody can unlock. Sites that later want to sell it can refuse through the ppt_profile_views_allowed filter without touching this file. SIGNED-IN VISITORS ONLY, and the copy says so. The table records anonymous hits too, but an anonymous hit is not a person — there is no way to tell one visitor from ten — so it is counted in the "viewed N times" wording and never in "N people". A panel that promised faces and showed none would read as broken. PRIVACY IS ASYMMETRIC, the same way it is on dating: what a viewer can see is never changed by this, only what the person they looked at can. Nobody's visit is hidden or revealed based on anything about THEM.

API
supported()allowed()listingIds()peopleSince()hitsSince()count()viewers()renderPanel()css()
inc/Account/ProfileViews.php

Recaptcha Recaptcha.php​

PPT — Google reCAPTCHA (v2 checkbox or v3 score). Configured in Settings → API keys (site key, secret key, version). Renders the widget on the account forms (login / register / lost password) and verifies the response server-side before the action proceeds. When no keys are configured, everything is a no-op (field() prints nothing, verify() passes) so the forms keep working. Lockout protection. The themed login IS wp-login.php (Account\LoginPage), so a bad key pair used to lock everyone out, the owner included, with no way back short of the database. Four things now stand in the way: - Settings ▸ API keys ▸ Test keys (ajaxTest / ajaxFrame) checks the keys AS TYPED, in a real widget, against Google, before Save; - guardSave() keeps the service off until that exact key pair has passed; - verify() lets a form through when Google says the SECRET is invalid (a site-owner mistake, never a visitor's) and raises an admin warning instead; - define('PPT_RECAPTCHA_OFF', true) in wp-config.php switches it off outright, for the wrong-site-key case the server cannot see (no token ever arrives).

API
register()siteKey()secretKey()version()enabled()killSwitch()field()verify()threshold()passed()passedAt()rejected()canSwitchOn()guardSave()takeHeld()ajaxTest()check()ajaxFrame()adminNotice()
inc/Account/Recaptcha.php

SavedSearch SavedSearch.php​

Saved searches — a logged-in member can store the directory search they're looking at (keyword + every filter + sort) and jump back to the exact same results page later from their members area. Storage mirrors the favourites pattern (no extra table): a single user-meta array ppt_saved_searches on the member, newest first. Each row: - id short opaque id (delete handle) - path root-relative search URL (path + whitelisted query), e.g. "/?s=cafe&price2=50" - label human summary of the filters, rebuilt on save - created unix timestamp The "Save this search" button (SearchPage template) toggles the current search on/off for the member; guests are routed to sign-in and returned to the same search afterwards. The members area lists saved searches with a "View results" link straight back to the stored URL, and a per-row delete.

API
register()all()has()toggle()remove()count()normalisePath()currentPath()isSearchContext()describe()button()ajaxToggle()ajaxRemove()assets()onUserDeleted()
inc/Account/SavedSearch.php

SignupProfile SignupProfile.php​

SIGN-UP → PROFILE — one continuous process on the profile forks (escort, dating). Before this, a new escort or dating member signed up through eleven or more one-question screens, landed in the Member Hub (after a login, on some settings), and then had to find the profile editor and start again — most of the wizard's answers never reached the profile, so she typed several of them twice. Client, 2026-09-28: "they register, log in, and then create a profile, and that takes too long." Now the wizard asks only what the ACCOUNT needs (age gate, account type, date of birth, name, email, password, terms) and hands over to the ordinary profile editor, opened on the profile it just seeded as a draft and titled "Step 2 of 2". Everything the editor asks is asked there once. Submitting it is the ordinary editor submit, so the site's approval setting, required photo, terms and paid plans all apply unchanged. The profile stays OPEN (this class's user meta) until it is first submitted. While it is open: - logging in, or revisiting /join/, returns her to it rather than to the hub; - email verification, when the site requires it, does NOT lock her out of it. The verify email is sent at sign-up and she can confirm whenever she likes — but a profile submitted before she has confirmed goes to approval, never straight live, and is published by the confirmation itself on a site that publishes new profiles without review. So verification still gates going LIVE, just not filling in. Which members get the hand-over: those whose sign-up seeds a profile at all (SignupSteps::seedsListing + AccountType::seedsProfile) — every dating member, and an escort who signs up as an independent/model. Agencies and browsing members keep the wizard exactly as it was. Filter ppt_signup_profile_handoff to switch it off.

API
register()handsOff()openFor()isOpen()editorUrl()open()verificationWaived()holdUnverified()onVerified()onTransition()loginRedirect()themeLoginRedirect()doneRedirect()
inc/Account/SignupProfile.php

SignupRules SignupRules.php​

PPT — the two Settings ▸ Users rules that shape a member account's first moments: "Business emails only" (user_business_email) — reject a free-mailbox address at sign-up. "Redirect after login" (user_login_redirect) — where a member lands once they have signed in. Both are OFF / empty by default, in which case every hook here is a no-op and registration and login behave exactly as WordPress and the theme already do. Sign-up is guarded on registration_errors, which is the one choke point every registration path runs through — the theme's own form (Account\LoginPage calls register_new_user()), wp-login.php?action=register, and anything else that goes through core. Checking it there rather than in the form handler means a rule the admin switched on cannot be walked around by using a different door.

API
register()businessEmailOnly()freeDomains()isFreeEmail()checkBusinessEmail()redirectUrl()loginRedirect()coreLoginRedirect()
inc/Account/SignupRules.php

SignupSteps SignupSteps.php​

PPT sign-up steps — the QUESTION LIST behind /join/. Sign-up used to be one screen asking for an email and a username. That is the right form for a shop and hopeless for a dating site, which needs a date of birth, a gender, a location and a photo before a profile is worth showing to anyone. Rather than fork the register screen per theme, the wizard asks ONE question per screen and this class is the list of questions: a library of step definitions, plus the ordered list each theme profile actually uses. That split is the whole point. Ppt\Account\SignupWizard knows how to ask a question, validate it and store the answer; it never knows which questions this theme asks. Adding a step to a profile is a line in self::flows(), and a plugin can do the same through the ppt_signup_steps filter without touching either. A step definition is an array: label short name for the progress rail q/sub the question and its supporting line type which control to render (see SignupWizard::render + views/join.php) req true = must be answered, false = offers "Skip for now" ph placeholder, for the text-ish types options fixed choices; taxonomy-backed steps resolve theirs in self::options() …and two the terms step understands, set per profile in self::overrides(): note a line under the tick boxes (the Cashback Theme says what it tracks) adult_confirm a second REQUIRED tick box, "I am 18 or over" — a confirmation, not a date of birth, for sites that need the promise but not the date Three steps come and go with the site's own settings rather than the profile: password dropped when the admin prefers WordPress's emailed set-password link verify dropped unless Settings ▸ Registration requires email verification adult only on the adult profiles, and always first …and on the escort fork the list narrows again once the visitor says what KIND of account this is (acctype): an agency is never asked its own gender or date of birth, and a member who is only here to look is asked neither those nor what she offers. That branching lives in Account\AccountType, not here — this file stays the question list.

API
library()flows()overrides()profile()keys()flow()def()options()seekingOptions()passwordStep()verifyStep()isAdult()seedsListing()moderates()
inc/Account/SignupSteps.php

SignupWizard SignupWizard.php​

PPT sign-up wizard — /join/, the theme's registration flow. This REPLACES wp-login.php?action=register: register_url is filtered so every "Sign up" link the theme already renders (headers, hero calls to action, block buttons — around a hundred call sites through wp_registration_url()) points here without any of them being edited, and a direct hit on wp-login.php?action=register is redirected in, so bookmarks and plugin links land in the same place. One question per screen, in the order Ppt\Account\SignupSteps gives for this theme profile. Each step is its own URL (/join/?step=dob), so Back, reload and the progress rail all work the way a visitor expects, and each submit is a POST that redirects to the next step rather than re-rendering — no "confirm resubmission" dialog halfway through a sign-up. WHERE THE ANSWERS LIVE, and why it changes halfway: before the account exists — a signed cookie (self::bag()). Nothing is written to the database until the member has given an email address and a password, so an abandoned sign-up leaves no half-built user behind for an admin to clean up. after — their real homes. The name is the user's first_name and display_name, the date of birth is user meta, the phone is the SAME meta key the account screen edits (AccountEdit::PHONE_META), the photo is a normal avatar attachment through Account\Avatar, and on the person forks the gender/interest answers become terms on a profile listing seeded for them. Nothing here invents a second copy of data the theme already stores somewhere. The account is created at the password step (or, when the admin prefers WordPress's emailed set-password link, at the terms step). Either way the terms are agreed ON the creating step — the password step carries the terms tick boxes (termsAtCreation) — so no account exists that has not accepted them. Creation runs the SAME registration_errors validation as every other registration path — the choke point Account\SignupRules relies on — so the "business emails only" rule and any plugin hooked there still apply. Two hard stops, both before anything is stored: the adult confirmation on adult profiles, and a date of birth under 18. Neither leaves an account, a draft listing or a cookie behind.

API
register()url()enabled()isDemoTour()rewrite()queryVar()registerUrl()interceptRegister()returnTo()urlReturningTo()maybeRender()termsAtCreation()adoptAnswers()isComplete()nearValue()value()answer()ageFrom()ageFromDate()
inc/Account/SignupWizard.php

Social Social.php​

Social login via the bundled HybridAuth library (inc/vendor/Hybridauth, v3.13). The provider CATALOG is discovered from the library itself. The admin chooses which providers to display and enters each one's credentials on the Settings screen (option ppt_social). The login page shows the enabled providers; clicking one runs the OAuth round-trip and signs the visitor into WordPress.

API
register()catalog()all()provider()enabledForDisplay()isDemo()forDisplay()connectUrl()startUrl()joinUrl()isExample()icon()maybeHandleOAuth()
inc/Account/Social.php

Tracking Tracking.php​

PPT members area — order tracking. Two surfaces, both reading Ppt\Checkout\Fulfilment and writing nothing: • A "Tracking" panel in the Member Hub (/account#tracking): every order of the member's that involves a parcel, with the stage it has reached. • A status page at /account/tracking/<orderId>/ (owner or admin only): the progress timeline, the carrier and consignment number, and the items. Mirrors Ppt\Account\Invoices — same rewrite + self-flush pattern, same ownership check — so the two members-area routes behave identically.

API
register()rewrite()queryVar()url()maybeRender()ordersFor()renderTab()renderTimeline()
inc/Account/Tracking.php

Verification Verification.php​

Account verification — require a member to confirm they own their email OR their phone before they can use account features (submitting/managing listings). Gate: when "Require email verification" (Settings ▸ Registration) is on, an unverified, non-admin member is not elevated to the listing capabilities (Ppt\Frontend\MemberCaps checks Verification::blocks()), and the Member Hub shows a verify card. The member clears it one of two ways: - Email: we send a single-use link (24h) to their account email; clicking it marks them verified. - SMS (optional, when "Also allow SMS verification" is on AND the built-in SMS gateway is configured): they enter a phone number, we text a 6-digit code through Ppt\Notifications\Sms, and entering it marks them verified. Either method satisfies the gate — "email OR SMS". Admins are always exempt.

API
register()required()smsAllowed()isVerified()method()blocks()handleSendEmail()sendEmailLink()maybeConfirmEmailLink()handleSmsSend()handleSmsConfirm()renderCard()
inc/Account/Verification.php