Skip to main content

Directory Theme class reference

21 min read

Hotel Booking Theme — Class reference

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

← Back to Hotel Booking Theme features

AdminReservations AdminReservations.php​

AdminReservations — the hotel's Reservations screen (_ht). A themed admin screen (rendered in the theme Chrome, like Admin\Bids) with two tabs: Reservations every stay across every room — guest, room, dates, total, what is paid and owed, status — with the host's actions on each (confirm, decline, cancel, mark completed, record the balance paid at the desk), and a month occupancy calendar. Hotel settings what the hotel sets ONCE: deposit %, the optional extras guests can add, check-in / check-out times and directions (Ppt_ht\StayBridge). Since 2026-09-30 the rows are the shared nightly-stay engine's (ppt_stays, see inc/Stays/Stays.php). The engine's own list queries are per property; a hotel wants every room at once, so the cross-room queries live here, reading Stays::table(). Registered only under the Hotel profile; the nav entry is in Admin\Chrome::sections().

API
register()menu()adminList()statusCounts()day()roomsTotal()activeInWindow()handleAction()handleSettings()render()handleMove()move()handleEmail()emailGuest()handleBlock()block()roomStats()
inc/_ht/AdminReservations.php

BookingWidget BookingWidget.php​

BookingWidget — the room-page reservation widget for the Hotel fork (_ht). Renders in the room single's sidebar: check-in / check-out, guests and the hotel's optional extras (breakfast, late checkout…). "Check availability" asks for a live, itemised quote; "Reserve" books the room and sends the guest to their own stay page, where the shared pay panel takes the deposit or the whole stay. Since 2026-09-30 everything underneath is the SHARED nightly-stay engine (inc/Stays/): availability and the double-booking guard (Stays::check / create, with the room's inventory from StayBridge), prices (Stays\Rates — seasons that repeat every year, weekend supplement, weekly discount, extras, deposit), payment (Stays\Checkout, LET- orders) and the emails (EmailTemplates stay_*). This class only draws the widget and answers its two AJAX calls. NO ACCOUNT NEEDED. Both calls are open to logged-out visitors: a guest gives a name and an email, and the stay's private token is their way back in. Requiring sign-in here was the single biggest reason a hotel booking was abandoned. ppt_ht_quote price + availability for a date range — no commitment ppt_ht_reserve create the stay, email guest + hotel, return the stay page URL ppt_ht_calendar one month of free / full nights for the tap-to-pick grid THE CALENDAR (2026-09-30). A month grid replaces the two date boxes once the script runs; the boxes stay in the markup (hidden) and still carry the chosen dates, so the widget works without it. The grid only ever learns "free" or "full" for a night — Availability::publicCalendar() collapses every reason (booked, blocked for maintenance, the owner's own use) to "full", and a room with several units is full only when every unit is taken that night.

API
register()assets()isVirtual()showroom()render()reasons()quoteData()calendarData()ajaxCalendar()ajaxQuote()ajaxReserve()
inc/_ht/BookingWidget.php

DateChange DateChange.php​

Hotel fork (_ht) — a guest asks to change the dates of their booking. The guest's own booking page (StayPage, reached by its private link — no account) shows "Need different dates?" while the stay is pending or confirmed and still in the future. The guest picks new dates; the request is re-priced (same guests and extras) and checked against availability, then stored here — one open request per stay — and emailed to the hotel (template ht_date_request). NOTHING moves on its own and no money changes hands: the hotel opens the stay in Reservations ▸ Change dates with the requested dates filled in and approves there (which re-checks, re-prices, keeps what was paid and can email the guest), or declines (template ht_date_request_declined). The guest can withdraw a request while it is open. Requests live in one option, stay id => request, because the stays table has no column for them and there are only ever a handful open at once.

API
register()all()get()openStays()clear()canRequest()request()datesText()tokens()approveUrl()handleGuest()card()handleAdmin()adminBadge()css()
inc/_ht/DateChange.php

DemoAccounts DemoAccounts.php​

Hotel — what the one-click DEMO guest walks into. The Hotel Theme's home-demo offers one "members area" login: the plain test member (Account\DemoLogin, no account types). A hotel's own side lives in wp-admin (Reservations), so the member hub is the GUEST's, and that is what it is furnished as (owner's call, 2026-10-02): - a stay in about two and a half weeks, deposit paid, balance due a week before arrival (the hub's "to pay" to-do and the Pay and view button); - a stay in about seven weeks, paid in full; - a stay about six weeks ago, completed and reviewed (a "Verified stay" review); - a receipt under Invoices for each payment, and two rooms saved to Favorites. The rooms are a throwaway hotel account's (self::HOTEL), built for the demo on first use - a demo site needs no listings (owner's ruling, 2026-10-02), so nothing here comes from a seeded catalogue. Stays go through the engine's own Stays::create() (availability re-check and all), priced by Stays\Rates::quote() as the booking widget prices them; payments are raised as the guest's real stay orders (Stays\Checkout::orderFor) and marked paid. Everything is recorded on the member (self::META) and removed by unfurnish() when DemoLogin purges the account at go-live; the rooms go with the hotel account (DemoLogin::purge drops its demo rows).

API
register()metaKey()furnish()unfurnish()
inc/_ht/DemoAccounts.php

DemoRooms DemoRooms.php​

DemoRooms — gives the Hotel fork's seeded demo rooms what makes them bookable. The sample rows (Content\SampleListings::forNiche('hotel') and ('guesthouse')) carry a room's type, "Sleeps N" and nightly price as text, and SampleData seeds them as plain listings. A room needs those as its own meta (Rooms::RATE_META / SLEEPS_META / INVENTORY_META and the room-type term) or the room page draws no booking widget, the search page cannot filter by guests and the home page's live room cards show no price. Hooked on the demo FLAG being written, exactly like _cb\Cashback::onDemoFlag(): both SampleData seeding paths insert the listing, then write DEMO_META = '1', so this runs once per seeded room on every path that seeds (Sample Data screen, the installer, a local design install). An owner's own value always wins — a field already set is left alone.

API
register()onDemoFlag()sample()isOccupancy()virtualMeta()applySample()
inc/_ht/DemoRooms.php

Enquiries Enquiries.php​

Hotel fork (_ht) — group and event enquiries. A room page's booking widget carries a "Group or event? Send an enquiry" link that opens a short form: name, email, phone, dates (optional), group size, occasion and a message. A submission is stored as a private ppt_ht_enquiry post, emailed to the hotel (template ht_enquiry) with a short acknowledgement to the guest (ht_enquiry_ack), and listed in Reservations ▸ Enquiries, where the hotel marks it replied or closed. Switched by Hotel settings ▸ "Group and event enquiries" (on by default). Logged-out posts are allowed (a guest has no account); they are nonce-checked, honeypotted and limited to five an hour per address. Hooked from StayBridge::register() (already a registered _ht service).

API
register()registerType()enabled()recipient()ajaxSubmit()create()data()status()dates()tokens()ids()countNew()handleAction()view()css()
inc/_ht/Enquiries.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/_ht/Expiry.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/_ht/Gallery.php

Geocoder Geocoder.php​

Bulk geocoder — fills in lat/lng for existing listings that have an address but no coordinates (e.g. listings created before the editor's map picker existed), so they get a precise map marker and the "Distance from me" feature. Uses the site's Maps provider (Settings ▸ API keys): Google / Mapbox geocoding APIs (their key), or OpenStreetMap Nominatim (keyless, rate-limited to ~1 req/sec with an identifying UA). A small box on the PPT Listings screen runs it in batches over AJAX. Listings that can't be geocoded are flagged (_ppt_geo_failed) so they aren't retried forever.

API
register()geocode()pendingCount()ajaxBatch()assets()box()
inc/_ht/Geocoder.php

ListingActions ListingActions.php​

Per-listing visitor actions shown on the single-listing section nav: - Add to favorites — toggles the listing in the member's saved list (user meta ppt_favorites); the account page shows them. Logged-in only; guests are routed to sign-in. - Report — flags the listing to the site admin. The reason is stored as a comment on the listing (type ppt_report, a custom approval status so it stays hidden from the front-end and the normal comment-moderation tabs) and also emailed to the admin. Reports are reviewed in the admin Comments screen's dedicated "Reports" tab. Open to guests and members.

API
register()favIds()favHas()favToggle()favListingIds()ajaxFav()ajaxReport()assets()favButton()reportButton()
inc/_ht/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/_ht/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/_ht/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/_ht/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/_ht/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/_ht/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/_ht/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_ht\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/_ht/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, Reviews. Turning one OFF removes it from BOTH the single listing page and the listing submission/edit form; the order of the list controls the order the (main-column) sections appear in. Config lives in the shared design option (Branding::OPTION = ppt_design) under the listing_sections key: { order:[keys…], enabled:{key:0|1} }. Anything not saved yet defaults to ON, in the catalog order — so existing sites are unchanged until the admin edits the list.

API
supported()catalog()config()order()enabled()orderedAmong()sanitize()
inc/_ht/ListingSections.php

ListingViews ListingViews.php​

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

API
register()table()install()maybeRecord()total()countSince()series()recentViewers()ajaxData()
inc/_ht/ListingViews.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/_ht/Media.php

ReservationsCron ReservationsCron.php​

ReservationsCron — daily maintenance for hotel stays (_ht). Since 2026-09-30 the stays are the shared engine's (ppt_stays, inc/Stays/). The engine itself runs no schedule — PrivateLet has none yet — so the hotel keeps its own, gated to the hotel fork so it never touches a PrivateLet site's rows: 1. Expire abandoned holds — an ONLINE booking still PENDING and unpaid after the hold window is cancelled, freeing its nights. Only when the site can actually take payment online: with no gateway switched on, a pending stay is a request waiting for the hotel's answer, not an abandoned checkout. 2. Complete — a CONFIRMED stay whose check-out has passed becomes 'completed'. 3. Pre-arrival — one email to guests arriving within the hotel's pre-arrival window (Hotel settings, a week by default), from tomorrow on, recorded in the stay's reminder list so it never repeats. 4. Arrival day — check-in time and directions on the day (template stay_arrival). 5. Review request — 1–3 days after check-out, unless the guest already reviewed from their booking page (template stay_review_request, links to StayPage's form). All guest emails are EmailTemplates the owner can edit or switch off (StayBridge adds them to the "stays" group). The schedule is daily, so "arrival day" means whenever the day's run happens, not a set hour. The date maths lives in pure helpers (cutoff, reminderRange) so the schedule is testable without running WP-Cron.

API
register()schedule()unschedule()holdHours()remindDays()cutoff()reminderRange()staleUnpaidPending()runCron()reviewRequestsDue()sendTemplate()sendPrearrival()
inc/_ht/ReservationsCron.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()replies()renderForm()
inc/_ht/Reviews.php

RoomRates RoomRates.php​

RoomRates — the room editor's "Rates & seasons" card, stored in the shared stay engine's own format (Ppt\Stays\Rates) on the room listing. Replaces the fork's first rates class (inc/_ht/Rates.php, retired 2026-09-30), which kept seasons on fixed dates only, so "Peak · Jul–Aug" had to be typed in again every year. What a room now carries: seasons label, from/to, REPEATS EVERY YEAR (stored MM-DD, may wrap the year end), nightly rate, minimum nights, arrival day min_stay/max_stay the room's own limits when no season sets them arrival_days, changeover days ("arrive and leave on a Saturday"); a season's departure_days own arrival day takes over during that season weekend_uplift "+£25 on Friday and Saturday nights", as its own invoice line weekly_discount a % off the nights from weekly_threshold nights (default 7) weekly_rate or a set price per full week, used where it beats the nightly rates The nightly rate itself stays on the room card (Rooms::RATE_META) and the deposit and extras are hotel-wide (StayBridge): Ppt_ht\StayBridge::pricingFor() lays both over what is saved here whenever the engine reads the room's pricing. Static, not a Service — the listing editor calls it.

API
weekdayOptions()saveFromForm()hasSeasons()summaryNote()changeoverText()editorCard()
inc/_ht/RoomRates.php

Rooms Rooms.php​

Rooms — the room-type model for the Hotel Booking fork (_ht). In the hotel model a listing is a ROOM (room-type): the site is one property, each listing is a bookable room with a nightly rate, an occupancy ("Sleeps N"), an inventory count (how many of that room exist — "2 left"; for hostels, beds), a set of amenities, and a room-type term (Double, Suite, Family, …). Wiring mirrors _cd\VehicleSpecs (the structural analog): the editor prints Rooms::editorCard($pid); the save is Rooms::saveFromForm($pid, $in); the listing card calls Rooms::renderStats(Rooms::cardData($pid)); the single page calls Rooms::renderDetails($pid). Data model: - room TYPE is a taxonomy (self::TAX_TYPE, 'listing_room_type'), seeded with the defaults below. Being a real listing taxonomy, the search query filters on it for free via SearchPage::taxQueryFromRequest() (Phase 5 search). - nightly RATE / SLEEPS / INVENTORY / AMENITIES are plain listing meta. Phase 1 scope: rooms exist, are priced, described and browsable. The live nightly availability calendar, seasonal rates and range booking are later phases — see dt10-backups/BUILD-PLAN-hotel-booking-ht.md.

API
register()isEnabled()defaultTypes()registerTaxonomy()seedTypes()typeSlug()typeLabel()amenities()amenitiesList()rate()sleeps()inventory()isPerBed()bedKinds()beds()bedsText()has()cardData()saveFromForm()statIcon()renderStats()renderDetails()editorCard()
inc/_ht/Rooms.php

SearchPage SearchPage.php​

Server-rendered search / archive for the listing post type — the clean PPT rebuild of DT10's PPT\Custom\Search\SearchPage. Runs the normal WordPress main query (WP_Query) via official hooks (pre_get_posts + template_include) so it is SEO-friendly and every core filter applies. Contexts it owns: - keyword search (/?s=…) - the listing post-type archive (/listing/) - listing category / tag archives Filters (GET, all optional, combinable): s keyword tax-listing_category category term id price1 / price2 min / max price (numeric, price meta) sort featured | popular | newest | oldest | rating | price_low | price_high | title paged pagination

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

StayBridge StayBridge.php​

StayBridge — how the Hotel fork (_ht) plugs into the shared nightly-stay engine (inc/Stays/). The engine thinks in "properties": one post, one calendar, one price list. On a hotel each ROOM TYPE is that property — a listing with an inventory ("3 Deluxe Doubles"), its own nightly rate and seasons. What a hotel sets ONCE for the whole building — the deposit it asks for, the extras it sells (breakfast, late checkout), check-in and check-out times, how to find it — lives here, in one option, and is laid over every room's pricing through ppt_stay_pricing. Every answer is a filter the engine already asks, so nothing in inc/Stays/ names this class. That matters because each product ships as its own ZIP: the hotel ZIP carries inc/Stays/ and inc/_ht/, never inc/_pl/. ppt_stay_inventory how many of this room type exist → Rooms::inventory() ppt_stay_pricing base rate + hotel-wide deposit/extras → pricingFor() ppt_stay_url the guest's own stay page → StayPage::url() ppt_stay_manage_url where the owner manages bookings → AdminReservations ppt_stay_mail_tokens hotel name, room, check-in time, directions

API
register()phone()leftOn()liveRow()emailCatalog()defaults()settings()normalize()save()inventory()rowUnits()newUnits()pricingFor()stayUrl()manageUrl()hotelName()mailTokens()extrasText()
inc/_ht/StayBridge.php

StayPage StayPage.php​

StayPage — a hotel guest's own page for one booking, opened from a private link. Lives at /checkout/?stay=<token> (routed in Frontend\Pages). The token is the one Ppt\Stays\Stays::create() mints; it is the guest's authority, so no account is needed to see the booking, pay what is owed or find the way to the hotel. Every stay email carries the link ({stay_url}), and the member hub's "My stays" opens it too. What it shows: the room and dates, the itemised price, what has been paid and what is left, the extras chosen, check-in / check-out times and directions — and, beside it, the shared pay panel (Ppt\Stays\Checkout::payPanel) while money is owed. It wears the shared stay stylesheet (inc/Stays/views/stay-style.php), the same one PrivateLet uses.

API
canReview()handleReview()submitReview()url()isRequest()money()lines()render()
inc/_ht/StayPage.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/_ht/TermMeta.php

Uploads Uploads.php​

Listing media uploads — the AJAX backend for the Uppy uploader on the listing editor (replacing the WordPress media frame). Uppy's XHRUpload posts one file per request to ppt_listing_upload; each becomes a normal WordPress attachment (so Media::gallery()/Media::videos() and the single-listing template keep working). Files are left unattached (post_parent = 0) until the listing form is saved, which parents the final set (ListingEditor::save()). A companion ppt_listing_media_remove deletes a freshly uploaded, not-yet- saved attachment when the user removes it in the editor, so abandoned uploads don't pile up in the media library.

API
register()imageMimes()videoMimes()ajaxUpload()ajaxRemove()ajaxPoster()ajaxPersist()
inc/_ht/Uploads.php