Skip to main content

Directory Theme class reference

31 min read

Micro Jobs Theme — Class reference

The 40 classes that make _mj behave differently from the shared Directory core. Generated from the theme source.

← Back to Micro Jobs Theme features

Addons Addons.php​

Gig add-ons ("extras"): the seller's optional upsells on a gig — extra-fast delivery, another revision, source files, "+N of X" — each with a price and, optionally, extra delivery days. A buyer ticks them under the packages; they ride into the checkout as ?addons=a,b, become their own lines on the summary, are summed into the order total, are snapshotted onto the order (so a later price change never rewrites a paid order), and lengthen the order room's delivery date. Storage: listing meta, a list of {id, title, price, days}. Ids are short and stable so a reordered list keeps its links. Up to MAX per gig.

API
all()has()fromRequest()pick()sum()extraDays()param()forOrder()writeOrder()saveFromForm()editorCard()boxHtml()lines()
inc/_mj/Addons.php

BuyerProtection BuyerProtection.php​

Classifieds — Buyer Protection fee. The classifieds "Buy now" flow (Ppt_mj\Checkout) charges a small buyer-protection fee on top of the item price, the way marketplaces like Gumtree/Vinted do. The admin turns it on and sets a percentage of the item, a fixed amount, or both (Payments ▸ Checkout ▸ Buyer Protection). It's stored in the shared ppt_checkout option (read via Ppt\Admin\Checkout::get()), alongside tax/shipping/commission, and shown as its own line on the buy box, the checkout summary and the buyer's invoice. Ships ENABLED by default (5% + a small fixed fee) so a fresh classifieds site has a working buy box out of the box; the admin can lower it or switch it off entirely.

API
enabled()percent()fixed()applies()on()label()
inc/_mj/BuyerProtection.php

Checkout Checkout.php​

Micro Jobs — "Buy now" checkout for a single gig. A buyer on a gig page picks a package (Basic / Standard / Premium — see buyBox()), lands on the shared /checkout/ page with ?buy=

&tier=``, picks a gateway and pays. On the /callback/ return the order completes, its order room opens (Ppt_mj\Workroom::open, keyed on the ORDER) and buyer + seller + admin are emailed. A gig is never "sold": it stays buyable for every buyer, again and again (one buyer may hold several open orders on the same gig). The seller’s share is credited to the Payouts ledger when the order COMPLETES (Workroom::complete → accrueSellerEarning), not at purchase. It reuses the same building blocks the Shop and Auction forks use — the shared gateway registry (Ppt\Admin\Payments) with the ppt_gateway_start / ppt_gateway_verify filter contract, the ppt_orders store, the shared Ppt\Checkout\Address + Ppt\Checkout\Shipping helpers, and the shared tax math in Ppt\Payments\CheckoutFlow::totals(). Orders carry an MJ- reference prefix so Pages::content() can route /callback/ to this class. Registered only when the Micro Jobs profile is active (Theme::boot fork-gating). This is a single-item, buy-on-the-spot flow, so — unlike the shop — there is no multi-item cart: the order is created at pay time for exactly the gig being bought. The donor's escrow-funding mode (?fund=) was retired with the project model on 2026-09-20.

API
register()buyUrl()isDemoListing()resolveTier()callbackUrl()isMicroRef()itemPrice()isBuyable()showroomGig()buyBox()needsShipping()totals()coupon()handlePay()creditsQuote()payWithCredits()paidOrderFor()accrueSellerEarning()markPaid()ajaxShippingQuote()renderCheckout()renderCallback()
inc/_mj/Checkout.php

Comments Comments.php​

Listing comments (member Q&A) — the Classifieds fork's replacement for star reviews. Built on native WordPress comments so they moderate and store like any comment, but stamped with the comment type "ppt_qa" (the identifier that distinguishes them from ordinary blog comments, listing reviews and reports in the admin). There is no rating — this is a plain "ask a question / leave a comment" thread, which fits a classifieds ad (buyers ask the seller questions) better than a public 1–5 rating. Mirrors Ppt_at\Comments (the Auction fork's identical swap); reuses the shared 'ppt_qa' comment type so Ppt\Admin\Comments' type filter/badge and the demo questions (Ppt\Content\DemoContent::questions) work here unchanged. Rendered in the single-listing "Comments" section (list + add-comment form).

