Skip to main content

Directory Theme class reference

77 min read

Job Board Theme — Class reference

The 89 classes that make _jb behave differently from the shared Directory core. Generated from the theme source.

← Back to Job Board Theme features

ActivityLog ActivityLog.php​

Who did what, when — the audit trail for a jobs board. The client asked for "activity log (who did what, when)" as an admin reporting feature, and on a board holding resumes that is not really a reporting feature at all: it is the answer to "who looked at my CV?", which is a question a candidate is entitled to ask and a question PIPEDA expects the site owner to be able to answer. SO THE ENTRIES THAT MATTER MOST ARE THE PRIVACY ONES. Every path that reveals a person to somebody is recorded: a resume downloaded, a candidate unlocked from the database, an application opened. The rest — a job posted, a status moved — is ordinary book-keeping and is here because an audit trail with gaps in it is not one. NOTHING IS LOGGED TWICE. Every event comes from a hook that already existed in the feature that owns it (ppt_jb_resume_downloaded, ppt_jb_application_status …), rather than from a call bolted into each class. Adding a new logged event is one listener here, not an edit to somebody else's file. IT DOES NOT GROW FOR EVER. A log with no retention becomes the largest table in the database and then a liability of its own — an audit trail of who read whose CV in 2019 protects nobody. Pruned daily to self::days(), a year by default. WHAT IT DELIBERATELY DOES NOT STORE. No IP addresses. They are personal data in their own right, they would have to be disclosed and retained under the same rules as the thing they were logged to protect, and for "who downloaded this CV" the user id is both necessary and sufficient.

API
register()table()install()schedule()days()log()onResumeRead()onAppCvRead()onUnlocked()onApplied()onMoved()onWithdrawn()onResumeStored()onPostStatus()query()count()aboutMe()describe()prune()purgeForUser()
inc/_jb/ActivityLog.php

AdminResumes AdminResumes.php​

Admin ▸ People ▸ Resumes — the owner's view of candidates, applications and the log. Everything Phase 1 and 2 built was reachable by MEMBERS, in the Member Hub, and by a developer through the CLI harnesses. None of it was reachable by the person who owns the site. That is a real gap and this screen closes it: Candidates who has uploaded a resume, what they can do, whether they have opted into the database, and a download for each file. Applications every application on the board, across every employer. Activity who did what, when — chiefly who read whose CV. It also gives the two CSV exports somewhere to be clicked from. _jb\Exports had a working, nonced, admin-only endpoint that NOTHING linked to, which is the same as not having built it. WHY UNDER PEOPLE. A resume belongs to a person, not to a listing — the same reasoning that puts Freelancer Categories under People rather than with the listing taxonomies. The site owner looking for "who has applied" looks under People, not under Jobs. THE OWNER CAN READ ANY RESUME, and that is deliberate: Resumes::canView() has always allowed manage_options, because the site owner is answerable for what is stored on their site. Every one of those reads is written to the activity log by the same listener that records an employer's — an administrator is not exempt from the audit trail.

API
register()available()url()menu()tabs()candidates()applications()stats()render()
inc/_jb/AdminResumes.php

ApplicationProfile ApplicationProfile.php​