API
register()open()stampType()count()all()renderList()renderForm()
inc/_mj/Comments.php

DemoAccounts DemoAccounts.php​

Micro Jobs — what the typed DEMO members walk into (home-demo "Buyer login" / "Freelancer login", Account\DemoLogin). The one-click accounts exist to show a prospective buyer of the THEME what the two hubs look like in use. An empty hub shows nothing, so when a typed member is provisioned this furnishes BOTH sides of one coherent story, once: the FREELANCER (AccountType::SOLO) six real gigs of their own, built from the active niche's curated sample rows (title, category, price, blurb, photo), tiered prices and a delivery window — two still for sale, four sold; the BUYER (AccountType::CLIENT) those four orders, placed over the last fortnight and in every state the order room has: two complete (one already paid out to the seller), one delivered and awaiting the buyer, one in progress; between them feedback both ways on the completed orders (Account\Feedback, when the feature is on), and a short inbox thread about the live one. Everything is written through the fork's own machinery — _mj\Workroom (open / deliver / accept / complete, which credits the Payouts ledger), Account\Feedback::submit, Account\Messages::send — so the hubs read exactly what a real sale leaves behind. Every row is marked demo (Support\Demo) and recorded on both members; unfurnish() removes it all when DemoLogin purges the accounts at go-live. Gated to the micro profile by Theme::boot()'s fork-gating (this is Ppt_mj*).

API
register()furnish()unfurnish()
inc/_mj/DemoAccounts.php

DemoProfiles DemoProfiles.php​

Micro Jobs Theme — what a SEEDED demo gig gets that the generic seeder cannot give it. Tools\SampleData::seed() writes every demo listing the same way (title, blurb, a single price, a city) and then hands the row to the fork's DemoProfiles::apply(). A gig is not a business card: it sells in three packages, promises a delivery time, and belongs to a seller with a face. So this: - turns the row's one price into Basic / Standard / Premium packages (ListingPricing::writeTiers — the same meta the editor saves, so the buy box, the checkout and the order room all read real packages, not showroom filler); - mirrors the fastest package into the delivery facet (GigSpecs); - hands the gig to the seller the design's cards already credit for that row (DemoSellers::memberForRow — a real member, marked theme-managed so it is purged at go-live), so the name under the card photo IS the author, the admin can buy every demo gig, and the catalogue is spread across a dozen sellers.

API
tiersFor()apply()body()faq()
inc/_mj/DemoProfiles.php

DemoSellers DemoSellers.php​

Micro Jobs Theme — the showroom's SELLERS, one per curated sample gig. A niche's sample rows (Ppt\Content\SampleListings, classicmicro / socialtasks) describe the GIG — title, category, price, blurb — and nothing about who offers it. Every Micro Jobs design's home page, though, credits a seller on each card: avatar, name and standing ("Level 2", "Top seller") straight under the photo, then the delivery window beside the price. The search page draws the same card, so its demo rows need the same four facts. Merged by row index from SampleListings::cards(). The names and avatars are the ones the family's home-page blocks already use (ErrandFeatured / ErrandFresh for the classic family, CloutFeatured / HypeGigs for the social family), so a face that greets the visitor on the home page is the same person, with the same name, in search. Sellers past a block's roster borrow the neighbouring design's spare avatars. Demo only: a REAL gig gets its seller from the post's author (Ppt_mj\ListingCard), its delivery from GigSpecs, and no standing at all (the product has no seller levels — the label is showroom colour).

API
forRow()memberForRow()
inc/_mj/DemoSellers.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/_mj/Expiry.php

FilterBar FilterBar.php​