The details a candidate sends WITH one application — their own, tailored for that job. WHY THIS EXISTS. An application used to carry a covering letter, a CV snapshot, and a pointer to whoever sent it. Everything else an employer read — job title, location, years, skills, work history — was fetched live from the candidate's profile. That is wrong in both directions: - Nobody applies for two different jobs with the same words. A care assistant applying to a hospital and to a private client wants to lead on different things. With one shared profile they get one pitch, or they edit it and silently change what every employer who already has their application sees. - An application is a record of what was said AT THE TIME. Reading it live means a profile edited six months later rewrites history in an employer's inbox. So an application carries its own copy, exactly as it already carries its own copy of the CV file (see Applications' snapshot reasoning — this is the same argument applied to the text). The candidate's account is the DEFAULT the copy is made from, never the source an employer reads. THE DRAFT. Tailoring happens across several screens before an application exists, so the part-finished version lives in the candidate's own user meta, keyed by job. That makes leaving the flow safe — come back tomorrow and the tailoring is still there — and it keeps half-made applications out of employers' inboxes, which is what creating the post up front would have done.

API
fields()standingFields()fromAccount()forEditing()draft()hasDraft()saveDraft()clearDraft()draftJobs()resume()attachResume()resumeToSend()attach()get()isSnapshot()changed()
inc/_jb/ApplicationProfile.php

Applications Applications.php​

Applications — a candidate applying to a job, and everything that follows from it. THE RESUME IS SNAPSHOT, NOT LINKED. This is the decision the rest of the class hangs off, so it is worth being clear about. When somebody applies, the bytes of their current resume are COPIED into a file this application owns. The employer reads that copy, for ever, whatever the candidate does afterwards. Linking to the live resume would have been less code and wrong three times over: - _jb\Resumes keeps one resume per candidate and deletes the old bytes on replace, so an application pointing at the live record would break the moment somebody tidied up their CV; - an employer shortlisting on Tuesday should see what they read on Tuesday, not a version edited on Wednesday; - and a candidate who applied with one CV has not consented to the employer watching every later revision of it. So applying does NOT grant sight of the live resume. ppt_jb_resume_can_view — the seam built for exactly this in _jb\Resumes — is deliberately left alone here; it stays available for resume-database access, which is a different bargain the employer pays for. Applying grants one file, frozen, to one employer. STATUS IS META, NOT POST STATUS. Custom post statuses read elegantly and then quietly break every 'post_status' => 'any' query in the codebase, including the ones that count things for the hub. _ppt_app_status is boring and queryable. WHO OWNS WHAT. The application is authored by the CANDIDATE (so post_author answers "whose is this?" and WordPress's own user-deletion cascade reaches it) and parented to the JOB (so the employer's inbox is one post_parent query, not a meta scan).

API
register()registerType()statuses()employerStatuses()statusLabel()isFinal()existing()whyCannotApply()isExpired()markOpened()handleFill()filledButOpen()stillOpenFor()declineRest()markFilled()openedAt()canApply()apply()get()forJob()forCandidate()forEmployer()counts()unreadFor()
inc/_jb/Applications.php

AxonCard AxonCard.php​

Axon's job row — a three-column card: the employer's mark, the role and its discipline chips, and a right-hand stack carrying the salary, the age of the advert and an Apply pill. A "New" tab rides the card's top edge when the row carries a flag. Lifted from axon_roles. Every rule is scoped to .ppt-jbcard--axon, the card's own root, never to the listings block's section — see {@see JobCard::css()}.

API
render()railCss()css()
_jb/Cards/AxonCard.php

BrigadeCard BrigadeCard.php​

Brigade's job row — a three-column card with a square employer mark, a condensed uppercase job title over its employer line and section chips, and a right-hand stack carrying the rate, the age of the advert and a solid Apply button. The card's left edge is a 4px rule that lights paprika on hover, and a "New" tab rides its top-left corner when the row carries a flag. Lifted from brigade_jobs. Every rule is scoped to .ppt-jbcard--brigade, the card's own root, never to the listings block's section — see {@see JobCard::css()}.

API
render()css()
_jb/Cards/BrigadeCard.php

BuyerProtection BuyerProtection.php​

Classifieds — Buyer Protection fee. The classifieds "Buy now" flow (Ppt_jb\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/_jb/BuyerProtection.php

CandidateProfile CandidateProfile.php​

The structured candidate profile — the half of a resume a computer can read. A PDF is opaque. An employer searching for "React, Toronto, five years" cannot filter on a file, and neither can we: Resumes stores bytes, and bytes do not answer questions. So a candidate has TWO things, and the split is deliberate: _jb\Resumes the file. Private, per-pair permission, downloaded. _jb\CandidateProfile the facts. Structured, queryable, and — when the candidate says so — searchable by employers. WHY THEY ARE SEPARATE RECORDS. Two reasons, one practical and one about privacy. Practically, a candidate replaces their PDF often and their work history rarely; if the facts hung off the file record, every re-upload would throw the profile away. The privacy reason matters more: it lets an employer search and preview a profile WITHOUT being handed the document. Resume-database access shows what someone can do; the file, with their address and phone number in it, still needs the per-pair grant in Resumes::canView(). Real boards work this way and it is the right default. SEARCHABLE IS OFF UNTIL ASKED. A candidate who uploads a resume to apply for one job has not agreed to be found by every employer on the site — and under PIPEDA that distinction is the whole ballgame. So _ppt_cand_searchable starts at 0 and the hub asks. A site owner who wants the other default has ppt_jb_profile_default_searchable, and should think hard before using it. SKILLS ARE TERMS, NOT TEXT. ppt_skill is a taxonomy so the resume search in item 5 is a tax_query rather than a LIKE over a meta blob — the difference between a search that stays fast at ten thousand candidates and one that does not. Free-typed skills are matched to existing terms before new ones are created, so "react", "React" and "React.js" do not become three facets. EXPERIENCE AND EDUCATION are JSON row-sets in one meta key each, because nothing queries an individual row — the searchable facts (years, current title, location) are denormalised into their own meta keys where an index can reach them.

API
register()registerType()attachCategories()remoteOptions()availabilityOptions()periodOptions()levelOptions()regionOptions()zoneOptions()eligibilityOptions()authCountry()authCountryName()forUser()ensureFor()get()save()skills()setSkills()categoryTax()categoryChoices()categories()categoryNames()setCategories()multi()
inc/_jb/CandidateProfile.php

CandidateWizard CandidateWizard.php​

The candidate profile as a FULL-PAGE stepper, at /candidate-profile/. WHY THIS EXISTS SEPARATELY FROM THE HUB PANEL. The same five steps used to live inside the Member Hub's "My Profile" panel, in a column perhaps 640px wide with the hub's own navigation rail beside it. That is enough room to EDIT a profile you already have and not nearly enough to BUILD one: every field sat in a single narrow column, the resume upload was a third form stacked under the rest, and the one job the page has — get a stranger from nothing to applying — read as an admin screen. So the flow moved out to its own page and the hub panel became a summary that links here. One flow, one owner: the hub reports, this edits. THE LAYOUT, which is the point of it: ┌──────────────────────────────────────────────┐ │ STEP 1 OF 3 ████████████░░░░░░░░░░░░░░░░░░░ │ ├───────────────────────────┬──────────────────┤ │ their information │ their resumes │ │ (headline, location, │ (drop a file, │ │ salary, skills …) │ the files they │ │ │ already have) │ │ [ Continue ] │ │ └───────────────────────────┴──────────────────┘ The right-hand column is not decoration and not a marketing panel: it is the resume, which is the one thing on this page an employer actually receives. Keeping it beside the fields rather than under them means a candidate can never finish the flow having quietly skipped it, which is what happened when it was step 1 of a linear stack. ARRIVING FROM A JOB. "Apply" on a listing sends the candidate here with ?job=N. That adds a final step — the covering letter and the submit button — and makes the whole flow the application itself, rather than a detour they have to find their way back from. Somebody whose profile is already complete lands straight on that last step; somebody with nothing lands on step 1, which is the "take you to the resume section" behaviour asked for.

API
register()handleAttach()rewrite()queryVar()url()steps()stepMeta()maybeRender()isDemoJob()demoTitle()jobIsOpen()css()
inc/_jb/CandidateWizard.php

CardData CardData.php​

The one normalised shape every Job Board card is drawn from, built from whichever of three very different sources the page happens to have. This is what makes "one card definition, home page AND search" true rather than a claim: the renderers in this folder take {@see JobCard}'s shape and nothing else, so a design's row is written once and appears wherever a job is listed. fromPost() a published job — search results, category archives fromFields() a listings block's curated item{N}_* row — the design's home page (already overwritten with the owner's real jobs by LiveItems on a live site, which is why this path must not re-read the database) fromCard() the shared ListingCard shape — the showroom's sample rows, which have no post behind them

API
fromPost()fromFields()fromCard()postedFor()
_jb/Cards/CardData.php

Cards Cards.php​

Which card the active Job Board design draws, and the shared plumbing around it. Every Job Board design owns its own results card by implementing {@see \Ppt\Designs\Contracts\HasSearchCard} — the same optional contract the Coupon fork uses, resolved here for jobs. This is the one place that asks the design which class that is, and the one place that decides what happens when it does not answer: results card → the design's, else {@see DefaultCard} (a plain, token-driven row) search box → the design's, else none, which is what jobs browse pages carry today So a design added tomorrow gets its own row by declaring one class name, and a design that declares nothing still gets a ROW rather than the shared photo card: a job post is a role, a salary and a place, and the four-by-three picture tile the Directory draws was never the right shape for one. Two things are shared rather than left to each design, because they are page mechanics rather than looks: the results-grid rules (Bootstrap's row-cols sizes every direct child, so a full-width row has to opt out whichever design drew it), and printing the stylesheet once per request.

API
register()reset()card()box()renderPost()renderFields()renderCard()printBox()boxContext()assets()
_jb/Cards/Cards.php

Checkout Checkout.php​

Classifieds — "Buy now" checkout for a single ad. A buyer on a classifieds single hits Buy now (see the buy box in the fork's single template + Ppt_jb\BuyerProtection), lands on the shared /checkout/ page with ?buy=

``, fills their delivery address, picks a gateway and pays. On the /callback/ return the order completes, the ad is marked sold to the buyer (Ppt_jb\MarkSold), and buyer + seller + admin are emailed. 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 a CLAS- reference prefix (cf. the shop's SHOP- / auction's AUCT-) so Pages::content() can route /callback/ to this class. Registered only when the Classifieds profile is active (Theme::boot fork-gating), so its files never touch another profile. This is a single-item, buy-on-the-spot flow (like the marketplaces it mirrors), so — unlike the shop — there is no multi-item cart: the order is created at pay time for exactly the ad being bought.

API
register()buyUrl()callbackUrl()isClassifiedsRef()itemPrice()isBuyable()needsShipping()totals()handlePay()markPaid()ajaxShippingQuote()renderCheckout()renderCallback()
inc/_jb/Checkout.php

ChimeCard ChimeCard.php​

Chime's job row — a three-column card on a white surface: a round plum employer mark, a serif job title over its company line and pill chips, and a right-hand stack carrying the pay in a marigold-tinted pill, the age of the advert and an outlined plum Apply pill that fills on hover. A marigold "New" tab rides the card's top-right edge when the row carries a flag. Lifted from chime_jobs. Every rule is scoped to .ppt-jbcard--chime, the card's own root, never to the listings block's section — see {@see JobCard::css()}.

API
render()css()
_jb/Cards/ChimeCard.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/_jb/Comments.php

Company Company.php​

Job Board — how an employer is IDENTIFIED on the public side. The head band and the sidebar company card both name the same employer, show the same logo and count the same open roles. Three small readers in one place so they cannot drift — and so the fallback chain (company logo → member avatar → initial) is written once. There is no company post type on this fork: an employer IS the WordPress user who authored the job, and CompanyProfile hangs the logo / cover / website off their user meta.

API
nameFor()logoFor()name()logo()websiteFor()website()initial()openJobs()
inc/_jb/Company.php

CompanyProfile CompanyProfile.php​

The employer's public face: logo, cover image, website and social links. Account\AuthorProfile already renders a member's public page — avatar, name, location, followers, bio, and a grid of their listings — and on a jobs board that page IS the company page. What it had no concept of was a COMPANY: a logo rather than a personal avatar, a cover image behind it, and the two links every candidate looks for before applying (the company's own site, and somewhere to see who works there). WHY THESE FILES ARE ORDINARY ATTACHMENTS. A resume goes into Media\PrivateStore because it is one named person's private document. A company logo is the opposite: it is published on purpose, on a page built to be found. Putting it in the private store would mean streaming every logo through PHP on every page view for no benefit at all. WHY HOOKS RATHER THAN ANOTHER TEMPLATE BRANCH. author-profile.php already carries per-fork flags ($flOn for freelance, $prOn for providers) with explicit branches in the markup. Rather than add a fourth set, this attaches to two new actions in that template — so the NEXT fork that wants something on the profile page does not have to touch it either.

API
register()networks()imageMimes()get()has()logoUrl()save()attach()detach()purgeForUser()renderCover()renderLinks()css()renderPanel()panelCss()handleSave()
inc/_jb/CompanyProfile.php

ConvoyCard ConvoyCard.php​

Convoy's job row — a three-column card on the asphalt surface: the employer's mark, the role in condensed uppercase with an outlined licence chip, and a right-hand stack carrying the rate set in hi-vis, the age of the advert and an Apply button. The card's left edge lights up lime on hover, and a "NEW" tab rides its top-right corner. Lifted from convoy_jobs. Every rule is scoped to .ppt-jbcard--convoy, the card's own root, never to the listings block's section — see {@see JobCard::css()}. ONE CHIP, NOT THE MOCKUP'S THREE. The approved row showed licence, shift and pattern side by side; a live listing carries exactly one of those (its category), so the other two would be a home-page-only flourish that vanishes the moment a real job is drawn. The one that survives is drawn as the licence chip, because that is the first thing an HGV driver checks.

API
render()css()
_jb/Cards/ConvoyCard.php

DefaultCard DefaultCard.php​

The Job Board fork's own job row — what a design that declares no card of its own gets, and what the search page draws when no design is active at all. It is a ROW rather than the Directory's photo card on purpose. A job post is a role, an employer, a place and a salary; the four-by-three picture tile the rest of the theme lists things in gives most of its area to an image a job rarely has and squeezes the facts that matter into two lines beneath it. Every Job Board design that has been drawn so far reached the same conclusion independently, so the fork default follows them rather than the shared card. Entirely token-driven — no colour, font or radius of its own — so it belongs to whichever design is active instead of to one reference site.

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

DefaultFields DefaultFields.php​

The Job Board's factory custom fields. _jb shipped NO default fields — it was cloned from Classifieds, which has none, and nothing replaced them. So a fresh jobs board had an empty Details set, and the single page's "About the job" card could not answer the one question every job board on the internet answers in its sidebar: is this full-time? Kept deliberately small. A job advert's other facts already have homes of their own and must not be duplicated here: the salary is _jb\Salary (it feeds search, the cards and the JobPosting schema), the closing date is _jb\Expiry, the location is the city field, and the category and skills are taxonomies. A second "Salary" custom field would be two numbers that disagree the first time somebody edited one. Employment type has no such home, and Google's JobPosting schema wants it, so it becomes a real field an employer fills in rather than something invented for the showroom.

API
register()defaultFields()seeds()definitions()seedFields()
inc/_jb/DefaultFields.php

DemoAccounts DemoAccounts.php​

Job Board — what the typed DEMO members walk into on top of the shared furnishing (Account\DemoFurnish: favourites, follow, inbox thread). the EMPLOYER (AccountType::SOLO) three seeded jobs of their own (DemoLogin lends them — the count is raised here from one), with applications arriving on them, and the candidate saved to their shortlist; the CANDIDATE (AccountType::CLIENT) a filled-in candidate profile, a resume on file, and those applications in the states the board has — new, shortlisted, interviewing. Built through the fork's own machinery (CandidateProfile, Resumes::store, Applications:: apply / setStatus, SavedCandidates) so both hubs read exactly what a real applicant leaves behind; removed on the pair's unfurnish action. Gated to the jobs profile by Theme::boot()'s fork-gating (this is Ppt_jb*).

API
register()lendCount()pair()unpair()
inc/_jb/DemoAccounts.php

DemoEmployers DemoEmployers.php​

Job Board — who posted the showroom's sample vacancies. A job advert without an employer is not a thin card, it is a different document: the two things a candidate scans a results list for are the role and who is hiring, and the niche fixtures in {@see \Ppt\Content\SampleListings} carry only the role. Every design's HOME page names an employer on every row (its listings block types them into item{N}_author), so the showroom's SEARCH page was the one surface on this fork showing anonymous jobs — the difference the customer sees when they click "Search" from a design preview. This is the missing half of those fixtures, merged onto the card set by row index the same way the Freelancer fork's "who posted it" meta is ({@see \Ppt\Content\SampleListings::freelanceCardMeta()}). One table, so the search card, the virtual single page behind it and anything else that names the employer of a sample job cannot drift apart. Names are invented and deliberately in the same voice as the designs' own curated rows — where a fixture is the same role one of them advertises, it reuses that design's employer verbatim, so a visitor moving between a home page and its search results sees one coherent board rather than two casts. posted is written BARE ("2 hours ago"), not "Posted 2 hours ago": each design's card phrases the line its own way, and Lanyard is the only one that prefixes it.

API
forRow()has()
inc/_jb/DemoEmployers.php

DemoProfiles DemoProfiles.php​

Job Board — the demo copy for a job advert. Picked up automatically by Tools\SampleData::profileCopy('body', …), which every demo path already calls: the showroom's virtual single (Frontend\DemoListing) and the seeded sample listings both route through it, so one writer keeps them saying the same thing. WHY THIS EXISTS. Without a fork copy, a job advert fell back to the shared filler, which is written for a directory listing and ends "…each order is packed with care and dispatched quickly. If you have any questions about specifications, sizing or availability, our team is happy to help before you buy." On a Care Assistant vacancy that is not thin copy, it is the wrong document — and it is the first thing a customer reads when they open the demo. A real advert is also LONG. The job-description column on the boards this design was matched against runs 400-800 words of headed sections and bullet lists, and the page is laid out for that — a two-paragraph stub leaves the sidebar hanging beside white space. So this returns the real shape: an opening, the duties, what the employer wants, what they offer, and how to apply. Copy is generated from what the fixture actually knows — the role's title, its category, its city and its one-line excerpt — and rotated on $i so nineteen sample adverts do not read identically. Nothing here invents a fact the page contradicts: there are no salaries, closing dates or company names in the prose, because those live in the sidebar and would be two sources of truth the moment either changed.

API
body()faq()
inc/_jb/DemoProfiles.php

EmployerLogos EmployerLogos.php​

Job Board — the logo marks the invented demo employers wear. A jobs board is a list of companies as much as it is a list of roles, and the one thing a candidate recognises before they read a word of the advert is the mark beside it. Every demo row on this fork used to show two letters on a tinted square instead ({@see \Ppt_jb\Cards\CardData}'s initials), which reads as a placeholder rather than a company — a showroom of thirty adverts all wearing the same three tints tells a visitor nothing about what their own board will look like once real employers upload real logos. So each invented employer gets a mark drawn for its trade, shipped as one small SVG in assets/img/_jb/logos/. The same idea (and the same Asset::url(...'.svg') call) as the Coupon fork's store wall, which ships the retailer marks its starter stores wear. Three rules this class exists to hold: 1. A name is the key, not a row index. The same employer appears in the search fixtures ({@see DemoEmployers}), in a design's curated home rows, and on the virtual single page behind the card — three tables that already agree on the NAME. Keying on it is what stops "Meadowbrook Care Group" wearing one mark on the home page and another in search results. 2. Only a name in this map ever resolves. No slugify-and-hope: a miss returns '' and the caller falls back to the monogram it always drew. That also means a real customer's employer can never accidentally inherit a demo mark, and it keeps the class honest about the CDN — {@see \Ppt\Support\Asset::has()} answers true for EVERY path once assets are served from a CDN, so a has() guard here would be worthless and a generated slug would happily emit a URL for a file nobody drew. 3. Demo only. A published listing's employer mark comes from their company profile through {@see Company::logo()}; this map is consulted for the showroom's inventions alone. The marks are drawn by dt10-backups/mockups/jb-logos/gen-logos.js, which writes the SVGs and this map's body from one table, so a new employer cannot arrive with a mark and no entry (or the reverse). tools/qa/verify-jb-employer-logos.php asserts both halves still line up.

API
forName()all()
inc/_jb/EmployerLogos.php

EmploymentTypes EmploymentTypes.php​

Employment type as a real TAXONOMY for the Job Board (user ask 2026-09-13). _jb already answers "is this full-time?" with the job_type custom field (inc/_jb/DefaultFields.php), which is what the single page's "About the job" panel prints. A custom field cannot be SEARCHED on, though — Directory\SearchPage builds its facets from the listing taxonomies — so every "Full-time" pill the board showed a visitor could only ever be a KEYWORD search for the words "full-time": it matches whatever happens to say them in a job description, and misses every job that does not. Lanyard's hero pills were exactly that. So employment type gets the home the Software Theme gave its buyer terms ({@see \Ppt_so\SoftwareTaxonomies}): a taxonomy. That buys the board a working search facet with live counts, an archive URL per type, and a vocabulary the owner edits on an ordinary Listings taxonomy screen. Three jobs, each one-shot and each refusing to fight a curated list: - seed the taxonomy DEFINITION into the admin's own custom-taxonomy option (Listings::CUSTOM_OPT), so it is an ordinary owner-editable taxonomy rather than something only code can change — skipped entirely when a taxonomy for employment type already exists, which is how a taxonomy an owner made by hand survives a theme update; - seed its terms once, from the SAME option lines the job_type field ships, so the two vocabularies cannot disagree — and only into an empty taxonomy; - resolve, for the blocks, which taxonomy on THIS site means employment type and what a term's search URL is. The job_type field stays. It is what an employer fills in, what the About panel prints and what JobSchema reads; Admin\Migrations::jbEmploymentTypeTerms() copies its stored answers onto the matching terms, so the field and the facet agree about the jobs a board already has.

API
register()candidates()taxonomy()terms()url()seedTaxonomy()seedTerms()vocabulary()syncOnce()backfill()flushOnce()
inc/_jb/EmploymentTypes.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/_jb/Expiry.php

Exports Exports.php​

CSV exports a jobs board needs and Porting\Exporter does not cover. That exporter handles LISTINGS, which on this fork means the jobs themselves and works already. The two things an owner actually asks for on a jobs board are the ones it has no concept of: applications and members. FORMULA INJECTION IS THE WHOLE SECURITY STORY OF A CSV. Every cell here goes through self::safe(), which prefixes anything opening with = + - @ or a control character with an apostrophe — the same guard Porting\Exporter::csvSafe() uses. Without it a candidate can put =cmd|... in their headline and the site owner opening the export in Excel runs it. Copied rather than shared only because that method is private; the rule is identical and must stay identical. THE MEMBER EXPORT IS DELIBERATELY THIN. Name, email, type, joined, and counts. NOT the resume, not the work history, not the summary. A spreadsheet emailed around an office is the least controlled copy of personal data a site will ever produce, so it carries the minimum that makes it useful for what people actually export members for — seeing who is on the board and getting in touch. ADMIN ONLY. Both exports are whole-database dumps; manage_options is the gate, checked in the endpoint and again in each builder.

API
register()kinds()url()applications()members()safe()filename()handle()
inc/_jb/Exports.php

FareCard FareCard.php​

Fare's job row — a three-column card: the operator's mark on a ROUND tile, the role with its sector chip, and a right-hand stack carrying the pay on a cab-black pill, the age of the advert and an Apply pill that turns roof-light yellow on hover. A "New" tab rides the card's top-right edge when the row carries a flag. Lifted from fare_jobs. Every rule is scoped to .ppt-jbcard--fare, the card's own root, never to the listings block's section — see {@see JobCard::css()}. ONE CHIP, NOT THE MOCKUP'S THREE. The approved row showed sector, contract and pattern side by side; a live listing carries exactly one of those (its category), so the other two would be a home-page-only flourish that vanishes the moment a real job is drawn. The one that survives is the sector, because that is how this trade divides itself.

API
render()css()
_jb/Cards/FareCard.php

FoyerCard FoyerCard.php​

Foyer's job row — a three-column card: a round employer mark, the position with its department chips, and a right-hand stack carrying the pay, the age of the advert and an outlined Apply pill. A brass "New" tab rides the card's top-right edge when the row carries a flag. Lifted from foyer_jobs. Every rule is scoped to .ppt-jbcard--foyer, the card's own root, never to the listings block's section — see {@see JobCard::css()}.

API
render()css()
_jb/Cards/FoyerCard.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/_jb/Gallery.php

GantryCard GantryCard.php​

Gantry's job row — a three-column card on the dark ground: a circular mark, the role and its chips, and a right-hand stack carrying the day rate in safety orange over the period it is quoted for, the age of the advert and an outlined Apply pill that fills on hover. An orange rule lights down the card's leading edge on hover, and a "New" tab rides its top edge when the row carries a flag. Lifted from gantry_jobs. Every rule is scoped to .ppt-jbcard--gantry, the card's own root, never to the listings block's section — see {@see JobCard::css()}. The pay is ONE string split by {@see PayLine}, not two

fields: see that class for why a second field would be a home-page-only flourish.

API
render()css()
_jb/Cards/GantryCard.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/_jb/Geocoder.php

GessoCard GessoCard.php​

Gesso's brief row — a three-column card: a rounded-square studio mark, the brief with its discipline chip, and a right-hand stack carrying the rate, the age of the brief and an outlined Apply pill. An oxblood "New" tab rides the card's top-right edge when the row carries a flag. Lifted from gesso_briefs. Every rule is scoped to .ppt-jbcard--gesso, the card's own root, never to the listings block's section — see {@see JobCard::css()}.

API
render()css()
_jb/Cards/GessoCard.php

HelioCard HelioCard.php​

Helio's job row — a three-column card: a circular employer mark, the job with its pill sector chips, and a right-hand stack carrying the pay underscored in marigold, the age of the advert and a filled Apply pill that turns marigold on hover. A dark "New" tab rides the top-right edge when the row carries a flag. Lifted from helio_jobs. Every rule is scoped to .ppt-jbcard--helio, the card's own root, never to the listings block's section — see {@see JobCard::css()}.

API
render()css()
_jb/Cards/HelioCard.php

Interviews Interviews.php​

Interview scheduling, attached to one application. WHY NOT THE BOOKING ENGINE. Ppt\Bookings books a LISTING: a customer picks from opening hours against a per-slot capacity, on a public advert. An interview is the opposite shape — a private arrangement between two people who already know each other about one application, with no capacity and nothing to publish. Routing it through Bookings would mean job adverts advertising availability, which is not a thing a job advert does. THE SHAPE OF IT, and why it is a proposal rather than a calendar: The employer offers a FEW times. The candidate picks one. That is it. Not a live calendar with the employer's real availability, because a jobs board does not hold the employer's diary and pretending otherwise produces double bookings it cannot see. Not a single fixed time either, because "be there at 2pm Thursday" from a stranger is how candidates drop out. Two or three options is what a person would offer over email, and it needs no integration to be honest. CONFIRMING MOVES THE STAGE. Once a time is agreed the application becomes Interviewing without anybody remembering to set it, because a pipeline that has to be maintained by hand alongside the thing it describes will drift from it.

API
register()modes()get()isConfirmed()isPending()propose()confirm()cancel()when()whereText()icsUrl()canSee()handlePropose()handleConfirm()handleCancel()handleIcs()emails()
inc/_jb/Interviews.php

JobAlerts JobAlerts.php​

Job alerts — the email a saved search sends when something new matches it. Account\SavedSearch has always stored the search, normalised it and described it in plain English. What it never had was the other half: a cron, a matcher, and a send. A candidate could save a search and then had to remember to come back and run it, which is the opposite of why anybody saves one. ONE EMAIL PER MEMBER, NOT PER SEARCH. Somebody with four saved searches and a busy board would otherwise get four emails on the same morning, and the third one is spam however good the matches are. Matches are grouped under their search's own label. ONLY WHAT IS NEW SINCE THEY WERE LAST TOLD. Every search carries its own high-water mark. Without it the first run mails somebody the entire board and the second run mails it again — the classic alert-system failure, and the one that gets a domain blocked. A FIRST RUN SENDS NOTHING. A search saved today has its mark set to now, so the first alert covers jobs posted after the member asked to be alerted — not the archive they have already browsed. WHAT IT DELIBERATELY CANNOT MATCH. near + radius geo searches are skipped, not approximated: the radius filter runs through posts_clauses in _jb\SearchPage with a geocoded origin, and half-implementing it here would send people jobs in the wrong city. A saved search with a radius still works on the site; it just does not alert, and self::alertable() says so out loud so the hub can mark the row.

API
register()schedule()emailCatalog()alertable()pathArgs()matches()marks()stamp()stampNew()isOff()setOff()unsubscribeUrl()handleUnsubscribe()run()
inc/_jb/JobAlerts.php

JobCount JobCount.php​

Resolves the {jobs} token the Classic Jobs designs use wherever their copy states how many roles the board is carrying. The token expands to a counted noun ("846 jobs", "1 job"), never a bare digit. A number typed into a design default is a CLAIM about the customer's site — the lesson the Coupon designs learned when three of them shipped invented store counts that disagreed with the stats panel on the same page. So the designs write {jobs} and this fills it with the real published-listing count. A count of zero BLANKS the whole string rather than printing "0": dropping only the digit reads fine where the number is an adjective ("All {jobs} jobs" → "All jobs") and leaves wreckage where it is the subject ("{jobs} jobs in Manchester"). Callers already guard on an empty string and drop the element.

API
total()fill()
inc/_jb/JobCount.php

JobFacts JobFacts.php​

Job Board — the sidebar's "About the job" card and the company card above it. WHY THE FACTS LEFT THE MAIN COLUMN (user ask, 2026-09-13). Everywhere else in the theme a listing's custom fields ride inside the Overview card, under the write-up, because they are specifics about the thing being described. A job advert reads the other way round: the write-up IS the job and runs for a thousand words, so the facts a candidate actually decides on — what it pays, where it is, when it closes — must not be buried under it. Both reference boards this page was matched against put them in a fixed sidebar card, and so does this one. The shell skips its own placement via $pptDetailsInOverview. Rows are DROPPED when empty rather than printed as a dash: an employer who quotes no salary should leave no "Salary —" behind, which reads as a refusal rather than an omission.

API
companyHead()companyFoot()render()shareCard()css()
inc/_jb/JobFacts.php

JobHero JobHero.php​

Job Board — the single-listing head band. A job advert has no gallery to lead with (this fork ships no media card at all — see inc/_jb/ListingEditor.php), so the top of the page is the ROLE: who is hiring, what the job is called, how fresh the posting is, and the two things a reader can do about it. That is the shape every jobs board converges on, and it is what the two reference boards the design was matched against both do. Drawn through the shell's head-takeover seam (templates/parts/single-hero.php sets $pptHeroDrawn), which also switches OFF the gallery and the shell's own title block — so this band has to carry the state markers that block held (verified / sold / expired) or they vanish silently. Painted from design tokens only, so one band wears Lanyard, Punch and Rung — and anything a customer builds after them.

API
render()css()
inc/_jb/JobHero.php

JobRail JobRail.php​

JobRail — the filter column a Job Board design's home page draws beside its job list. The Job Board line's half of {@see \Ppt_cp\HomeRail}. The three Classic Jobs designs (Lanyard / Punch / Rung) were each approved with a rail of their own, and each drew it as hand-built markup inside its listings block — so the commute card existed twice, the locations list twice, and a fourth design would have had to write both a third time. The panels are now sidebar BLOCKS ({@see \Ppt\ppt_blocks_jb\sidebar}) and this is the one place a list of them is turned into a rail. ══ 2026-09-13: THE RAIL IS THE THEME'S RAIL NOW (user-directed) ══════════════════ What these designs drew was a PICTURE of a filter rail — tick-boxes typed into a textarea, which looked exactly like filters and filtered nothing. The home page draws the theme's REAL search rail instead ({@see self::liveRail()} → Directory\Filters\Rail): the same renderer the search page uses, the same facets, the same "Filter rail style" setting, real terms with real counts, and a submit that lands on the filtered results. The design's panels are still here, and still built from the block's own editable fields — as the FLOOR, for a site that has no filters to draw yet. See render() for the precedence. An owner's hand-built Widgets rail still outranks everything. From 2026-09-11 a Job Board owner can also build a home rail by hand on PremiumPress ▸ Widgets, and when they have placed anything there it REPLACES the design's — the same order of precedence {@see \Ppt_cp\HomeRail} uses, and the same exception: in a sample context (the showroom, the demo-default preview, a builder canvas) the DESIGN's panels are drawn whatever this install's owner has saved, so a design is always reviewed as designed rather than through someone else's rail. Nothing here ever WRITES that option: switching designs must not rewrite an owner's list, and a design's defaults are its own, not the site's. The panels are drawn through their block classes DIRECTLY rather than through a nested {@see \Ppt\Blocks\Renderer::render()} pass: the calling block is itself inside that renderer, whose per-block overlay state (the live-items slot map) a nested pass would reset underneath it. Rail panels are chrome — they read none of those overlays.

API
render()drawsLiveRail()liveRail()css()panel()
inc/_jb/JobRail.php

JobSchema JobSchema.php​

Google Jobs structured data — schema.org JobPosting on a job's own page. This is what puts a board's roles into the Google Jobs box, and it is the one SEO feature a jobs board cannot do without: most candidates never see the site's own search. Frontend\Schema could not do it — it bails unless is_singular('post'), so it covers the blog and nothing else, and a listing single emitted no structured data at all. THE RULE THIS CLASS IS BUILT AROUND: say nothing rather than guess. Google treats JobPosting as a factual claim about a real vacancy, and penalises boards that make claims that turn out to be wrong — so every field here is omitted when the underlying fact is missing, and nothing is defaulted: - no baseSalary unless the employer said which PERIOD the figure is (see _jb\Salary; an hourly rate published as a yearly salary is the exact error Google penalises) - no employmentType unless a term on the job actually maps to one of Google's values — the category taxonomy holds SECTORS ("Trades & construction"), which are not employment types and must not be passed off as them - no jobLocation without at least a locality - nothing at all for an EXPIRED job. Google asks that filled or lapsed postings stop being marked up; an expired listing here stays published and merely goes noindex, so without this check the board would go on claiming a vacancy that is gone. directApply is true, and honestly so: since _jb\Applications there is a real apply flow on the page rather than a link off to somebody else's form.

API
register()employmentTypeMap()remoteSlugs()output()build()
inc/_jb/JobSchema.php

JobSort JobSort.php​

The job board's home-page "order by" control. Every _jb home design draws a sort pill beside its listings heading ("Sort by [Newest]"). Until this class existed all 26 of them were the SAME dead span — a word and a caret glyph, no <select>, no link, nothing bound to it in JS — so the one control a job seeker reaches for first did nothing on any design. The control itself now lives in {@see \Ppt\Blocks\Support\SortControl}, because the same dead pill was drawn by designs on other lines (Car Dealer's Veloce, Learning's Circleboard) and one implementation beats a copy per fork. What stays here is everything that is the JOB BOARD'S about it: - {@see self::FORM_ID} — the id on {@see JobRail::liveRail()}'s GET form. _jb is the only line whose home rail is a real form ({@see \Ppt\Designs\Contracts\HasSearchRail}), so it is the only line whose select joins one instead of navigating by data-url. - The orders are pinned to _jb's OWN {@see SearchPage::sortOptions()} rather than to whichever fork is active, so a _jb design drawn in another line's showroom still offers "Salary: high to low" and not "Price: high to low". - The ppt_jb_home_sort_options filter, and the "Order jobs by" accessible name.

API
options()current()render()css()js()
inc/_jb/JobSort.php

JoistCard JoistCard.php​

Joist's job row — a three-column card with a rounded gunmetal mark, the role and its chips, and a right-hand stack carrying the pay in copper over the period it is quoted for, the age of the advert and a filled Apply pill. A "New" tab rides the card's top edge when the row carries a flag. Lifted from joist_jobs. Every rule is scoped to .ppt-jbcard--joist, the card's own root, never to the listings block's section — see {@see JobCard::css()}. The pay is ONE string split by {@see PayLine}, not two

fields: see that class for why a second field would be a home-page-only flourish.

API
render()css()
_jb/Cards/JoistCard.php

KelvinCard KelvinCard.php​

Kelvin's job row — a hairline table row rather than a floating card: a square outlined employer mark, the role in the serif with a mono dotted tag line, and a right-hand stack carrying the pay in the mono face, the age of the advert and an outlined Apply button. A copper "New" tab hangs from the row's top edge, and a copper bar lights the left edge on hover. ══ WHERE THE HAIRLINE LIVES ══════════════════════════════════════════════════════ The card draws its OWN full border by default, so a row standing alone — every result on the search page, every card on a category archive — is a complete object. Inside kelvin_board's list the block collapses that to a single bottom hairline and lets the list carry the outer frame, which is what makes the home page read as one ruled table. Putting the hairline on the list alone would look right on the home page and vanish everywhere else, which is the classic failure this fork's README warns about.

API
render()css()
_jb/Cards/KelvinCard.php

LanyardCard LanyardCard.php​

Lanyard's job row — the friendly general-board card: an initials mark beside the role and employer, an Easy-apply flag in the corner, city / type / salary chips, the posted date and an Apply pill. Lifted verbatim from lanyard_jobs. Every rule is scoped to .ppt-jbcard--lanyard, the card's own root, never to the listings block's section — see {@see JobCard::css()}.

API
render()css()
_jb/Cards/LanyardCard.php

LatticeCard LatticeCard.php​

Lattice's job row — a hard-edged technical card: a bordered monogram square, the role and employer, uppercase mono spec tags, and a ruled right-hand column carrying the salary and the advert's age in tabular mono with an APPLY link under them. A featured row gains a cobalt left edge and a corner label. Lifted from lattice_roles. Every rule is scoped to .ppt-jbcard--lattice, never to the listings block's section — see {@see JobCard::css()}.

API
render()railCss()css()
_jb/Cards/LatticeCard.php

LigatureCard LigatureCard.php​

Ligature's brief row — a three-column card on the dark surface: a round production mark, the brief with its department chip, and a right-hand stack carrying the rate in coral, the age of the brief and an outlined Apply pill that fills coral on hover. A coral "New" tab rides the card's top-right edge when the row carries a flag. Paints from tokens only: on the "ligature" scheme --ppt-surface is dark and --ppt-ink is light, so a literal #fff anywhere here would be the brightest thing on the page. Lifted from ligature_briefs. Every rule is scoped to .ppt-jbcard--ligature, the card's own root, never to the listings block's section — see {@see JobCard::css()}.

API
render()css()
_jb/Cards/LigatureCard.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/_jb/ListingActions.php

ListingCard ListingCard.php​

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

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

ListingEditor ListingEditor.php​

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

API
register()newUrl()handleNew()submissionsOpen()memberCanAdd()canEdit()adminEditUrl()memberEditUrl()applicableFields()locationKeys()coordKeys()mapPickerReady()mapConfig()locationFields()socialNetworks()socialValue()socialIcon()hoursDays()hoursValue()normTime()bookingValue()bookingCapacity()bookingBlackout()bookingTz()
inc/_jb/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/_jb/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/_jb/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/_jb/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/_jb/ListingMap.php

ListingPricing ListingPricing.php​

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

API
plans()demoPlans()has()lowestPrice()normalise()render()
inc/_jb/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/_jb/ListingSections.php

ListingTiles ListingTiles.php​

The tile row at the top of the Jobs listing form: Category. Category used to be a stacked chip-and-button field under the title — a row of chips, a "Choose category" button and a line of hint text, three stacked things for one decision. It now reads as the same square tile the Directory theme uses (Ppt\Directory\ListingTiles) and the Coupon theme before it: icon, label, and either the chips that are set or the word "Select". The tile opens the SHARED category picker modal (assets/js/category-picker.js) — searchable, with a Clear All / Continue footer — so there is still exactly one picker implementation across every fork. Jobs has no Business hours tile: the Directory row's second tile pulls that theme's hours card into a modal, and a job advert has no opening hours. The grid is auto-FILL, so a single tile stays tile-sized and left-aligned instead of stretching across the card.

API
categoryTile()assets()
inc/_jb/ListingTiles.php

ListingViews ListingViews.php​

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

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

LungeCard LungeCard.php​

Lunge's job row — a three-column card on white with a 4px left rule that turns aqua on hover: a square ocean employer mark, a heavy grotesque job title over its employer line and chips, and a right-hand stack carrying the pay in an aqua tint, the age of the advert and an outlined ocean Apply button that fills on hover. An aqua "New" tab rides the card's top-right edge when the row carries a flag. Lifted from lunge_jobs. Every rule is scoped to .ppt-jbcard--lunge, the card's own root, never to the listings block's section — see {@see JobCard::css()}.

API
render()css()
_jb/Cards/LungeCard.php

MarkSold MarkSold.php​

Classifieds — "Mark as sold" + pick-buyer. Classifieds has no cart/checkout, so unlike the auction fork there is no built-in record of a completed sale. This adds a lightweight one: the listing's owner marks an ad sold and picks the buyer from the members who have messaged them about it. That writes the sale meta (ppt_sold / ppt_sold_buyer / ppt_sold_at) that Account\Feedback::transactionFor() reads to open two-way feedback — so the whole feature only matters where feedback is switched on (control() renders nothing otherwise). Registered only on the classifieds fork (see Theme::$services).

API
register()isSold()buyerId()markSold()markUnsold()buyerOptions()ajax()control()
inc/_jb/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/_jb/Media.php

MettleCard MettleCard.php​

Mettle's job row — a three-column card on the scheme's dark surface with a 3px bottom rule that turns yellow on hover: a square gym mark underlined in yellow like a weight plate's label, a condensed uppercase job title over its gym line and chips, and a right-hand stack carrying the pay in condensed yellow, the age of the advert and an outlined uppercase Apply button that fills yellow on hover. A yellow "New" tab rides the card's top-right edge when the row carries a flag. Lifted from mettle_jobs. Every rule is scoped to .ppt-jbcard--mettle, the card's own root, never to the listings block's section — see {@see JobCard::css()}. Every colour is a token, because this design is a dark scheme and a literal neutral here would be the wrong end of the ramp on any re-skin.

API
render()css()
_jb/Cards/MettleCard.php

PayLine PayLine.php​

Splits a salary string into the FIGURE and the PERIOD it is quoted over. The trades board is the first Job Board niche whose rows quote pay four different ways on one page — "£210 a day", "£24 an hour", "£40,000 – £44,000 a year" and price work like "£450 per 1,000" — so Trowel, Joist and Gantry all set the money large and the period small underneath it. That is the design, and it is also the only way those four shapes read as one column. WHY THIS DERIVES THE PERIOD RATHER THAN TAKING A SECOND FIELD. {@see CardData} has one fixed vocabulary, and pay is one string in it, because that is all {@see CardData::fromPost()} can ever produce from a published listing. An item{N}_payunit field would give the curated home rows a second line that a real customer's jobs could never fill — the home page would show it and the search page beside it would not, which is precisely the split these cards exist to avoid. So the string stays whole and is read here. A live salary arrives from {@see \Ppt\Content\Currencies::price()} as "£210.00" with no trailing words, which yields the figure and an empty period — the card simply draws no second line, and nothing is missing. Anything that does not open with a number comes back untouched as the figure ("Competitive", "Negotiable"), which is the honest reading: it is all headline.

API
split()
_jb/Cards/PayLine.php

PlankCard PlankCard.php​

Plank's role row — a three-column card on white: a studio mark cut with the design's scooped corner (a leaf shape, olive on the first tone), a serif role title over its studio line and chips, and a right-hand stack carrying the rate in a rust tint, the age of the advert and an outlined olive Apply pill that fills on hover. A rust "New" tab rides the card's top-right edge when the row carries a flag. Lifted from plank_jobs. Every rule is scoped to .ppt-jbcard--plank, the card's own root, never to the listings block's section — see {@see JobCard::css()}.

API
render()css()
_jb/Cards/PlankCard.php

PunchBox PunchBox.php​

Punch's own search box over its results (Classic Jobs, Job Board fork). The front page opens with the design's grey search band (punch_search) directly under the charcoal masthead: a keyword field, a town-or-postcode field with a live "use my location" pin, and a solid Search button. Search and category pages carry no hero, so this is that band drawn again at the top of the results it filters — same two fields, same button, same full-bleed grey strip with its hairline underneath, standing in for the page's heading strip (which goes visually-hidden; the shared search template's $pptJbBox). The hero's tip line becomes the result count. Both fields stay LIVE on this page: the keyword box carries the current query, and the location box carries the current near, so the visitor can refine either without the other resetting. Every other active filter (radius, category, salary, sort, the showroom's design) rides along as a hidden input — drop that and a search from a filtered page throws the filters away. near is deliberately NOT among the hidden inputs: the visible field owns it, or the page would submit two. Inside a category archive the form posts to that archive (SearchPage::baseUrl() is the term link there), so the category is kept by the URL path and needs no hidden field. The pin is wired by Frontend\HeroGeo exactly as the hero's is: any .ppt-geo-btn in a form that holds one visible near input gets the geolocation behaviour.

API
render()css()
_jb/Cards/PunchBox.php

PunchCard PunchCard.php​

Punch's job row — the dense, specialist-board line: role, employer / location / salary meta, a one-line snippet that clips, the posted age and a save heart, with an initials logo box in the corner. Lifted verbatim from punch_results, which used to draw it inline, so the home page is unchanged to the pixel. The point of the move is the other half: the same row now draws Punch's search results and category archives, which used to fall back to the Directory's four-by-three photo card. ══ THE CSS TRAP THIS FILE EXISTS AROUND ═══════════════════════════════════════════ Every rule here is scoped to .ppt-jbcard--punch, the card's OWN root — never to .ppt-jb-pnres, the listings block's section. A section-scoped rule looks perfect on the home page and silently stops applying on every other page the card reaches. The block appends {@see css()} rather than owning these rules, so there is one copy.

API
render()css()
_jb/Cards/PunchCard.php

RailShell RailShell.php​

RailShell — the chrome and base stylesheet every Job Board sidebar panel shares. The Classic Jobs designs (Lanyard / Punch / Rung, 2026-09-11) were approved with a filter rail beside their job list, and each design's listings block drew that rail as hand-built markup of its own. This is the Job Board's half of what the Coupon fork does with {@see \Ppt_cp\WidgetShell}: the rail is now a set of sidebar BLOCKS ({@see \Ppt\ppt_blocks_jb\sidebar}) assembled by {@see JobRail}, and this class is the one place their box chrome lives. ══ WHY EVERY ELEMENT CARRIES TWO CLASS NAMES ══════════════════════════════════════ A panel renders as, for example:

Details
<summary>…</span><svg …></summary> The ppt-jbw* half is the SHELL: token-driven rules in {@see css()} that give a panel a working box on any design, including one written tomorrow that ships no rail CSS at all. The second half is the DESIGN's own prefix, handed in as pfx, and it is what makes this refactor invisible: the three approved designs' rail rules (.ppt-jb-pnres .pnres-body{…}) are two-class selectors, so they keep matching the same elements and keep winning on specificity over the shell's single-class base. Nothing had to be re-derived, and nothing moved pixel. A design may therefore skin the rail in either direction — override .ppt-jbw* the way the coupon designs override .ppt-cpw*, or keep a prefix of its own — and both roads lead through these blocks rather than through copied markup. pfx is sanitised to [a-z0-9-] before it reaches a class attribute: it arrives from block data, which an owner can edit.

API
pfx()cls()root()isOpen()title()caret()lines()resetCss()css()
inc/_jb/RailShell.php

ResumeAccess ResumeAccess.php​

Who may search the candidate database, and who may open one candidate's resume. TWO GATES, NOT ONE — and the difference is the whole privacy design. SEARCH an employer with resume-database access can find and preview PROFILES: headline, skills, experience, location. Structured facts about what somebody can do. UNLOCK opening the resume FILE — the PDF with their name, address and phone number in it — is a separate, deliberate act, recorded against that one employer and that one candidate. Keeping them apart is what lets a board sell the database at all without handing every subscriber a bulk export of its members' personal data. It also happens to be the better commercial shape: browsing is the subscription, unlocking is the moment of value. And it is the reason _jb\Resumes::canView() was built to grant for a PAIR of ids rather than for a role — this class is the thing that was designed for. Applying is NOT an unlock. An application carries its own frozen snapshot (_jb\Applications), so an employer reading an applicant's CV never touches the live resume and never consumes database access. FREE WHEN NOBODY SELLS IT. If no membership plan on this install offers the entitlement, the gate is open — mirroring _da\Entitlements::gated(). A board that has not set up plans yet should still work, rather than locking a feature nobody can buy.

API
register()entitlement()isGated()canSearch()upgradeUrl()unlocked()hasUnlocked()unlock()canView()handleUnlock()purgeForUser()
inc/_jb/ResumeAccess.php

Resumes Resumes.php​

A candidate's resume: the file, who may read it, and how it is served. This is the first thing on the jobs board that is genuinely PRIVATE. A job is meant to be found; a resume is a named person's home address, phone number and work history, handed over for one purpose. So it is deliberately NOT a WordPress attachment: - an attachment shows up in the media grid, where any other author with upload rights can page through it; - an attachment has a public permalink and a REST route; - an attachment's URL is guessable from the uploaded filename and the month. Account\LockedMedia exists to paper over exactly that and says so in its own header — it gates the attachment page and the REST response, but "the file itself still sits at a public uploads URL". For a resume that is not good enough. Instead the bytes go to Media\PrivateStore (a denied folder, an unguessable 32-hex name, never an attachment) and this class keeps a small record pointing at them. The record is a post so it can be queried, owned by an author, and — once the structured profile lands — carry skills as terms. Nothing about it is public: the type is registered with no UI, no archive, no REST and no permalink. WHO MAY READ ONE. Only ever three answers, resolved in self::canView(): the candidate their own file, always an administrator the site owner is answerable for what is stored here an employer ONLY through the ppt_jb_resume_can_view filter — which is where Applications will grant a specific employer a specific resume because that candidate applied to them. Nothing grants "employers" as a class, and there is deliberately no path here that could. SEVERAL RESUMES PER CANDIDATE, up to self::MAX_PER_USER, each with a label the member sets ("General", "Design roles"). The apply modal is a chooser over them. Uploading no longer replaces anything, so the retention answer had to change with it: a cap, enforced at upload with a refusal rather than a silent eviction. Deleting the oldest file on somebody's behalf to make room is not a decision this code gets to make — it is the one copy of a document they may not have anywhere else. self::forUser() still answers "the most recent one", which is what a default selection and an employer-facing view both want. Callers that mean ALL of them say listForUser().

API
register()registerType()mimes()maxBytes()acceptAttr()forUser()has()info()store()cleanLabel()labelFromFilename()label()rename()owns()listForUser()remaining()allForUser()delete()canView()downloadUrl()handleDownload()handleUpload()handleAjax()handleRename()
inc/_jb/Resumes.php

ResumeSearch ResumeSearch.php​

Searching the candidate database. Queries ppt_candidate profiles — never the resume FILES, which are not posts and are not searchable by design. What an employer finds here is the structured half a candidate chose to publish: headline, skills, experience, where they are, what they are looking for. Opening the document is a separate act, gated by _jb\ResumeAccess. ONLY PROFILES THAT OPTED IN. Every query carries _ppt_cand_searchable = 1, and it is added here rather than left to callers: a search that forgot it would quietly expose every candidate on the board, including the ones who only ever uploaded a CV to apply for one job. There is no argument that switches it off. SKILLS ARE A TAX_QUERY, NOT A LIKE. ppt_skill exists so this stays fast at ten thousand candidates, and so the facet list is short and honest — see CandidateProfile::setSkills(), which normalises on slug before creating a term. THE KEYWORD IS DELIBERATELY NARROW. It matches the headline and the summary — the two fields a candidate wrote ABOUT themselves — and not the whole profile. Matching every meta field would turn a search for "Toronto" into a search for anyone who once worked at a company with Toronto in its name, and the location filter already exists.

API
register()allowWorkauthFilter()args()run()keywordWhere()result()renderPanel()css()skillFacets()
inc/_jb/ResumeSearch.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/_jb/Reviews.php

Roles Roles.php​

The split portal: who this member is, and what the jobs board is therefore FOR. A jobs board has two sides and they want opposite things. One puts a role on the site and waits for applications; the other is looking for a role and sends them. _jb has asked which is which since the role sign-up step was written — the jobs override in Account\SignupSteps::overrides() reads "Hiring, or job hunting?" — and then dropped the answer on the floor: it was stored in ppt_signup_role and read by nothing at all. Every member got the same dashboard, so somebody who said she was job hunting was handed a "post a job" button and an employer's desk. The machinery to act on that answer already exists. Account\AccountType was built for the escort fork, where the same people show up under different names: SOLO advertises their own, AGENCY advertises other people's, CLIENT is only here to look. It owns the sign-up flow each one gets, the wording, whether finishing sign-up seeds a listing, and what the Member Hub is for. None of that is escort-specific — only the words were. Real Estate bridged onto it the same way; see inc/_rt/Roles.php, which this follows. So this class is a bridge and a dictionary, and deliberately nothing else: BRIDGE map the answer we already have (ppt_signup_role: buy/sell) onto the type AccountType already understands (solo/client), so no second question is asked, no migration runs, and a member who signed up last year is already answered. DICTIONARY supply the words. The two sides are EMPLOYER and CANDIDATE on a classic board, CLIENT and FREELANCER on a contract one, PRACTICE and LOCUM on a healthcare one. A design says so by implementing Designs\Contracts\HasRoles; anything that does not gets the neutral Employer / Candidate pair from self::base(). WHY TWO DOORS AND NOT THREE. Escort has an AGENCY type because an agency advertises people who are not itself. A recruitment agency is the same shape and would map onto AccountType::AGENCY cleanly — but the role step only offers two answers on this profile, and inventing a third door nobody asked for would change a question that is already live on installed sites. The seam is left open: add the answer to the step, map it in self::toType(), and give it words in self::base(). WHY buy MEANS HIRING. The shared role step is written in marketplace words — the side that consumes is buy, the side that supplies is sell. On a jobs board the employer is buying labour and the candidate is selling it, which is why the jobs override captions buy as "I am hiring". It reads backwards at a glance and is correct; self::SEEK and self::HIRE exist so no other file has to think about it. Gated to the jobs profile by Theme::boot()'s fork-gating (this is Ppt_jb*), so none of it exists on any other product.

API
register()profiles()typeForUser()typeCurrent()toType()isEmployer()isCandidate()hubUrl()side()base()vocab()designRoles()declared()word()accountBadge()options()shortLabel()rosterLabels()flow()overrides()headerSpec()signupStep()
inc/_jb/Roles.php

RotorCard RotorCard.php​

Rotor's job row — a three-column card: a rounded-square employer mark, the role with its sector chips, and a right-hand stack carrying the pay, the age of the advert and an outlined Apply pill. A sea-glass "New" tab rides the top-right edge when the row carries a flag, and a sea-glass bar lights the card's left edge on hover. Lifted from rotor_roles. Every rule is scoped to .ppt-jbcard--rotor, the card's own root, never to the listings block's section — see {@see JobCard::css()}.

API
render()css()
_jb/Cards/RotorCard.php

RungBox RungBox.php​

Rung's own search box over its results (Classic Jobs, Job Board fork). The front page opens with the design's gradient search band (rung_search): a keyword field, a town-or-postcode field with a live "use my location" pin, and a pill Search button. Search and category pages carry no hero, so without this a visitor who lands on results had only the filter rail to search from. This is that band put back at the top of the results it filters, in Rung's hand — same two fields, same pill, same full-bleed peach-to-mint wash, standing in for the page's heading strip (which goes visually-hidden; the shared search template's $pptJbBox). The hero's editorial hint line becomes the result count. Both fields stay LIVE on this page: the keyword box carries the current query, and the location box carries the current near, so the visitor can refine either without the other resetting. Every other active filter (radius, category, salary, sort, the showroom's design) rides along as a hidden input — drop that and a search from a filtered page throws the filters away. near is deliberately NOT among the hidden inputs: the visible field owns it, or the page would submit two. Inside a category archive the form posts to that archive (SearchPage::baseUrl() is the term link there), so the category is kept by the URL path and needs no hidden field. The pin is wired by Frontend\HeroGeo exactly as the hero's is: any .ppt-geo-btn in a form that holds one visible near input gets the geolocation behaviour, and the button here is sized to its glyph so the bar matches the front page's pixel for pixel.

API
render()css()
_jb/Cards/RungBox.php

RungCard RungCard.php​

Rung's job row — the roomier take on the classic board line: role, employer / location / salary meta, a one-line snippet, the posted age with its New and Premium labels, a save heart and an initials logo box. Lifted verbatim from rung_results. Every rule is scoped to .ppt-jbcard--rung, the card's own root, never to the listings block's section — see {@see JobCard::css()} for why that distinction is the whole reason this file exists.

API
render()css()
_jb/Cards/RungCard.php

Salary Salary.php​

What a job pays, and how to say it. _jb was cloned from Classifieds, where price is an asking price and there is nothing more to know about it. On a jobs board the same number is a SALARY, and a salary without a period is not a fact — £22 could be an hourly rate and £44,000 a yearly one, and a board that prints both the same way is lying to somebody. THE PERIOD LIVES HERE, ONCE. The candidate profile needed pay periods first (what somebody is looking for) and defined them locally with a note that this class must HOIST rather than copy them — CandidateProfile::periodOptions() now delegates here. Two lists would mean a board matching a yearly expectation against an hourly advert. NO TRAILING DECIMALS ON A WHOLE SALARY. Currencies::price() always renders two, which is right for £19.99 and wrong for "£44,000.00 per year" — nobody writes a salary that way, and the two dead digits make the most important number on the page harder to read. A salary with real pence (an hourly rate of £12.50) keeps them. ONE FORMATTER, EVERYWHERE. The job card, the search results, the Apply box and the Google JobPosting schema all have to agree, so they all come through self::format() or self::parts(). Nothing outside this class should read the price meta on a job and decide for itself how to print it.

API
periods()periodNames()isPeriod()periodLabel()period()amount()has()figure()baseFigure()parts()format()schemaBaseSalary()savePeriod()
inc/_jb/Salary.php

SavedCandidates SavedCandidates.php​

An employer's private shortlist of candidates. WHY NOT Account\Follows. Following is PUBLIC and mutual-ish: it drives a follower count on somebody's profile and a "Following" list they can reason about. An employer putting a candidate on a shortlist is doing the opposite — it is a private note about somebody who must never learn they were considered and passed over. Using the follow graph would publish a hiring decision as a number on a stranger's profile. WHY NOT Account\Collections either: those hold LISTINGS. A candidate is a person. So this is its own small list, the same shape as ResumeAccess's unlock list: an array of user ids in the employer's own meta, private to them, and swept when either party deletes their account. A SAVE IS NOT AN UNLOCK. Saving costs nothing and reveals nothing — the shortlist shows exactly what a search result shows, so a candidate stays anonymous on it until the employer unlocks them. Otherwise "save" becomes a free way to bank identities.

API
register()all()has()count()toggle()results()handleToggle()purgeForUser()
inc/_jb/SavedCandidates.php

SconceCard SconceCard.php​

Sconce's job row — a three-column card on the scheme's dark surface: a round amber employer mark, a serif job title over its venue line and chips, and a right-hand stack carrying the rate, the age of the advert and an outlined Apply pill that fills amber on hover. An amber "New" tab rides the card's top-left edge when the row carries a flag. Lifted from sconce_jobs. Every rule is scoped to .ppt-jbcard--sconce, the card's own root, never to the listings block's section — see {@see JobCard::css()}. Every colour is a token, because this design is the one dark scheme on the product line and a literal neutral here would be the wrong end of the ramp on any re-skin.

API
render()css()
_jb/Cards/SconceCard.php

SearchPage SearchPage.php​

Server-rendered search / archive for the listing post type — the clean PPT rebuild of DT10's PPT\Custom\Search\SearchPage. Runs the normal WordPress main query (WP_Query) via official hooks (pre_get_posts + template_include) so it is SEO-friendly and every core filter applies. Contexts it owns: - keyword search (/?s=…) - the listing post-type archive (/listing/) - listing category / tag archives Filters (GET, all optional, combinable): s keyword tax-listing_category category term id price1 / price2 min / max price (numeric, price meta) — on this fork the rail only writes price1, as a MINIMUM SALARY; price2 is still honoured so an old saved search keeps working jbpaid the pay period that salary is quoted in (Salary::periods()) sort featured | newest | oldest | rating | price_low | price_high | title paged pagination

API
register()shapeQuery()nearCoords()radiusKm()distanceFrom()distanceLabel()radiusClausesMain()radiusClauses()featuredOrderClauses()isListingContext()postType()isMapTakeover()categoryTax()terms()taxQueryFromRequest()listingTaxonomies()hasActiveFilters()matchingIds()termCounts()priceMetaQuery()salaryPeriod()salaryPeriodMetaQuery()ratingMetaQuery()activeMetaQuery()
inc/_jb/SearchPage.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/_jb/SingleListing.php

StippleCard StippleCard.php​

Stipple's brief row — a three-column card with square corners: a square studio mark, the brief with its discipline chip, and a right-hand stack carrying the rate in ultramarine, the age of the brief and an ink-outlined Apply button. An ultramarine "New" tab rides the card's top-right edge when the row carries a flag, and the whole card takes an ink border on hover rather than a lift. Lifted from stipple_briefs. Every rule is scoped to .ppt-jbcard--stipple, the card's own root, never to the listings block's section — see {@see JobCard::css()}.

API
render()css()
_jb/Cards/StippleCard.php

SwitchboardCard SwitchboardCard.php​

Switchboard's job row — a three-column card on the scheme's dark surface: a square mint employer mark, a heavy job title over its company line and chips, and a right-hand stack carrying the pay in a mint-tinted pill, the age of the advert and an outlined Apply button that fills mint on hover. A mint "New" tab rides the card's top-right edge when the row carries a flag. Lifted from switchboard_jobs. Every rule is scoped to .ppt-jbcard--switchboard, the card's own root, never to the listings block's section — see {@see JobCard::css()}. Every colour is a token, because this is a dark scheme and a literal neutral here would be the wrong end of the ramp on any re-skin.

API
render()css()
_jb/Cards/SwitchboardCard.php

TensorCard TensorCard.php​

Tensor's job row — a calm serif card: a round employer mark, the role set in the design's display face, an icon meta line (employer · place · type), and a right-hand stack with the salary in bottle green, the advert's age and a Featured label. A save bookmark sits in the corner on the home page; on the search page the theme's own Save button takes that corner instead, so this one steps out of the way. Lifted from tensor_roles. Every rule is scoped to .ppt-jbcard--tensor, never to the listings block's section — see {@see JobCard::css()}.

API
render()railCss()css()
_jb/Cards/TensorCard.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/_jb/TermMeta.php

TetherCard TetherCard.php​

Tether's job row — a three-column card on a white surface: a square cobalt employer mark, a wide-set job title over its company line and chips, and a right-hand stack carrying the pay in a tangerine-tinted pill, the age of the advert and an outlined cobalt Apply button that fills on hover. A tangerine "New" tab rides the card's top-right edge when the row carries a flag. Lifted from tether_jobs. Every rule is scoped to .ppt-jbcard--tether, the card's own root, never to the listings block's section — see {@see JobCard::css()}.

API
render()css()
_jb/Cards/TetherCard.php

TrowelCard TrowelCard.php​

Trowel's job row — a three-column card on paper: the firm's square mark, the trade and its chips, and a right-hand stack carrying the day rate set large over the period it is quoted for, the age of the advert and a square Apply button. A "New" tab rides the card's top edge when the row carries a flag, and the left rule lights up on hover. Lifted from trowel_jobs. Every rule is scoped to .ppt-jbcard--trowel, the card's own root, never to the listings block's section — see {@see JobCard::css()}. The pay is ONE string split by {@see PayLine}, not two

fields: see that class for why a second field would be a home-page-only flourish.

API
render()css()
_jb/Cards/TrowelCard.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/_jb/Uploads.php

WaybillCard WaybillCard.php​

Waybill's job row — a three-column card: the employer's mark on a 16px rounded tile, the role with its vehicle chip, and a right-hand stack carrying the rate on an espresso pill, the age of the advert and a tangerine Apply pill. A "New" tab rides the card's top-right edge when the row carries a flag. Lifted from waybill_jobs. Every rule is scoped to .ppt-jbcard--waybill, the card's own root, never to the listings block's section — see {@see JobCard::css()}. ONE CHIP, NOT THE MOCKUP'S THREE. The approved row showed vehicle, contract and shift side by side; a live listing carries exactly one of those (its category), so the other two would be a home-page-only flourish that vanishes the moment a real job is drawn. The one chip that survives is styled as the vehicle, because that is what a driver scans a board for.

API
render()css()
_jb/Cards/WaybillCard.php

WidgetRail WidgetRail.php​

WidgetRail — draws the owner's sidebar panels on a Job Board page. The Coupon Theme's rail with the coupon-specific half taken out: a job board has no shop archive, so there is no store context and no per-store targeting, which leaves the whole of this class as "work out the page type, render that page's list". The HOME context is deliberately NOT rendered here. A design owns its home rail, and {@see JobRail} is what decides whether the owner's list replaces it — see the note there. Rendering it from both places would draw it twice.

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

Widgets Widgets.php​

Widgets — the owner's sidebar panel list for the Job Board Theme (_jb). The same screen, list and storage shape the Coupon Theme has; the mechanics live in {@see \Ppt\Content\WidgetList} and this declares only what the Job Board line owns. ══ WHY THE LIST STARTS EMPTY ══════════════════════════════════════════════════════ {@see defaults()} returns nothing, so a board that has never opened this screen renders exactly what it rendered before it existed. That is deliberate, and it is not the coupon line's answer: coupon had four hard-coded boxes to reproduce, so its defaults are what those boxes were. A jobs board's rails were already accounted for — • the HOME rail is part of the design ({@see JobRail}); a default list here would quietly overrule the design an owner chose, and • the SEARCH page already carries the real filter form. …so anything shipped as a default would be adding furniture nobody asked for. Placing the first widget is one click in the picker at the foot of the screen. ══ WHY FOUR OF THE SIX PANELS ARE HOME-ONLY ═══════════════════════════════════════ Not here — on the blocks themselves, through {@see \Ppt\Blocks\Contracts\SidebarContexts}. The commute card, the facet groups, the salary bands and "Clear all filters" are a PICTURE of the search filters: inert by design, because the home page has nothing to filter. Beside the search page's real, working filters they would read as broken controls, so those four declare home and the Add control stops offering them anywhere else. Locations and the job-alert promo are real links and work on every page.

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