Micro Jobs — the Fiverr-shaped search filter bar (built 2026-09-20, user-directed): one row of dropdown buttons — Category · Service options · Seller details · Budget · Delivery time — with the "Featured" / "Online now" switches held at the right. This is the fork's "Filters on top" layout: Directory/templates/search.php draws it in place of the shared tinted band whenever $layout === 'top' on _mj (see $pptMjBar there). The stacked layouts (sidebar / three columns) keep the shared rail, where the same facets render as cards through Ppt\Directory\Filters\Rail — so an admin who switches the layout loses nothing. What it draws is decided by the SAME switches as the rail: Admin\Search::orderedFilters() for the order and Search::showFilter() per key. Keys are folded into the bar's groups — the three seller facets share one "Seller details" dropdown, the two switches share the right-hand row — and a group whose every member is off simply does not appear. A members-only (premium) key locked for this visitor renders as a padlock button that links to the plans page, as the rail's padlock does. Every control is an ordinary GET field of the search <form> this renders inside, so the bar works with JavaScript off (Apply is a submit button). The script adds the one-open-at-a-time behaviour, outside-click / Escape closing, the live "2 selected" badges, and hands submits to search-ajax.js where it is present. Colours are design tokens only (--ppt-ink / --ppt-surface / --ppt-line-2 …), so a dark scheme repaints the bar with the rest of the page — no colour is hard-coded.

API
render()
inc/_mj/FilterBar.php

FreelancerCategories FreelancerCategories.php​

Freelancer categories (Freelancer Marketplace, _mj). The admin-managed list of disciplines a hireable member can file themselves under — "Video Editing", "Copywriting", "Web Development" — and the thing the dedicated freelancer search (Ppt_mj\FreelancerSearch) filters on. It is a real WordPress taxonomy registered against the user object type, not a bespoke option list, so the admin gets WP's native term editor for free (add / rename / describe / nest / delete, with slugs and search) and each member's choices are ordinary term relationships. Two consequences worth knowing: - WP registers no admin menu for a user taxonomy, so menu() links the native term screen (edit-tags.php) into the PPT admin itself. - Term counts default to counting POSTS, which would always read 0 here, so the taxonomy uses _update_generic_term_count() to count the attached users. The member picks their categories in Member Hub ▸ Profile & settings ▸ Available for hire (Ppt_mj\FreelancerProfile::settingsCard), capped at MAX_PER_USER.

API
register()taxonomy()available()maxPerUser()adminUrl()menu()render()maybeCreate()maybeRename()maybeDelete()parentFile()columns()assets()ajaxQuery()rowHtml()ajaxAction()ajaxBulk()ajaxImport()importLines()stats()ajaxSearch()total()pickerMax()smallEnoughToList()
inc/_mj/FreelancerCategories.php

FreelancerProfile FreelancerProfile.php​

Freelancer profile fields (Freelancer Marketplace, _mj). Enriches the shared public member profile (Ppt\Account\AuthorProfile, the author archive) so it reads like a hire-me page rather than a blog author archive: a headline, a skills chip row, an hourly/day/project rate, and a "Recent work" grid drawn from the projects the member has been AWARDED (Escrow). The fields are user meta the member edits in place on their own profile (owner-only AJAX); everything is gated to the freelance fork by the view.

API
register()isListed()headline()about()cleanBio()skills()rate()rateType()rateLabel()country()countryName()languageOptions()languages()languageNames()setLanguages()hasProfile()recentWorkIds()ajaxSave()settingsCard()
inc/_mj/FreelancerProfile.php

FreelancerSearch FreelancerSearch.php​

Dedicated freelancer search (Freelancer Marketplace, _mj) — /freelancers/. The listing search (Ppt_mj\SearchPage) searches POSTS: ready-now tasks and project briefs. This searches PEOPLE: the members who have switched on "Available for hire" in Member Hub ▸ Profile & settings. Different object, different query engine (WP_User_Query), so it is its own route and template rather than another layout of the listing search. Self-contained rewrite + render, the same pattern as Frontend\Blog and Account\MembersPage: ?ppt_freelancers=1, prettified to /freelancers/. Filters, all optional and combinable, all GET, all in the top bar (the template has no sidebar — see inc/_mj/templates/freelancers.php): fcat category term id or slug (Ppt_mj\FreelancerCategories) fq keyword — name, headline, skills or bio frating minimum member feedback score, 1–5 (Ppt\Account\Feedback) frate1 / frate2 min / max rate fsort newest | rating | rate_low | rate_high | name The keyword match and the rating/rate ordering both need SQL WP_User_Query can't express (an OR across core columns and meta; a LEFT JOIN so members with no score sort last instead of vanishing), so both are applied in userQuery() on pre_user_query. Everything else rides in the normal args.

API
register()rewrite()queryVar()url()isPage()maybeRender()documentTitle()bodyClass()heading()val()catId()keyword()minRating()rateBound()sortOptions()sort()hasActiveFilters()paged()results()userQuery()card()view()demoCards()
inc/_mj/FreelancerSearch.php

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

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

GigOptions GigOptions.php​

Micro Jobs — "Service options": a fixed set of extras a seller ticks on the gig editor (source file included, commercial use, unlimited revisions, express 24h delivery) that buyers can filter search by. Built 2026-09-20 for the Fiverr-shaped filter bar (_mj\FilterBar): Fiverr's "Service options" are per-category metadata, which this fork has no equivalent of, so the user chose a small gig-level checklist instead. Storage: TWO metas, on purpose. · META_LIST — one serialized array of option keys, what everything that READS the gig uses (the editor, the gig page). · META_FLAG — one row PER ticked option (add_post_meta, non-unique), what the search query filters on. A serialized array can only be matched with LIKE; a row per option gives an exact = clause, and picking two options is two AND-ed clauses. Both are rewritten together in save(), so they cannot drift.

API
options()of()has()labels()save()saveFromForm()editorCard()requested()metaQuery()
inc/_mj/GigOptions.php

GigSpecs GigSpecs.php​

Gig facts for the Micro Jobs fork (_mj): the at-a-glance figures a fixed-price gig shows on its card and under its title — the "from" price and the fastest delivery. This is what remains of the donor's ProjectSpecs once the project model (budget, deadline, skills, the project/task kind chooser) was retired from this fork on 2026-09-20. Every listing on the Micro Jobs line is a gig, so nothing here branches on a kind. Data model — plain listing meta: - The PRICE is the shared searchable price meta (SearchPage::PRICE_META). A gig is priced by its Basic / Standard / Premium packages (ListingPricing); the editor mirrors the CHEAPEST package into this meta, so it means "from £X" and drives price sort, price range and the cards. The buy box charges the package the buyer actually picks (Checkout::itemPrice). - DELIVERY_DAYS_META is the FASTEST package's turnaround in whole days, mirrored by ListingEditor::savePricing() the same way, so the search facet filters one number per gig ("delivered in as little as N days").

API
deliveryDayWindows()deliveryDays()deliveryLabel()priceLabel()metaStrip()statIcon()displayCss()cardSellerRow()cardFoot()cardFacts()
inc/_mj/GigSpecs.php

HubCards HubCards.php​

Micro Jobs — the two Member Hub cards this product adds beside the shared ones. about() the "About <member>" column on the right of the inbox (Fiverr's shape): who you are talking to — Buyer / Freelancer, where they are, member since, rating, skills and rate, and the orders the two of you have together. Loaded by AJAX when a thread opens (ppt_mj_inbox_about). balance() the balance card on the Overview's right column: what is available to withdraw, earned to date, paid out, and spent as a buyer. Gated to the micro profile by Theme::boot()'s fork-gating (this is Ppt_mj*).

API
register()ajaxAbout()about()balance()
inc/_mj/HubCards.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/_mj/ListingActions.php

ListingCard ListingCard.php​

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

API
fromPost()fromSample()isVerified()render()
inc/_mj/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()bookingsEnabled()bookingProviderAllowed()currentCategoryId()
inc/_mj/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/_mj/ListingFaq.php

ListingHours ListingHours.php​

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

API
has()isOpenNow()renderSidebar()
inc/_mj/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/_mj/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/_mj/ListingMap.php

ListingPricing ListingPricing.php​

Gig packages — the Basic / Standard / Premium tiers a seller offers on one gig. Stored in the pricing_plans post meta (key kept from the donor so nothing has to be moved) as a list of rows: ['key' => 'basic'|'standard'|'premium', 'price' => float|null, 'was' => float|null, 'days' => int, 'revisions' => int, 'desc' => string, 'features' => string[]] The tier KEY is fixed and its label is a theme string, so the three names translate and read the same on every Micro Jobs site. A seller may leave a tier empty to offer fewer than three; a row counts as filled once it has a price. The LOWEST tier price is mirrored into the searchable price meta (Ppt_mj\SearchPage::PRICE_META) by the editor on save, so the price-range filter, the price sort and the result-card "from" price all keep working off one number — while the buy box charges the tier the buyer actually picked (Ppt_mj\Checkout). Prices are stored in the site's base currency; the buy box converts and formats via Ppt\Content\Currencies for the viewer's chosen currency. INHERITED SHAPE: the donor stored free-text ['name','price','desc'] rows and rendered them as display-only cards in the main column, with the sidebar buy box charging a separate single price — two unconnected prices on one page. normalise() upgrades those rows in place on read, so no migration has to run before a page can render.

API
tierLabel()tiers()editorTiers()tier()isTiered()demoTiers()normalise()row()writeTiers()lowestPrice()has()render()
inc/_mj/ListingPricing.php

ListingSections ListingSections.php​

PPT — configurable single-listing sections. The admin (Design ▸ Listings ▸ "Listing page sections") gets a sortable list of on/off toggles for the optional feature blocks a listing page can carry — Booking, Business hours, Maps & Location, FAQ, Comments. 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/_mj/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/_mj/ListingViews.php

MarkSold MarkSold.php​

Micro Jobs — LEGACY "sold" meta on a gig. Nothing on this fork marks a gig sold any more. Up to 12.0.42 Checkout stamped ppt_sold / ppt_sold_buyer / ppt_sold_at on the gig at its first purchase, and that one flag shut the gig to every later buyer: the buy box showed "Sold", checkout said "Already sold", offers were refused, and the order room, refunds and feedback were all keyed on the gig. That is classifieds logic (one item, one buyer); a gig is a service sold again and again, so since then every purchase is its own order with its own room (Workroom, keyed on the ORDER), and the gig is never sold out. The class stays so the fork's service list mirrors the others and so the mj_rooms_on_orders migration can name the old keys and clear them (markUnsold()). Do not call markSold() from new code.

API
register()isSold()buyerId()label()markSold()markUnsold()
inc/_mj/MarkSold.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/_mj/Media.php

Offers Offers.php​

Make Offer — a buyer asks a seller for a custom order on a gig. The buyer states what they need, a price (or hours × the seller's rate) and a delivery time; the seller accepts, declines or counters with their own price/days; the buyer accepts a counter; an accepted offer is payable at the normal gig checkout (?buy=&offer=``), which prices the order from the offer instead of a package and opens the same order room. Both sides are emailed at every step, and the hub lists a member's offers under Buying ▸ My offers and Selling ▸ Custom offers. Storage: a private post type (one post per offer) with meta — nothing to migrate, and wp-admin can inspect a row if ever needed. Offers expire after OFFER_DAYS untouched. One open offer per buyer per gig. A paid offer becomes its own order (and room), just like a package purchase; the gig stays on sale, and the buyer may ask for another offer.

API
register()registerType()get()forBuyer()forSeller()openFor()awaitingSeller()awaitingBuyer()forOrder()payable()fromRequest()requested()payUrl()lineName()statusLabel()request()accept()decline()counter()take()withdraw()markPaid()writeOrder()markPaidByOrder()
inc/_mj/Offers.php

Refunds Refunds.php​

Refunds and disputes for a gig order (Micro Jobs, _mj) — the room's Resolution tab. The buyer asks; the seller answers; the site owner settles what they cannot. REQUEST the buyer, with a reason, while the order is open — or, once it has completed, inside the dispute window (disputeDays(), 14 by default). ACCEPT the seller: the order is cancelled and the refund approved. DECLINE the seller, with a note. The buyer may then ESCALATE — as they may when the seller has not answered within replyDays() (3). DECIDE the site owner (manage_options), from the room itself, which they can open for any order: approve (cancel + refund) or reject (the order carries on as it was). The money: nothing on the platform side has to move until completion — the seller's share is only credited to the Payouts ledger when the buyer accepts — so a refund before then is clean. After completion the seller's PENDING ledger entry is voided (Ppt\Payouts\Ledger::void); one already paid out cannot be, and the owner is told. The purchase itself goes back the way it came: the owner refunds it in the gateway (PayPal's dashboard) — approving here records the decision, marks the order refunded, and emails the owner to do that. One record per order, in the ORDER post's meta (the room is keyed on the order); every step is also an activity event in the room (Workroom::pushEvent).

API
register()disputeDays()replyDays()record()status()isOpen()escalateAt()disputeCloses()requestBlock()canRequest()canEscalate()request()accept()decline()escalate()decide()creditsAvailable()creditsFor()awaitingSettlement()settle()settlementText()unsettledOrders()escalatedOrders()ajaxRequest()
inc/_mj/Refunds.php

Requirements Requirements.php​

Gig requirement questions (Micro Jobs, _mj) — what a seller needs from a buyer before work can start, defined per gig and answered right after purchase. The seller writes up to MAX questions on the gig editor (each optional or required). When a buyer pays, the order room opens in its "requirements" state and the delivery clock does not start until the buyer submits answers (Ppt_mj\Workroom). A gig with no questions starts the moment it is bought, as before. Storage: listing meta, an array of {q, optional}. The ANSWERS live on the order (Workroom::REQS_META), snapshotting the questions as asked — editing the gig later never rewrites what a buyer already answered.

API
questions()has()saveFromForm()editorCard()
inc/_mj/Requirements.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/_mj/Reviews.php

Roles Roles.php​

Buyer or freelancer: who this member is, and what the Micro Jobs hub is therefore FOR. A gig marketplace has two sides. A FREELANCER lists gigs and delivers orders; a BUYER orders them. Sign-up has asked which since the role step was written ("I am here to browse" / "I want to list something") and stored the answer in ppt_signup_role — but nothing read it, so every member got the same seller-shaped hub, with a buyer's purchases filed under "Selling". Exactly the Real Estate pattern (Ppt_rt\Roles): a bridge from that stored answer onto the type Account\AccountType already understands (buy → CLIENT, sell → SOLO), and a dictionary for the words. No second question, no migration, and a member who answered last week is already typed. What the type changes: - the Member Hub badge beside "Verified": Buyer / Freelancer; - a BUYER with no gigs gets no Selling group and no Add gig button (their orders live in the Buying group); they can switch to Freelancer under Account, where the account-type control already offers this site's two types; - the sign-up wording for the role step on this profile (SignupSteps' override). Gated to the micro profile by Theme::boot()'s fork-gating (this is Ppt_mj*).

API
register()profiles()typeForUser()typeCurrent()toType()options()shortLabel()typeOf()isBuyer()accountBadge()
inc/_mj/Roles.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; priceband picks a preset range instead fmdays delivery time — within N days fmcountry the listing author's country (ISO code, from their profile) fmrecent 1 = posted within the last recentWindowDays() fm_rating minimum author feedback score sort featured | newest | oldest | rating | price_low | price_high | title paged pagination

API
register()shapeQuery()nearCoords()radiusKm()distanceFrom()distanceLabel()radiusClausesMain()radiusClauses()minMemberRating()memberRatingClausesMain()memberRatingClauses()authorCountry()authorCountryName()countryClausesMain()countryClauses()languageCodes()languageClausesMain()languageClauses()isFeaturedOnly()featuredMetaQuery()isOnlineOnly()onlineAuthorIds()optionsMetaQuery()requestedOptions()
inc/_mj/SearchPage.php

SellerLevels SellerLevels.php​

Seller levels — earned from completed orders and buyer feedback. Four standings: New seller → Level 1 → Level 2 → Top seller. A seller climbs by completing orders (Workroom::complete — the buyer accepted, or it auto-completed) and by the rating buyers leave (Account\Feedback, the visible buyer→seller average). Nothing is bought: the thresholds are the only way up, and a cancelled or refunded order comes off the count. The site owner can PIN a level on a member from wp-admin ▸ Users (a manual override, "Automatic" to release it). Shown wherever a seller is: the gig card's seller row (the same "standing" slot the showroom's roster fills), the gig page's seller card, the author profile, the messages About column, and the seller's own Balance card with what the next level takes. A climb sends a bell and the mj_level_up email. Thresholds (filter ppt_mj_seller_levels): Level 1 3 completed orders Level 2 10 completed orders, average rating 4.5 from at least 3 ratings Top seller 25 completed orders, average rating 4.8 from at least 10 ratings The computed level is cached in user meta and recomputed on the events that can change it (order completed / cancelled, feedback revealed); a missing cache computes on first read, so nothing needs a migration.

API
register()thresholds()labelFor()isLevel()rank()stats()compute()of()applies()label()pill()recompute()override()setOverride()progress()progressLine()onOrderChange()onFeedback()profileMeta()
inc/_mj/SellerLevels.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/_mj/SingleListing.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/_mj/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/_mj/Uploads.php

Workroom Workroom.php​

The gig order room (Micro Jobs fork, _mj): what a paid gig turns into. A buyer pays for a gig through _mj\Checkout; on the callback the order completes and Checkout calls Workroom::open() on the paid ORDER post. From then on that order has a room in the Member Hub at /account/order/<order>/ (views/partials/order-room.php) for its two parties — the SELLER (the listing author) and the BUYER — and nothing for anyone else. Modelled on a Fiverr order page: Activity a dated log — placed, requirements, started, delivery date, delivered (with the delivery inline), revision requested, completed. Details the invoice view — package, duration, price, service fee, tax, total. Requirements the seller's per-gig questions (Ppt_mj\Requirements), answered by the buyer right after paying. The delivery clock starts on submission. Delivery numbered deliveries, each with the seller's message and the files / links that came with it; accept or request a revision from here. States: requirements (only when the gig asks questions) → in_progress → delivered → complete. The buyer ACCEPTS a delivery, or REQUESTS A REVISION (back to in_progress, within the package's revision allowance). Nobody responding for autoCompleteDays() after a delivery completes the order (daily cron + a lazy check on read). Completion is what credits the seller's share to the Payouts ledger — NOT the purchase — so an undelivered gig never pays out. A gig sells again and again (many buyers, repeat orders — the same buyer may have several open at once), so the room is keyed on the ORDER: every method here takes the paid ppt_orders post id ($oid) and the room's state lives in that order's meta. The gig is gigId($oid); the buyer is the order's own order_userid. Up to 12.0.42 the room was keyed on the gig and the gig was marked sold after one purchase; the mj_rooms_on_orders migration moved those rooms onto their orders. Deliverable files live in a private, HTTP-denied uploads folder. This replaced the donor's escrow + milestone workspace when the project model was retired from this fork (2026-09-20): there is no award, no escrow, no milestones.

API
register()schedule()gigId()sellerId()buyerId()isParty()isActive()ordersOnGig()open()status()isComplete()awaitingRequirements()completedAt()deliveredAt()orderedAt()requirementsAt()orderId()orderRef()tierKey()tierLabel()tierRow()days()amount()totals()
inc/_mj/Workroom.php