How to read this. Sections 1–3 are the overview; 4–9 are reference you dip into. Every numbered heading below folds — click one to open it, or use the buttons. Searching the page with Ctrl + F still finds text inside folded parts and opens them for you.
Section 1What has been built
The entire site run on a standard, unmodified WordPress + WooCommerce platform, with all custom behaviour in two version-controlled packages built specifically for the business.
The catalogue
- The full catalogue (138,000+ titles), synced from the office system - with authors, illustrators, series, formats, age ranges, awards and Australian Curriculum links.
- Instant search - a purpose-built full-text index answers searches across title, author, illustrator, ISBN, category and series in milliseconds, even at 138k products.
- Cover images - covers are simple files named by ISBN, uploaded by staff over FTP; books without a cover get a branded owl-logo placeholder card automatically, and cover artwork sits straight on the page with its shadow hugging the book (no white backing).
- Browsing - filter drawer (type, age, format, award, availability, teacher notes), context-aware category chips whose counts follow the active filters, sorting by newest, price, author, illustrator, availability and title, and a New Releases view by month.
- Book details - byline with author bios, details grid, category chips, teacher-notes callout, "more by this author" rail, and a sticky price panel with live quantity pricing.
Pricing & ordering rules
- One pricing engine - every price shown or charged comes from a single function: quantity tiers (10% off, 15% at 10+, 20% at 20+ on every book), pre-release pricing, standing-order pricing (flat 20% off), the September birthday month (flat 20% off), and the office's own special price, which wins for everyone when it is set. Rounding is always down to the cent; prices are GST-inclusive.
- Availability you control - a status registry maps back-office stock codes to wording and orderability that staff can edit; imports never overwrite your wording.
- Login-gated ordering - guests can order in-stock and new titles; everything else needs an account, enforced on every add-to-basket path and again at checkout.
- Delivery rules built in - free for signed-in customers on orders of $50 or more, otherwise $9.90; free on every order for standing-order customers; free store pickup at the retail checkout.
Promotions & PDF catalogues
- Promotions from the office spreadsheet - paste or upload the ISBN + item-number CSV and the site builds the full browsable catalogue page, numbered exactly like the PDF catalogue, with section jump-cards.
- One-click PDF catalogues - the site generates the PDF catalogue itself, in four designs (including three built from your cover artwork concepts), with every book, index entry and section clickable through to the website. Regenerate any time with one button.
- Homepage in your hands - a layout manager for the hero band, side column and main column, plus a hero-section designer with nine styles that fill themselves from live promotions, and rotating clickable banners managed like posts. You have already built and placed your own.
- Shelf rails you define - staff create homepage book carousels (heading, how many books to show, filters over type/age/format/awards/categories/series, Released and Published date windows, sort) under the Shelf rails menu; each rail's Show all opens the full list view with exactly the same filters applied, and a smaller "More {heading}" link shows this month's new releases in that shelf.
Customers & ordering on account
- Self-service registration, no approval queue - someone types in their school or organisation (name and postal address), sets a password from the emailed link, and can order straight away as a Purchaser. The record is provisional until the office keys that person’s first order under a customer - which is the office’s own matching, done where it always has been. The shop’s customer list is never searched or shown to the public.
- Logins, customers, access - a login is one person (the email is the login; there are no usernames); one login can order for several customers with a different role in each (Purchaser, or Accounts for the customer's money); shop staff decide who belongs where, from the customer's screen or the login's profile, and can block an email address or a whole domain outright.
- Order now, invoice later - account checkout takes no payment; the order becomes a web reference (W-1042) that PegiSync delivers into the office's own inbox, the office raises its numbered order(s) against it, and the invoices sync back to the customer's dashboard. Anything that stops on the way surfaces on Customers → Needing attention.
- Card payment - retail shoppers pay at the checkout and schools pay their outstanding invoices from the dashboard, both on eWAY's own secure page (no card detail ever touches the site); the office is emailed to apply each receipt, and a payment the shopper never brought back from the gateway is recovered automatically.
- Staff view - any customer's dashboard can be viewed from
/my-account/(Find a customer), staff can shop and check out for any customer, and "Switch To" on any login shows staff exactly what that login sees.
Section 2Why future WordPress & WooCommerce updates don't worry us
- Not one core file has been touched. WordPress and WooCommerce are installed exactly as shipped and update exactly as shipped. All custom behaviour connects through their official hook-and-filter system - the public extension API both projects guarantee backwards compatibility for, and the same mechanism every mainstream plugin relies on.
- Almost no third-party dependencies. The whole site runs on WooCommerce plus three mainstream, actively maintained plugins: eWAY's own payment gateway, WP Mail SMTP for reliable mail delivery, and User Switching for staff impersonation. Every extra plugin is an update risk and a security surface, so there are no others.
- No page builders, no licences, no subscriptions. Nothing on the site depends on a vendor renewing interest - there is nothing to renew and nothing that can be abandoned.
- All custom work lives in exactly two packages. One plugin (
pegi-core- data and business rules) and one theme (pegi-shelf- everything visual), under full version control with every change recorded and reversible. - The one bundled library is a safe choice. The PDF engine (FPDF) is a small, stable, dependency-free piece of plain PHP that has been unchanged for years and only loads while a catalogue is being generated.
- Data is stored the WordPress way. Books are ordinary WooCommerce products; promotions, heroes, schools and contributors are ordinary posts. The seven purpose-built database tables exist only where 138k products or the office's trading data demanded them, and they are plain MySQL tables that WordPress updates never touch.
- Single source of truth everywhere. Prices, orderability, catalogue membership and delivery each have one function that decides. Future rule changes are one-place edits, not site-wide hunts.
- Documented for succession. A maintained technical README ships inside the plugin (updated in the same commit as any rule change), plus this handbook. Any competent WordPress developer can take the site over without archaeology.
- Proven at scale. Queries, indexes and caching are tuned and tested against the full 138,000-product catalogue, not a sample.
Section 3Architecture at a glance
The build follows one rule everywhere: pegi-core owns data and business rules; pegi-shelf owns presentation. If code decides what a price is or what a field means, it lives in the plugin; if it decides how something looks, it lives in the theme. The theme never computes a price and the plugin never emits front-end markup.
The theme asks the plugin for answers (pegi_get_pricing(), pegi_product_orderable()) instead of duplicating rules. Change a rule once, it changes everywhere.
Books, promotions, heroes, schools and contributors are ordinary posts with meta. Custom tables exist only where 138k rows demanded indexed queries.
Filters expose the rules to future code: pegi_customer_ctx, pegi_guest_orderable_codes, pegi_school_orders, pegi_web_order_reasons, and the office-sync filing filters.
Section 4System reference
Each custom system in one place: what it does, where it lives, and the functions that own it. File paths are relative to wp-content/.
4.1 Content model
Registered by plugins/pegi-core/includes/content-model.php.
- Five product taxonomies:
book_type,age_band,book_format,award,series. - Four post types:
contributor,pegi_promotion,pegi_hero,pegi_rail. (pegi_schoolis registered inschools.php.) - All taxonomies are flat data by convention — four are registered hierarchical purely to get the checkbox UI. Never introduce parent terms.
- Contributor pages are never rendered:
/author/{slug}/permanently redirects to/shop/?contrib={id}. - Book fields live in
_pegi_*product meta, edited on the Book data tab in the product editor (admin-book-data.php), which also derives ISBN-10 from ISBN-13 and syncs RRP to the Woo regular price.
4.2 Pricing
pegi_get_pricing( $product_id, $customer_ctx, $qty ) in includes/pricing.php is the only place prices are computed. Precedence, first match wins:
- Two stored prices, no more. RRP (office Price 1) and the special (office Price 3, synced raw). sbas Price 2 is never synced — it is arithmetic, and can never hold a special.
- Rounding is always down to the cent, and the 20+ tier shows and charges on every book.
- School price in catalogues = flat 0.8 × RRP, rounded down. A special still wins.
- Who the customer is comes from
pegi_resolve_customer_ctx(), filterable viapegi_customer_ctx. School memberships override the legacy per-user flags through that filter. - Every flat price labels itself. Special, standing-order and September birthday each carry a badge on the listing card and beside the price, the reason on hover, a note under the price and a line on the basket. Only one applies at a time, read in the pricing order, so a badge can never disagree with the figure beside it.
- Every word of that is staff-editable on one screen — Settings → Wording (see 4.3a). The theme holds none of it.
4.3 Availability & login-gated ordering
The status registry (includes/status-registry.php, option pegi_status_registry) separates which code a product has — import-owned, _pegi_status_code — from what the code means: the staff-owned label, explanation, orderability and colour, edited on Settings → Wording.
- The office uses about 37 different status wordings, because most append a month or a reason (“Temp O/S Due August”, “Reprinting/No Due Date”). The sync groups those families onto the registry's nine defined statuses, and the exact office wording is still kept on the book and shown as the banner title.
- A wholly new office status arrives as its own code and needs a line adding to that grouping — otherwise it cannot be filtered on and has no customer-facing wording.
- Two things show a status, set separately. The small badge (a tick-box on the code) appears on listing cards, basket lines, the checkout and above the title on the book page. The fuller banner on the book page appears only when the code has a sentence written for it — without one the badge says NEW RELEASE and nothing says due in October, which is why filling those sentences in matters.
Who may order what
pegi_product_orderable( $id, ?$logged_in )is the single source of truth. Signed-in customers get the registry's flag; guests are limited to the codes in thepegi_guest_orderable_codesfilter — by default no-status,instockandnew.- Enforced server-side on
woocommerce_add_to_cart_validationplus a cart re-check, and — because the theme checkout is custom — guarded again in the theme: gated basket lines swap the checkout button for sign-in (pegi_shelf_basket_gated()), and the checkout POST handler refuses before any order exists. - Product pages show a greyed-out Add button with a contact note. No “sign in to order” buttons there (client preference).
woocommerce_product_is_in_stock filter and the Stock status / Manage stock / Sold individually fields are hidden on book edit screens — a stale Woo value would make an orderable book unbuyable while its button still looked live. Non-book products (BOB, standing-order packages) keep normal WooCommerce stock behaviour.4.3a Wording — one screen for every explanation
Settings → Wording holds every short piece of text that explains something to a customer, so the same thing reads the same way wherever it appears. It has five tabs:
- Stock statuses — what each availability code is called, the sentence shown under it, its colour, whether it can be ordered and whether it shows a badge. Every column heading carries an i button explaining what that column actually controls — Can be ordered most of all: unticked it takes the Add to basket button away from signed-in customers and visitors alike, and even ticked, a visitor who is not signed in can only ever order in-stock and new-release titles. The colour is picked from the site's own palette — green, amber, ochre, berry, grey — and applies to both the badge and the banner on a book page, with a swatch beside the box showing how it will look; each one is a matched set, so the writing stays easy to read whichever is chosen. The office decides which status a book has; the shop decides what it says. Every code the catalogue actually uses is listed, busiest first, with how many books carry it — and any left without a name are flagged, because a book with an unworded status shows a customer no availability at all.
- Prices — the SPECIAL, SO PRICE and BIRTHDAY SALE badges, their hover explanations, their notes on the book page, the bulk-price line and the GST note. The same badge and the same hover label a basket line, so there is nothing separate to word for the basket.
- Order statuses — what a customer sees for an order sent to the shop, received by the shop, or closed. Staff always see the fixed names in wp-admin, so a phone call never means two different things.
- Checkout & delivery — the no-payment note, the delivery-address note, the pick-up small print, and the delivery and pick-up lines written on the order itself.
- Accounts — the card shown to someone who has registered but is not attached to a school or organisation yet.
Each box holds the wording actually in use. Clearing a field restores the wording the site shipped with - except for the phrases marked optional, where empty means show nothing. Numbers and rules are deliberately not here: prices are worked out by the pricing engine, delivery by the delivery rule, and orderability by the status registry. These are only the words around them. Long-form page copy (About, Contact, the register page) lives in those pages and is edited in the block editor.
4.4 Search
- A custom FULLTEXT table,
{prefix}pegi_search_index. Itssearch_textcolumn holds title, author, illustrator, both ISBNs, categories, series and book type — deliberately not the description. - Built and maintained by the theme (
inc/archive.php): each product's row is rebuilt on save, and theposts_searchfilter swaps WordPress's slow LIKE search for a boolean FULLTEXT match ordered by MATCH score. Tokens under three characters fall back to a title LIKE. - The header search carries a scope dropdown (
?sf=): All, Title, Author, Category, Illustrator, Series, ISBN. - Title is a LIKE match ranked word-start-first. Author and Illustrator resolve contributor names then match the id metas. Category and Series resolve term names. ISBN is a normalised prefix match over the ISBN/SKU metas — an exact single-ISBN match goes straight to the book.
- The chosen scope swaps the search box's placeholder and shows in the results heading.
- No search-as-you-type, by client request.
4.5 Cover images
- Static files at
/images/{isbn10}.{gif|jpg|jpeg|png|webp}in the web root (ISBN-13 names also accepted), FTP-uploaded by staff. - Deliberately no media-library attachments — 138k attachment rows would bloat
wp_postsand every query that touches it. pegi_shelf_cover_file_url()resolves them. A missing file renders a branded placeholder: owl logo and title on a framed paper card (.cover-ph; its inner classes arecph-*, becauseph-*is the promo-hero namespace).- Covers render with no white backing: the 2:3 slot keeps layouts aligned, but the shadow is a
drop-shadowfilter on the img, so it hugs the actual artwork. - The PDF generator resolves the same files via
pegi_promo_pdf_cover_path().
4.6 Listings, filters & sorting
One block renders them all. pegi/archive-listing (inc/archive.php, ~840 lines) draws search results, the shop archive and every taxonomy archive: filter drawer, sort select, context-aware type chips, active-filter chips, tile/list toggle, multi-select add bar, and a designed zero-results panel with Clear all filters.
- Drawer filters are built once by
pegi_shelf_drawer_filters()and shared with the chip counts, so a chip's number always equals what clicking it returns. - Category (
cats[]) and series (ser[]) parameters ride the same machinery. They arrive from shelf-rail Show-all links, and the drawer shows no checkboxes for them. ?view=listopens the list layout directly — rail Show all and More links use it.
One sort vocabulary, everywhere
- Title A–Z / Z–A, Author A–Z / Z–A, Illustrator A–Z / Z–A, Availability, Newest, Oldest, Price Lowest / Highest First.
- No colons in the labels — the select's default row prefixes “Sort: ”. The same keys and labels are used by the shelf-rail admin.
- Context only decides the default: search → Relevance, promotion listing → Catalogue order, date-windowed → Newest, otherwise Title A–Z.
- Price sorts join WooCommerce's
wc_product_meta_lookup; author and illustrator sorts join the contributor post title. - Heading counts are thousands-separated, and all front-end headings and subheadings are Title Case.
Date windows — one canonical vocabulary
- Two fields filter every list:
rel(release date) andpub(publication date). - Values:
tm(this month),lm(last month),Nm(rolling last N months),YYYY-MM, or aYYYY-MM..YYYY-MMrange with either side open. - The drawer offers both as a This-month checkbox plus native month-range inputs. The same vocabulary drives the shelf-rail Released/Published settings and the homepage New Releases links.
pegi_count_ver option, which bumps on any product save — never add per-product cached variants, which is a transient explosion at 138k products. And never query type=DATE against pubdate meta: CAST() kills the meta index, so the window clauses use plain string comparison on Y-m-d.4.7 Promotions
A promotion is a pegi_promotion post (classic editor - the screen is a form) holding campaign copy, state, accent colours, artwork and the catalogue's section ranges. The volatile part - which books and their catalogue item numbers - lives in the {prefix}pegi_promo_items table so that range queries ("items 41–97") and catalogue-order sorting stay index-served. All updates flow through pegi_promo_replace_items(), which atomically replaces the set and bumps the cache version.
pegi_count_ver afterwards.Two ways to fill a catalogue
The promotion screen reads as five numbered steps: what the catalogue is, fill it from the office, its titles, its sections, then the printed PDF.
- From the office (step 2, the newer route). The office already numbers its catalogues in twenty numbered columns on each book, and those columns come down with the sync — so you pick the column this catalogue uses and press a button.
- You can narrow it first: a range of numbers, book types, age bands, availability. Check what this would load reports the count and the first few titles before anything is written.
- Pasting or uploading a spreadsheet of ISBNs still works exactly as before, and is the way to build a list the office never numbered.
- Two quirks of the office data are handled for you. It re-uses those columns between campaigns, so nothing is ever re-read without someone pressing the button, and every import is date-stamped.
- And one column sometimes holds two lists — which is what only numbers that appear once separates.
4.7a Standing orders — what's in each month
Two pages. /standing-orders/ sells the packages: the benefits, then What's in Each Month — one card per package family showing its newest finalised month, the title count and the first covers — then the paired Option 1 / Option 2 cards and PAYG (the books before the prices, client). /standing-orders/titles/ is the list itself — family tabs, a year strip, a month strip, then the month's titles as the site's ordinary book cards (the title's position as its No. badge), with the tile/list toggle and Add selected to basket.
- Nothing is published by hand. Each package family has a numbered column in sbas (Premium–Half–PAYG = Promo4, Secondary = Promo16, Popular = Promo15, Graphic Novels = Promo17). As the office packs a month it writes a running number on each title — 501 was the first book of the first month and the count is never reset — so a month is a range of that column: twenty numbers for Premium, twelve for the others. The office numbers a title; the month appears.
- The rule (
pegi-core/includes/standing-orders.php): index = number − 501; slot = index ÷ titles per month; year = first year + slot ÷ months per year; month = the family's months in order (Feb–Nov, or Feb/Apr/Jun/Aug/Oct for Graphic Novels); position = the remainder + 1. Premium numbered since 2018, Secondary and Popular since 2021, Graphic Novels since 2023. - The Premium list is cut by position — 1–8 Picture Books, 9–16 Novels, 17–20 Information Books — because that is how the office fills it. Half Premium and the three combination packages are drawn from the same twenty, so one list serves six products.
- A number below 501 or above next year's last number is ignored (the office parks 9999 on a title it wants out of a list). A number written twice lists both titles under the same No.; an unnumbered position is simply absent, and the heading's counts are counted from what is there.
- The year row keeps to one line: years run newest first and whatever would wrap is folded behind a “+N more” chip (oldest first, never the chosen year), re-fitted whenever the window is resized.
- Months of the current year not yet numbered show as muted “not finalised yet” chips. A family with no list yet shows the empty state and has no overview card.
- Each book's detail page carries a Standing Order row (“September 2026 — Premium, Half & PAYG, No. 3”) linking to its month.
- Cached per family and per month against
pegi_count_ver, so a sync shows on the next request. To add a family or change a column, edit the registry instanding-orders.php— one entry.
4.8 Catalogue PDFs
The site generates each promotion's PDF catalogue — promo-pdf.php for settings, the admin screens and data, promo-pdf-renderer.php for layout, on bundled FPDF. It is always a “PDF catalogue” in the shop's words, never a printed one.
- One ledger, five styles. Every catalogue has the same structure — a cover, then pages with the masthead band, a sidebar carrying the section index (long names wrap to two lines) and the notices, and the item rows. A style is a colour scheme and a cover: Forest ledger (deep green and gold, the photo cover) and School Term 1–4, each coloured to its Term artwork — sky blue, autumn mustard, storm blue, spring green — with that artwork as the cover. Forest is the default.
- Every book, index entry and section links to the live site.
- Everything is set in one place — the 5. PDF catalogue box on the promotion: style, cover, words, prices, notices. No separate settings page.
- The cover page is picked from tiles: the photo cover (forest's own — a bookshop photograph under a green wash, the logo and the catalogue's name over it), the bursting-books artwork (the magazine's own), plain green, your own finished artwork placed as it is, or none. For the photo cover a grid shows the seven bundled bookshop photographs exactly as they will print, plus a tile for a photograph of your own from the media library. Any photograph works — the wash makes the room for the wording.
- The catalogue's name on the cover is the promotion's short label very large (TERM 3), its title under that, then the one-line description — all from box 1, so the cover needs nothing typed twice.
- Sidebar notices are what the sidebar carries besides the index: any number of short notices — heading, wording, optional link — for an offer, an event, a school visit, printed on every page. The fixed 20%-off roundel is gone; nothing about the discount prints unless typed as a notice.
- Prices per catalogue: RRP and School Price (the default), RRP only, School Price only, or none.
- Duplicate on the Promotions list (and on Pages, Posts, Hero sections, Shelf rails and Banners) makes a draft copy of an item with everything on it — next term's catalogue starts from this term's. The copy has no PDF and no short web address until given its own.
- Drag to order. On the All Promotions list, the handle at the left of each row drags it into place and the order is saved at once — that is the order the homepage's Promotions module, the promotions page and the homepage hero show them in (the hero leads with the first). Sorting the list by a column greys the handle until the sort is cleared.
- Keep one off the hero. Untick Show in the homepage hero in box 1 and the promotion stays live everywhere else but the hero skips it.
- Set in the website's own type, carrying its own logo: Figtree throughout, embedded in the file so it travels; the shop's name and tagline are the lockup artwork itself (cream on green, green on cream), never typeset, drawn with real transparency on every masthead and cover.
- Density is 5–6 items per page with 4-line clipped descriptions.
- Generation is staff-only, via the Generate PDF button at the top of the box. The output URL is stable, nothing regenerates automatically, and
pegi_promo_generate_pdf()is directly callable for the future office API. - It opens on the cover. Browsers remember where you were in a PDF by its address, and the address does not change when a catalogue is regenerated — so the file itself says “open on page one, whole page” and the site's links to it add
#page=1.
4.9 Homepage layout, shelf rails & hero sections
The notice bar
- Edited at Settings → Store notice: type the message, tick to show it, optionally give it a start and end date.
- Both dates are included — a notice ending 7 April still shows all day on the 7th. Leave a date empty for “no limit”.
- The screen says whether the bar is on the site right now and, if not, why not: switched off, not started yet, or finished.
- Visitors can close it and it stays closed for the rest of their visit — but editing the message brings it back for everyone, so a changed closure notice is never missed.
The main menu is a WordPress menu
- Edited at Appearance → Menus like any WordPress site: drag to reorder, drag right to nest, add pages or custom links.
- One list feeds both the desktop menu bar and the mobile drawer, so the two cannot drift apart. Block themes normally hide that screen; the theme registers it back (
inc/nav.php, location “Main navigation”). - The item pointing at
/categories/becomes the Categories mega-panel button rather than an ordinary link — that is how the 3,500-category panel opens. - Anything nested under an item becomes its hover drop-down, one level deep.
- Empty or unassign the menu and the coded default renders instead, so the site cannot lose its navigation.
Homepage layout
- Registered modules arranged into three zones — hero band, left column, main column — at Appearance → Homepage layout (option
pegi_home_layout, registries ininc/home-modules.php). - Adding a new module type is one registry entry. A
side_okflag keeps wide modules out of the narrow column — enforced in the picker, on save and at render. - Side-eligible: the compact info cards (Curriculum with the Australia flag-map artwork, Standing Orders, Class Sets), the New Releases side card (stacked month pills that fill green on hover, plus the 6/12-month pills, mirroring the main strip's
?rel=links), and the banner rotator.
Author and Book of the Month — chosen at the office, not here
- Both picks are made in sbas, in the same Promo6 column a catalogue is numbered in, using two numbers kept above that catalogue's own range.
- 900 marks the Book of the Month. 899 marks the Author of the Month's title — the pick is that book's author, and the card shows that book.
- The sync brings the column down like any other, so the homepage follows the office's choice with nobody editing the website. A new nomination shows on the next page load.
- If two books ever carry the same marker, the newest book wins — newest by publication date, the same meaning the Newest sort uses. The office re-uses these columns rather than clearing them, so a marker left behind from an earlier month must never outrank this month's.
- A pick the office has not marked shows nothing, rather than last month's.
Rotating banners
- The office chooses them, not the website. A title numbered 891–898 in Promo6 (the same column as the monthly picks) becomes a banner, and the wide artwork is FTP'd to the same
/images/folder as the covers but named by the book's 13-digit ISBN instead of the 10-digit one. Clicking a banner opens that book. There is no banner screen in wp-admin. - A slot with no title, or a title whose artwork has not arrived, is simply skipped — and with none at all the module disappears from the page.
- Crossfades every 6 s, paused on hover and focus, off under reduced-motion. Arrows and dots appear only with several banners — a single banner is a static clickable image.
- The client placement is the side column, where the rotator is a square tile: design square artwork, e.g. 600×600, to fill it. A fixed-height main-column strip and a 6:1 default remain defined for other placements.
- Artwork of any other shape sits centred on a blurred copy of itself, so odd sizes still look deliberate.
Custom shelf rails
pegi_rail posts (Shelf rails admin menu; settings box in plugin shelf-rails.php). Each rail carries:
- Title — the heading. Sort — the listing's full vocabulary (title, author, illustrator both ways, availability, newest, oldest, price both ways), same keys and labels.
- Maximum books, compulsory, default 10, no upper cap. It limits the homepage rail only, never Show all.
- Released and Published windows — a preset (this month, last month, rolling 3, 6 or 12 months) or a specific month range, stored in the listing's own
?rel=/?pub=vocabulary. - Tick-box filters for stock status (from the status registry) and type / age / format / awards.
- Chip lists for Categories and Series — those taxonomies run to thousands of terms, so type to search, pick a suggestion to add, × removes. Entries resolve to term slugs on save.
- The lime shelf edge under each carousel doubles as a scroll position bar: a dark-green indicator sizes and slides to show where you are in the row, hidden when there is nothing to scroll.
- Every heading carries a “N titles” pill counted from the rail's own query, so the number always matches what clicking through returns.
- Show all opens the shop listing with the same filters and sort, in list view, with
shelf={title}naming the heading — so rail and listing always agree. - A smaller “More {heading}” link beside the heading lists the rail's taxonomy filters plus New release status and released this month, in list view.
- Every published rail appears in the layout picker as “Shelf rail: {title}”, main column only.
_pegi_highlight, lowest first) come from the office: the sync maps “topten” values 21–40 to Highlights 1–20 and clears everything else, so staff curate the list in the office system, not on the website — the Book data panel's Highlights field still exists, but the next sync overwrites it. The rail shows the whole numbered list, uncapped, so it carries exactly what its Show all lists (falling back to Featured-starred books, then newest releases, so it never renders empty). Show all opens ?highlights=1, “Sort: Highlights order”. It has no More link.Hero sections
pegi_heroposts (plugin edit screen, theme rendering ininc/hero.php), in nine styles.- The waves hero takes custom wording.
- Seven promo-statement bands fill themselves from live promotions — nothing to maintain when campaigns change, and they fall back to the default hero when none are live.
- A media band serves the original animated WebP/APNG/video file untouched: WordPress's resized copies would drop animation frames.
- Every style takes optional colour and background overrides, emitted as CSS custom properties.
- The edit screen's Preview button renders any saved state at
/?pegi_hero_preview={id}, staff only.
4.9a BOB — Box of Books
BOB is the on-approval selection: around 50 new titles sent to a school or library on request, freight paid both ways, with no obligation to buy.
- A BOB is not an order, and never becomes one. Nothing is bought, priced or decided until the box has been unpacked and the school says what it is keeping — so a request is a message to the shop, not a transaction.
- It is an ordinary page, at
/bob-box-of-books/, edited like any other page. There is no BOB product in WooCommerce — a box is not bought, so nothing about it can enter a basket. - The page shows the two Added Bonuses side by side — spend over $300 and keep $25 of books free; spend over $500 and keep $50, school price in both cases.
What staff can edit, and what they cannot
- Nearly all of it is yours: the heading, the opening paragraph, both section headings, the notes, the posters box, the budget note, and the list of facts beside the form.
- Three parts are drawn by the site and cannot be typed over — the request form, the two bonus tiers and the four steps. The form has to know who is signed in; the tiers and the steps are repeated word for word in the request emails, and if the page could be edited away from them a school could be promised one thing and told another.
- Those figures and steps are changed once, in the code, and the page and both emails follow together.
The request form
- Only a signed-in login that belongs to a customer can request one. That is not only a permission: the point of the form is that the school confirms who we would send a box to, and a login with no customer has no name, address or account to confirm.
- Everyone else — guests, and logins with no customer — sees the same greyed button an out-of-stock title shows, with links to sign in or register.
- The form shows the details as the shop holds them: the customer, the delivery address from the office's own account record, and the requester's name and email from their login. None of it is editable, because a copy typed here would be read by nobody; the form says instead who to tell when something is wrong.
- A login covering several customers picks which one, each shown with its address. A login covering one is simply told which.
- Two things can be typed, because they belong to this request alone: a contact phone (deliberately left empty — the best number for this box, not the switchboard) and a note.
- Choosing a bonus option is required, and checked again when the form is submitted.
What happens when it is sent
- Two emails, no record on the website, both set in the shop's own type and built from the same blocks — there is no reason the office's copy should look like a machine wrote it. Both name the customer in the subject, because one login can order for several schools.
- The shop gets the customer, who asked, their phone, the delivery address, the chosen option and their note quoted where it cannot be skimmed past — saying in as many words that this is a request and nothing has been raised, with the customer record as a button.
- The school gets the BOB artwork, what they chose, where it is going and the four steps, so nobody has to come back to the website to remember when the box arrives or how long they have to decide.
- The steps and the figures live in one place in the plugin and are read by both the page and the email, so a school is never told two different stories.
4.10 Customers, logins & ordering on account
The words, used everywhere at once
- Login — a WordPress user. One email address, one password, one person.
- Website customer — the record here for a school, kindergarten, library or anyone else ordering on account. (The record type keeps its internal name
pegi_school.) - Office customer — the same organisation's record in the office system, mirrored to the site. Linked means the two are tied together.
- Access — which customers a login may order for, and its Role at each: Purchaser or Accounts.
- Web reference (W-1042) — a website order. The shop's own numbered Orders are raised against it, and the Invoice is the money.
- “Account” means money only. Customer-facing copy says “school or organisation”; shoppers are never called customers.
A login is a person — there is no people list
- The name on an order is typed into a Your name box at the checkout (prefilled with the login's own name, required) and lives on that order. Nothing else.
- Logins are managed in WordPress and nowhere else. The office system never sends people to the website.
- The email is the login. WordPress insists on a separate username that can never change; here it is a hidden field that always equals the email address. Registering sets it, changing the email renames it in the same step (refused if another login has that address), and the shop's sign-in form accepts only an email. Nobody sees or types a username.
- Blocked addresses — staff can refuse an address, or a whole domain, from registering, signing in and resetting a password: Users → Blocked addresses, or the Block sign-in link beside any login. Blocking also signs that login out everywhere.
- Last sign-in is a column on the Users list, sortable, and says Never for a login that has not used the site — those are kept when it is sorted, since they are most of what the sort is asked about. (Wordfence adds a “Last Login” column of its own; the site hides it so the screen does not carry two.)
- The block list is independent of any login, so deleting a spammer's account keeps the block — and a staff login can never be blocked, so a typo cannot lock the shop out.
What a customer sees
- Five sections, in this order: Invoices, Orders, Still To Come, Saved Lists, Account Details.
- Each is the same panel — count pill, live search box, sortable columns, rows that expand to their line detail.
- A section with nothing to show renders empty rather than disappearing, so the page keeps its shape from the first day an account exists.
pegi_shelf_dash_sections()is the page: an ordered list where each section declares who may see it, andpegi_shelf_dash_can_see()is the single place that decides — so a change to the shell, the search box or an empty state lands everywhere at once.
Who sees what
- Everyone with a login at a customer sees that customer's trading. What a Purchaser does not see is the money: the balance and the customer-wide unpaid total are replaced by that person's own two figures.
- Scoping is by email and nothing else —
COMast.ContEmailfor orders, never by matching a typed name. - For invoices a Purchaser sees one addressed to them or raised from an order they placed. The office addresses every invoice to the customer's one standing accounts contact (
Custmast.InvEmail), never to whoever ordered — so the addressee alone would hide a person's own invoice from them, and with it the card payment. The second test follows the office's own link from invoice back to order. - Every section is scoped to the selected customer first and the person second, so one login covering several customers never sees one's orders under another.
- The selection is per browser (a session cookie), so two people sharing a login can work different customers at once. The basket is WooCommerce's and is shared per login — deliberately — and the checkout warns when it was touched from another browser in the last hour.
- Whether the shop has linked a customer to its office account is invisible to the customer: an unlinked customer simply has nothing to show yet.
- Staff view. Any staff login opening
/my-account/lands on Find a customer: a search over every customer — office customers with or without a website login, and provisional sign-ups — by name or the office's customer number, the customers that staff member opened last, and three tiles into wp-admin (Website customers, Needing attention, Order for a customer). Choosing a customer shows what that customer's own people would see. Staff also sign in to this page, where everyone else signs in to the home page. - Sync log. Customers → Sync log lists what the office sync changed on the website — a sign-up settled into its customer, a login or order moved, a customer created, renamed or retired because sbas said so, a shop order raised against a web reference — one line each, newest first, kept 30 days. PegiSync only carries the rows; every one of these decisions is the website's, so this is where they are read.
- Staff order for customers. A staff login shops and checks out as a customer: the shop's own record (customer number set under Settings → Wording → Checkout & delivery) until it picks another with Change customer under Bill To — a search over all of them. Prices, delivery and the order all follow the chosen customer; the order is stamped “Placed by shop staff” for the office.
A website order is a web reference, not an order number
- The shop raises its own #-numbered order(s) against what was submitted — possibly more than one, depending on what it can supply. W-1042 is the reference tying the confirmation email, the dashboard and the invoice together.
- The Orders panel shows both, as two columns: Order and Web ref.
- Until the shop has raised anything the submission is its own grey row with a blank Order column. Once the shop's orders arrive carrying the reference, the grey row gives way to the shop rows — the reference repeating across every part of a split, so the split is visible instead of explained.
- Order emails are headed “Web reference W-…” and carry it in the subject for the same reason. Retail orders stay plain orders.
Where a web reference is, in three words
- Sent to shop — the checkout has sent it; its lines can still be edited.
- With the shop — PegiSync has written it into the office inbox, or a shop order has appeared. The office's copy is now the one being worked, and the website order is locked.
- Closed by shop — staff closed it, or every shop order raised from it was closed with nothing supplied. The customer sees “Cancelled by the shop”.
- Sending one to the office again. If an order is lost on both sides — a download that half-ran, a shop order deleted before anyone worked it — open it and choose Order actions → Send to the office again. An account order goes back to Sent to shop (so its lines unlock too) and a paid retail one loses its downloaded stamp; either way PegiSync offers it on its next pass, exactly as it offers a new one. The order gets a note naming who did it, and a line appears in Customers → Sync log. It is offered only on an order the office has actually taken — a closed one is reopened on the attention screen instead, which asks why.
- Customers can never cancel an order themselves. Only staff cancel or refund, from wp-admin. A refunded retail order emails the shop so the cash sale can be adjusted in sbas.
Paying by card
- Retail shoppers pay at the checkout; a school can pay outstanding invoices from its dashboard — tick the invoices, see the total, pay them together.
- Card details are entered on eWAY's own page, so they never touch this website.
- The office remains the source of truth for money. A website payment marks nothing paid in the office system: it takes the money and emails the office to apply the receipt, and the invoice updates on the dashboard once they have.
- In between, the invoice reads Payment sent and cannot be paid twice, and the payer's receipt explains exactly that.
- A payment is recorded as its own order, one line per invoice, kept out of the customer's book orders and out of the office's order inbox — so nothing mistakes it for books to be picked. It gets a receipt rather than an order confirmation, and a declined card says so plainly and offers another attempt.
- Whoever can see an invoice can pay it: a Purchaser those that are theirs, an Accounts role any of the customer's.
- A payment can never be taken without us knowing. The card is charged on eWAY's page and only then is the shopper sent back, so somebody closing the tab at that moment has paid with nothing recorded. The website asks eWAY about any unfinished payment ten minutes later and completes the ones it confirms — office told, receipt sent, and the order notes say it was recovered. It asks twice, at about ten and twenty-five minutes, then stops: an order still unpaid by then was never paid.
- Refunds are made by staff in wp-admin (open the order → Refund → refund to the card). The money goes back through eWAY, and because nothing syncs a reversal the office is emailed to reverse the receipt in sbas and the payer is told.
- A paid order's confirmation email is a receipt: the amount paid, the date and the payment reference.
- A payment begun and abandoned reads Payment started with a Finish payment link; left alone it stops holding the invoice after half an hour, so nobody is locked out of paying.
Invoice pills
- Paid · Due with its date · Unpaid.
- A credit note reads Credit.
- A $0 invoice — a standing-order delivery, a free poster — reads No charge rather than Paid, because nothing was ever owed.
Every sbas customer is a website customer
- sbas is the only place a customer is created or renamed. The office sync carries every customer down as an organisation — name, postal address, phone, balances, standing order, last invoice — and the website keeps a customer record for each. Nobody links a record to a customer by hand: the record is the customer. Names, addresses and standing orders are changed in sbas and arrive on the next sync; the recordthe record’s own Customer details box shows them read-only.
- The customer number is the key both sides share. sbas numbers every customer itself (
Customer.BCODE, a whole number handed out the moment a customer is saved and never reused). The sync carries it down with the customer and the website files the record under it, so a customer renamed in sbas is renamed on the website, keeping its logins, orders and invoices. Nothing is picked or typed to make the link. - One number, one customer. sbas refuses a second customer on the same number, and the PegiXP customer form’s BCODE box is locked so nobody types one. Sub-accounts of a school (Book Shop account, Friends of, P&C) are customers in their own right; their people get logins through the Invite screen or by signing up.
- A customer record carries the office’s name, exactly as sbas holds it — that name is the office’s key, so every order arrives under a name it knows. (Customer-facing screens show it in title case.) Its address is the office’s postal address. Access is by id, so a rename changes nothing about who can order: a rename in sbas re-attaches the same record.
- Nothing about people travels. No login, email address or contact is read from sbas. Invoices and orders keep their own contact emails, which is what shows a Purchaser their own documents.
A sign-up for a school or organisation sbas does not have yet
- The record is created at once from what was typed - name and postal address - published so the person can order, and marked Provisional. Their orders reach the office inbox under the typed name with the typed address. The person sees nothing different.
- It settles itself. The office keys the first order under the right customer - an existing one, or one it creates, which sbas numbers on the spot - and when that shop order comes back down carrying the customer number, the website moves the login that placed the order to that customer (with its own orders) and removes the provisional record once its last login has left. If two people signed up under the same typed name, each settles on their own first order, because the office may well put them under different customers. A real customer’s order keyed under a different customer moves the order only, never the login. Until it settles, the record is listed on Customers → Needing attention with the plain fact; to settle one by hand, give the login access to the right customer on its profile and bin the record.
- A dormant customer (no invoice in five years) is still a customer; the list hides them by default and the Dormant view shows them.
Registering
- One short page,
/school-registration/, serves two cases. - Somebody new types in their school or organisation - name and postal address - gives their name and their own email address, and gets a set-password email.
- Somebody who already has a login entering the same address simply gains the customer — safe without asking, because a membership created this way is only ever a Purchaser, and the owner is emailed either way. Several can be chosen at once.
- The page answers identically in both cases, so it never reveals which addresses are registered.
- A typed-in name is matched against existing records by name and suburb before a new one is created, so “St. Mary's” and “St Marys” land on one customer.
- The copy is deliberately plain: no office process, no system words.
Signed in, the same page is a different errand
- Somebody signed in is not registering — they are adding another school to the login they have, and the page says so.
- It lists what their login already covers, which often answers the question before anything is filled in, and states their name and email as facts rather than asking again. Those two come from the login itself, so nothing typed can attach a school to somebody else's account.
- The confirmation is plainer too: no password link to go looking for.
Who belongs where is decided by shop staff, in wp-admin
- From either side: a customer's screen has a Logins box (give access with a role, change a role, remove access); a login's profile has an editable Access box doing the same from the other direction.
- Above it sits the login card — email, name, whether blocked, when registered and last signed in, every customer it can order for, and its last five web references. It is the one block staff see a login through, everywhere.
- Removing a login removes its access rows with it.
- Deleting a customer record removes its access rows; the customer itself is untouched in sbas and comes back as a fresh record on the next sync (its logins have to be given access again). A record with open web orders cannot be deleted.
- A login that loses all its access is not quietly turned into a retail shopper: it sees a card explaining it is not yet attached to a school or organisation, with the registration page and the shop's phone number, and no retail checkout.
Rules that keep the link honest
- One name, one customer. Two records with the same name are impossible to tell apart on any screen that lists customers by name — including the office's own matching — so the website refuses a duplicate as it is typed: the existing record keeps its name, the new one is held back as a draft nobody can order for, and a message links the record already using it. The office's own renaming is exempt, because the office decides names.
- Deleting a customer record loses nothing the office holds. Invoices, orders and balances come down against the office's customer number, so deleting a record deletes none of them — the next sync mirrors the customer again, documents and all. What goes is the logins' access. An order already closed keeps the customer's name written on it, so its own history stays readable.
- An order moved at the office follows. Every order the shop raises carries the web reference it came from, including every part of a split — so a shop order arriving under a different linked customer moves the website order to that customer, with a note recording the move, rather than changing silently or being stranded. Nothing is granted to the login by that move; if the login can no longer see the order, it appears on the attention screen.
The Customers menu in wp-admin — four screens
- Website customers — the one list: every sbas customer plus any provisional record a sign-up has made, with columns for Source (with the customer number), Logins, Open web orders, Last invoice and Where, and views that cut it six ways: From the office, Provisional, Dormant, Has logins, No logins, Has open web orders. Dormant customers are hidden until asked for. Each record carries the office’s invoices and orders for that customer, as the sync delivered them, with their lines; and the search box takes a name, a customer number, or an invoice or order number, so “did that invoice arrive?” is answered here too. Every “which customer?” control in wp-admin is a search box, because seven thousand rows cannot be a dropdown.
- Needing attention — see the three panels below.
- Invite — upload the office’s file of people (person, email, exact sbas customer name, role) and each gets a login, access to their customer and the branded welcome email, sent in batches; preview first, safe to run again, results per run, and the email’s words editable on its Email tab with a test send. A self sign-up receives a different welcome letter (its own words on the same tab) that says the first order will be checked. The link in either lasts 30 days and keeps working until the person has set a password or signed in — mail systems that open links on arrival can no longer spend it first.
- Card payments — every card payment the website has taken, invoice payments and retail orders alike, each with the gateway's own transaction reference and where it got to. The screen to answer a “did that go through?” call from, and the one to reconcile against the settlement report in MYeWAY: this list is what the website took, that one is what the bank paid out.
Customers needing attention
The first panel on Customers → Needing attention: provisional records - who signed up, their logins, whether an order is open - each stating that it settles when the office keys that order under a customer, and how to settle one by hand; and customers removed in sbas while logins or open web orders still point at their record. No buttons: nothing here needs a decision.
Web orders needing attention
Most web references look after themselves. The ones that stop are listed on Customers → Needing attention, with a badge on the menu and a notice on the Orders screen.
- What lands there: not downloaded after a working day; downloaded but no shop order back after three; the only shop order raised from it deleted in the office; or moved to a customer the ordering login cannot see.
- Each row says why in plain words and offers what to do: Give access (with a role — usually also removing access to the record it was wrongly placed under, and deleting that record if nothing else uses it), Leave as is when the login genuinely should not see that customer, or Close when the office confirms it is not going ahead.
- Several orders by the same login under the same customer are one row and one decision.
- Decisions stay visible on two further tabs — Left as is (with Reconsider) and Closed here (with Reopen) — so nothing decided here disappears and minds can be changed. Each tab appears once it has something on it.
- Closing an order here is the website's own bookkeeping. It tells the office nothing.
Card payments the office has not finished with
The same screen watches the money, underneath the orders. A card payment is only half done when the website takes it.
- An invoice payment is finished when the office applies the receipt in sbas and the invoice stops owing.
- A retail order is finished when the office downloads it and it becomes a cash sale.
- Anything still unfinished after seven days is listed, worst first, with what is needed — apply the receipt, or check PegiSync is running.
- Both kinds clear themselves the moment the office acts, so in the normal course nothing here is ever ticked off.
- The one manual escape is Record as applied, for a receipt the office put against a different invoice. It needs a typed reason, which is written on the payment and stays visible on the Card payments screen.
The screen is the notice. Nothing about it is emailed to the shop: the count on the Customers menu and the notice on the Orders and Customers screens point at it.
4.10a “Uncategorised” is never shown
WooCommerce keeps a default category called Uncategorised, and a book the office has not categorised yet lands in it. It is a placeholder, not a category anybody browses, and WooCommerce will not let it be deleted — so the site hides it everywhere a customer could meet it: the header's category menu, the filter lists, the category index, the shelf rails and a book's own category chips. A book whose only category is that placeholder shows no Categories row at all rather than a link to nothing.
In wp-admin it is still visible, deliberately: that is how staff see which books are waiting for a category.
4.10b When there is nothing to show
A search that matches no titles and a web address that does not exist are the same situation to a visitor, so they get the same panel: what happened in one line, and the ways onward as buttons. A listing offers Clear all filters (only when there is something to clear) and Browse all books; a wrong address offers Browse all books and the home page. The catalogue size quoted in that copy is counted, not typed, so it stays true on its own.
4.11 Basket, checkout & delivery
Both are theme screens (inc/basket.php, inc/checkout.php). Checkout is a custom order-creation handler — wc_create_order() in a template_redirect hook, not WooCommerce's process_checkout — with retail and school branches, which is why the gating and delivery rules are re-checked there explicitly.
- Delivery (
pegi_shelf_delivery_fee()): free for signed-in users at $50+ inc GST, otherwise $9.90. Standing-order customers never pay delivery, whatever the order comes to — and the order line and the copy say the standing order is the reason, not the $50 threshold. Guests can choose free Walkerville pickup at retail checkout. - Orders are stamped with
_pegi_order_type, the school identifiers and the “Ordered by” name. - Saved lists are per-user: save a basket, restore it later.
- An “already in your basket” confirm appears before adding a duplicate — and never blocks a sale if the check itself fails.
The checkout is a form beside a live order summary
- The summary shows every line with its quantity, the subtotal, the delivery fee, the GST included and the total — computed by the same code that will create the order, so what is shown is what is charged. The basket and the confirmation show the GST the same way.
- A school sees its name stated under Bill To with a Change customer control rather than a dropdown (option cards for a login with several customers; a search for staff). Picking another reloads the checkout as that customer, so its prices, delivery fee, names and address are the ones shown.
- The Delivery line in the basket's order summary and the checkout summary carries an ⓘ whose text is editable under Settings → Wording → Checkout & delivery; Send To has no explanatory paragraph of its own.
- It sees Bill To — the customer, with the postal address the office holds. Pick-up in store is offered to account customers only when the shop switches it on (Settings → Wording → Checkout & delivery); off, there is no Delivery step at all and every account order is posted to Send To. Card customers are always offered pick-up.
- Send To starts as the customer's name and Bill To address with a Same as Bill To tick and the fields locked; untick it to send the order somewhere else, and an Attention line names who it is for. A different destination travels to the office in the order's notes (“Send to: ATTN …”) as well as in the ship-to block, and is never written back onto the customer record.
- Their name is typed in, prefilled with the login's own and mandatory: this is the name the shop puts on the order and the invoice. A purchase-order reference and notes follow.
- If the basket was touched from another browser in the last hour, the checkout says so — the basket is shared per login.
- A retail customer gives name, phone and email, chooses post or pickup, and supplies an address either way: it is their address, and for a posted order also the destination. Send To also takes an optional Business name and Attention line, the same two lines a school's Send To carries.
- Validation is on the form itself. The browser refuses an incomplete submit in place; if the server ever bounces one it returns to the filled-in form with a banner, never an error page.
- Every check runs before the order is created, so a failed submit never burns a web reference number — the office sees an unbroken sequence.
- Confirmations (already in your basket, empty basket, delete list) are the shop's own dialogs, not browser pop-ups.
My Account is the dashboard, Account details, and nothing else
- WooCommerce's Orders, Downloads and Addresses endpoints are removed from the menu: the dashboard's Orders panel is the better view of the same thing, nothing is downloadable, and no code reads a saved address.
- Account details is the login's own email, name and password. Who it may order for is decided by the shop.
- Signing in lands on the home page — the person came to order — unless the sign-in was reached from somewhere specific (the checkout), which takes them back there. Shop staff land on the staff view instead.
- Ampersands in titles. A title like “Buck & Ears” is stored as the office sends it, and the catalogue PDF prints it as a real ampersand rather than the web code for one.
- The reset-password email names the email address, not a username — WooCommerce's stock wording showed a string customers have never seen.
- Sign in and the two password screens are one design: the form in a card, and a panel beside it answering the question of the moment — what an account gives a school, or how to get unstuck on a password. On a phone the form comes first and the panel sits under it.
- Forgotten passwords go through WooCommerce's own lost-password endpoint (
/my-account/lost-password/), dressed as the sign-in card — request form, “email sent” state and choose-a-new-password form all render there, signed in or not, and the reset email is the branded one. - The account block checks for that endpoint before deciding between the sign-in form and the dashboard.
- Every set-password and reset link the site sends is built by one function (
pegi_password_reset_link()) pointing there, and an older WordPress-format link (wp-login.php?action=rp…) is redirected to it — nobody lands on the bare WordPress screen.
A school order confirms itself at the checkout
- WooCommerce only mails when an order changes status, and a school order is born pending and stays pending until the office has it — left alone it would confirm nothing to anyone.
- So the checkout sends both confirmations itself once the order is saved: the customer's “order received” and the shop's “new order”, each still governed by its own settings under WooCommerce → Emails.
- Retail orders wait for the gateway, whose payment step fires the same mails the normal way.
An empty basket is a designed screen, not a dead end
- It is the moment a shop most needs to be useful — the visitor has either not started or just emptied it.
- So it offers the routes back in (Continue browsing to the home page, and restore a saved list when there are any) and states the two things schools most often ring to ask: the delivery threshold, and that ordering is on account.
- A visitor who is not signed in gets one extra line, aimed at schools and libraries, pointing at sign-in — the single change that most affects what a school sees: their prices, what can be added to the basket at all, and free delivery from $50 — and saying that retail customers need no account.
4.12 Email
One shell for everything the site sends
- Three kinds of mail leave this site — WooCommerce's order mail, WordPress's account and password mail, and the shop's own school and payment messages — sharing one masthead, one footer, one palette, the same colours as the website.
- WooCommerce's side is handled by template overrides in the theme (
themes/pegi-shelf/woocommerce/emails/). - Everything else is caught by a single filter in
pegi-core/includes/emails.phpthat wraps a plain-text body in the same shell — so a message added later is branded without anyone remembering to.
What an order email carries
- The covers. This is a bookshop, and a list of titles is far easier to check against what you meant to order when you can see the books. Each line carries its cover, title and ISBN, and the cover and title link to the book's page.
- A book with no cover shows the shop's placeholder — the same book-shaped owl card the website draws. Covers are files under
/images/rather than media-library attachments, so WooCommerce's own product image cannot be used; the shop's lookup resolves them. - The total, repeated above the books. A band at the top of every order email carries the amount, how many books it is for and the delivery line, so a forty-title order need not be scrolled to the bottom to answer “how much?”. It reads Total paid only when the money has moved; otherwise Order total, because an account order is invoiced at the price applying on the day each title is supplied, and the band must not contradict the note further down that says so.
- The account, not just the cart. Under the summary sits the school, who placed the order, and the school's own purchase-order reference — the number their finance office matches the invoice against, and the most important thing on the page for them.
- The purchase-order row shows on a school order even when it is blank, because its absence is exactly what someone needs to notice.
- Below it, the note that prices are those current when the order was placed and titles are invoiced at the price applying on the day they are supplied. On a card-paid order the note is omitted — that total cannot change.
Wording and the missing address
- WooCommerce's stock closing lines (“Thanks for reading.”, “Congratulations on the sale!”, quoting whoever installed WordPress) are replaced: every customer mail ends with the shop's phone number and the hours it is answered, and the shop's own new-order notice carries no closing line at all. Editable in WooCommerce → Settings → Emails, and an edit sticks.
- WooCommerce's advertisement for its phone app does not appear in the staff order mail.
- School orders have no billing name or address, because nobody types one — the shop already holds the account. Left alone WooCommerce prints “N/A” under a Billing address heading and writes “order #169333 from has been cancelled”.
- So the address block does not appear when there is nothing in it, and the name is filled from who placed the order — only while a mail is being written, so nothing else on the site starts showing a name the customer never entered.
4.13 Search engines & the price Google sees
There is no SEO plugin, and there is not meant to be. Every page is generated from data the shop already owns, so the tags are derived exactly rather than typed in one page at a time (pegi-core/includes/seo.php).
- Each page emits a meta description written from its own content, a canonical URL, and Open Graph / Twitter tags — so a pasted link previews with the cover.
- Book pages additionally carry a JSON-LD Product record: title, description, cover, contributors, publisher, availability, and one offer.
The offer is priced at our price, never the RRP
- People searching for a book are comparing prices, so the number in the markup is the one
pegi_get_pricing()returns — the same figure the page prints — with the RRP alongside it as a strike-through list price. - That ordering matters twice: it puts a competitive price in front of a shopper, and Google requires the marked-up price to match the visible one. Because the pricing engine resolves the viewer's own context, the markup always agrees with what that viewer sees.
- The ISBN-13 is published as a
gtin13as well as anisbn, because an ISBN-13 is a GTIN-13 — the identifier Google uses to match our listing to the same book everywhere else, and therefore what gets our price into the comparison at all. - WooCommerce's own product markup is switched off here: it would publish a second, conflicting record priced from the RRP mirror.
- Availability is stated honestly, not optimistically: on order with the publisher is a back-order, unreleased is a pre-order, and anything nobody can order is out of stock.
- The XML sitemap is WordPress's own at
/wp-sitemap.xml, with the shop's internal record types (schools, rails, hero sections, banners) removed.
noindex to every page. Untick it when the site goes live — that single checkbox is the difference between invisible and indexed, and nothing else needs changing.4.14 Caching
- Listing totals, homepage rails and the category JSON are transient-cached for 6–12 hours.
- All of them are keyed on the
pegi_count_veroption, which bumps on any product save or delete and on a promotion item change — one version bump invalidates everything at once. - An hourly cron (
pegi_warm_home_caches) pre-warms the homepage rails.
The host's own page cache sits in front of all of it
- The server would keep a copy of every signed-out page for 60 minutes, from the first time anyone asks for it, and nothing the website can do would clear it early.
- So the site asks for one minute instead. The server honours that, which is the only handle the website has on it — the cache itself is out of reach.
- A price, a status or a piece of wording changed in wp-admin therefore reaches staff at once and the public within a minute: staff are signed in, and signed-in pages are never cached at all.
- The basket and the checkout are never cached, so they always charge the current price. That minute is the most a catalogue page can disagree with what the basket charges — it used to be an hour, which mattered most at the end of September, when the birthday prices stop.
- Files replaced at an unchanged address still wait the full hour — a regenerated catalogue PDF, or a cover re-uploaded over an old one. Only pages carry the shorter life.
- Signed-in pages are kept out of it by one header the theme sends on every signed-in request. It is not a precaution; without it, one school's prices would be served to everybody.
?v=2 to a link, counts as a different file to the cache. Pages look after themselves; replaced files do not.The basket count is the one number a page cannot be trusted for
- The count in the header is drawn into the page, and signed-out pages are shared — so the number a shopper sees could have been built for somebody else.
- The browser corrects it on every page: an empty basket is settled from a cookie with no request at all, and only a shopper who really has something, on a page that disagrees, costs one small call.
- A shopper with something in the basket also has their pages kept out of the cache entirely, so their count can never become somebody else's.
4.15 Class sets, teacher notes and age groups
Class Sets and Teacher Resources maintain themselves
- Class Sets (top-level menu item) works like a promotion catalogue: a hero band, a card per year level that filters the list in place, then the ordinary book listing with its filters and sorts.
- Membership is owned by the office system — each book's year-level code comes across with the sync, so adding a title to Year 4–5 in the office puts it on the page at the next sync, with nothing to maintain here.
- Teacher notes come from the office too: the notes URL held against each book, 1,127 titles.
- The Teachers Resources menu item is simply the book list filtered to titles with notes, sorted by library rules — so it is always current. The old page address redirects to it.
Age bands are grouped
- Four groups — Early Years 0–4, Younger 5–7, Middle 8–11, Young Adult 12+ — are the parents of the individual ages.
- Filters show the four groups with the exact ages one click away, and choosing a group finds every book in the ages beneath it.
- A new age arriving from the office files itself under the right group, and obvious typos in the office age field are ignored rather than becoming filter entries.
Book types are flat and file themselves
- The office records a precise type for every book — Australian Novel, Overseas Picture Book, General Information — and that exact value is the book's one type here.
- The filter buttons build themselves from whatever types exist: one per type, alphabetical, the ten biggest first, and an All N types button opening the rest.
- A type the office invents later simply appears as a new button. So does a typo — the office spelling is worth keeping clean.
Sorting and the A–Z strip
- Library-rules sorting is the default on every list that is not a numbered catalogue (promotions keep catalogue order, Highlights its curated order, search its relevance). It ignores a leading “The”, “A” or “An”, so The Lost Island files under L.
- Author and illustrator sorts read denormalised name copies on the search-index table, so they answer in under a second across 138,000 books.
- The A–Z jump strip appears on any list in an alphabetical order. Each letter jumps to the page where that letter begins — the list is never filtered, exactly like flipping a printed catalogue open — and the page then scrolls to that letter's first book.
- The letters follow whatever alphabet the sort uses (title, or the author's or illustrator's name), letters with no books are greyed out, and the strip hides itself under Newest, Price and the other non-alphabetical orders.
- Each listing context names itself in the browser tab: a catalogue by its name, Class Sets and Teacher Resources by their menu names.
pegi_shelf_listing_context() (scoping filters in pegi_shelf_context_clauses()) feeds the main query, the type-button counts and the strip alike. A future filter added only in a query hook — where the counts cannot see it — produces page numbers pointing at books that are not on the list. The filter vocabulary (which URL parameter means which taxonomy) and the sort vocabulary are single shared tables for the same reason.The nightly tidy job
- The term janitor deletes filter entries left empty when the office renames a value — an empty book type or category would otherwise pollute the filter lists forever.
- It never touches the age-group headings, the awards lists, anything a homepage shelf rail uses, or the site's own structural categories.
- The Standing Orders category holds only hidden package products, which WooCommerce counts as zero — so it is on a permanent keep list.
Settings → Office sync mappings
- Where the filing tables live: which ages sit in each age group, the class-set year-level headings, and which office stock wording means which status.
- Book types need no table — they are filed exactly as the office spells them.
- Anything not listed still files itself sensibly, so the tables are only needed when you want something placed differently.
- There is a Re-file terms button, because changing a mapping should also move what already exists.
- These are the website's own filing rules, so changing them needs nothing installed or restarted. (PegiSync at the office has its own settings screen for connections, schedules and timing.)
About Us, Contact Us and the Privacy Policy are editable pages
- Their words, photos and cards live in the WordPress pages themselves and are edited in the block editor. They are database content, not theme files — they do not travel with code deploys. Use the same pattern for future content pages.
- The theme supplies only the dynamic bookends as blocks: the About hero (which computes “N years”, the birthday countdown and every word around it from one date — 12 September 1987, the day the lease was signed and the day the shop counts as its birthday — so it can never go stale, and still reads correctly after the 40th), the From James's Shelf band (live covers and links from the catalogue), and the contact form (security token, spam trap and sent-state stay in code).
- The Privacy Policy is the plainest example: every word is in the page, and its template adds nothing but the layout. Its heading is in the content, so — unlike a page rendered by the catch-all
page.html— its shell deliberately leaves the title block out. - Change the policy in the block editor; change its look in the
.legal-*rules inpegi.css. Edit the date at the top whenever it changes, and remember it is linked from the footer of every page and from the checkout. - Every page in Pages → All Pages is one the theme or WooCommerce routes (Shop, Basket, Checkout, My account, the content pages above and the slug-routed pages: All categories, Standing Orders, Australian Curriculum, Teacher Notes & Class Sets, Class Sets, Register your account, BOB, and Standing Order Titles — a child of Standing Orders at /standing-orders/titles/). WordPress's Sample Page and WooCommerce's Refund and Returns Policy draft are not part of the site; WooCommerce's pointer to a refunds page is left empty on purpose — refunds are staff work in wp-admin (§4.11), not a page.
4.16 Office sync
The office's sbas database is the single source of truth for the catalogue fields it holds and for who the customers are. PegiSync — a .NET 8 Windows service at the office (office-sync/ in the repository, with its own README) — hashes rows against a snapshot in a separate sync database and pushes only what changed. sbas itself is read-only to the sync; the few columns the integration needs (the school number and standing-order tick on a customer, the contact email and web reference on an order) are added once by sbas-schema.sql and written only by the office’s own programs.
What travels, and which way
- Office → website: the catalogue (books, authors, illustrators) and the trading data (invoices, orders with their web references, customer accounts).
- Website → office: web orders, through the inbox below.
- Never synced: the office's people register, or any customer email address. Logins live in WordPress and are made by sign-up or the Invite screen.
How a book arrives
- Written through WordPress and WooCommerce APIs only (
includes/office-api.php), never raw SQL into core tables — so ISBN-13 keys, price sync, contributor links, the status registry and the search-index rebuild all behave exactly as they do anywhere else on the site. - Titles arrive in office CAPS and become Title Case, with a trailing article moved to the front: “LOST ISLAND, THE” → “The Lost Island”.
- Descriptions come from the office review table. A book with no office review keeps the copy it has here.
- Categories come from the office's per-book list, and that list is the list — it replaces the site's.
- Highlights come from the office
toptencolumn: values 21–40 become positions 1–20. Its other ranges serve other office lists and are ignored. - Teacher-notes links and class-set year levels travel with each book.
- Deleted at the office = drafted here, never deleted.
Why nothing goes missing
- The snapshot only advances after the website confirms a batch, so a crash or an outage costs nothing but a repeat.
- Every synced record stores its office-row hash (
_pegi_office_hash), which the fingerprint endpoint echoes back for the agent's weekly reconcile. - Uploads run newest releases first, and changing the sync's title or pricing settings re-sends every book automatically, so nothing stale lingers.
- One website, one sync: a single service, a single local settings file, a single sync database, one API key.
Website orders travel to the office through an inbox
- The agent pulls orders from the website (
office/weborders: account orders once sent, retail orders once paid) and writes them intoweb.orders/web.oitemsin the sync database. - Those two tables are column-for-column the old website's
dbo_orders/dbo_oitems, which the office's WebTransfer “update from the internet” already reads — so its queries run unchanged. - Dashboard lists page at 25 rows, with the search box and the sortable columns working across the whole list rather than the page you can see. Panels hold two years of trading, which is all the office sync keeps.
- Each order is acknowledged only after the insert commits. An account order then reads “With the shop”.
- The order feed is never cached. Every reply from the office API goes out no-store, and the agent adds a throwaway value to the feed's address so no network in between can answer it from a copy it kept. Without both, a pass could be handed a list recorded before an order was placed — the order would sit on the website saying “sent to the shop” while the office never saw it.
- Inbox ids carry a 100,000,000 offset, so they can never collide with the office's own history. Rows older than 90 days are purged.
- Card numbers are never written. A retail row carries the card brand and the gateway's transaction reference — the reference that ties a cash sale to a payment in MYeWAY.
- Each row carries the fullest address the website holds: a retail order's own, or the delivery address the school confirmed at the checkout (which itself starts from the office's customer record, else the record here - what the sign-up typed), plus the customer's IP, the delivery choice and a ship-to block.
- Release dates stay the office's — it computes them from its own stock data, as it always has.
- The office raises the sbas orders, stamping the web reference on every part, and the invoices flow back down as ordinary trading data.
The agent's dashboard
- Served on the office machine and reachable only from that machine: sync status, logs, and an Up to date count per area (items the website has confirmed that are not waiting in the queue).
- An Inbox panel and Sync now buttons for every area — delivering web orders, refreshing the customer list — so nobody waits for a schedule.
- Its Settings page manages the agent's whole configuration; staff never edit config files. The settings file is written from the live configuration object, so a saved setting cannot silently revert on restart.
4.17 Newsletter & email marketing
FluentCRM (the free plugin) is the mailing system. It keeps the lists and the subscriber records, the one unsubscribe / manage-subscription page every email links to, the double opt-in, the campaign editor and the sending.
- The sign-up form is the site's own, in the footer where the sample news item was. FluentCRM's forms need a second plugin, so the footer posts to the site and the site hands the address to FluentCRM. The result appears in place, on the form.
- Two lists. Newsletter subscribers: anyone who types an address in the footer, with or without a login. They are pending until they click the confirmation email — nothing is sent to them before that click, and the click is the proof of consent. Customers: anyone who registers or places an order, and the office's customer file once imported. They are subscribed straight away, because trading with the shop is consent for the shop's own news — and every email still carries the unsubscribe link.
- Nothing ever downgrades a contact. A confirmed subscriber who signs up again simply gains the list; an unsubscribed customer is left unsubscribed. Unsubscribing takes a person off both lists at once.
- The form gives the same reply to everyone. New address, existing subscriber, blocked address — all see “Thanks — you’re signed up”. A reply that differed would let anyone type an address into the footer and learn whether it is on the list.
- The office's customers are a CSV import (FluentCRM → Contacts → Import): email, first name, last name, onto the Customers list, status subscribed. It is not synced automatically.
- Campaigns are written in FluentCRM → Campaigns: a visual editor, saved templates, and smart codes such as the first name. The footer with the shop's name, address and unsubscribe link is added for you.
- Emails go out from the same sender as the shop's order emails, at 15 a second.
- The confirmation email and the unsubscribe / preferences link emails are set in the shop's own header and footer, like an order email. Campaign newsletters use FluentCRM's own templates, which are styled in its settings.
4.18 Email safety net (testing)
Every email the site sends — order confirmations and the shop's copies, newsletter confirmations and campaigns, password resets, Box of Books requests — leaves through one door, and Settings → Email safety net is a net across it. The one email it cannot catch is eWAY's own card receipt, which eWAY sends itself.
- Switched on, only the allowed addresses receive mail. The allow-list takes whole domains (
it-tek.com.au— every address there, includingname+anything@) or single addresses, one per line. - Nothing is lost. A recipient who is not allowed is simply taken off the email. If nobody allowed is left, the email is redirected to the fallback address instead, its subject prefixed
[HELD — was to …]and a yellow note at the top saying who it was for — so a tester sees exactly what a customer would have received. - Every held or trimmed email is listed on the settings screen: when, what, who was removed, where it went.
- While it is on, a warning sits at the top of every wp-admin screen and a red EMAIL NET ON badge in the admin bar.
Section 5File reference
Every custom file and what lives in it. Only pegi-core, pegi-shelf and this documentation are custom; everything else on the server is standard software.
5.1 Plugin - wp-content/plugins/pegi-core/ (v0.46.1)
| File | ~Lines | Contents |
|---|---|---|
pegi-core.php | 88 | Bootstrap: constants (PEGI_CORE_VERSION), the plugin headers WordPress and WooCommerce read (Requires Plugins: woocommerce, WC tested up to), the WooCommerce feature-compatibility declaration (High-Performance Order Storage and cart/checkout blocks), requires all includes, activation/deactivation (register content model, install status defaults, flush rewrites). Also disables WP application passwords (see Section 8). |
README.md | 1,900 | The maintained developer contract document. Updated in the same commit as any rule change - read it first. |
includes/content-model.php | 301 | Registers the 5 product taxonomies, the contributor / pegi_promotion / pegi_hero / pegi_rail post types, and all product/rail/banner meta declarations. |
includes/pricing.php | 181 | The pricing contract: pegi_get_pricing(), pegi_resolve_customer_ctx(), pegi_round_down_cent(); cart hook that charges contract prices; pegi_customer_ctx filter. |
includes/seo.php | 386 | Search-engine metadata: meta description, canonical, Open Graph/Twitter cards and the book page's JSON-LD Product. The offer price is the shop's own selling price, never the RRP, and the ISBN-13 doubles as a GTIN-13 so Google can match the listing. Replaces WooCommerce's conflicting product markup. |
includes/emails.php | 804 | Branded email: the palette and type stack shared with the WooCommerce template overrides, the masthead and footer builders, the wp_mail filter that brands plain-text mail (WordPress account and password messages, the shop's own school notices), the shop's closing line for every WooCommerce mail, the school / ordered-by / purchase-order block and pricing note on order mail, the office recipient list, the payment-received and refund notices, the Paid / Payment reference receipt rows, and the cover-thumbnail lookup. Also removes WooCommerce's phone-app advert and its boxed-table dividers. See Section 4.12. |
includes/security-headers.php | 77 | Response hardening: security headers on every response (HSTS, nosniff, X-Frame-Options SAMEORIGIN, Referrer-Policy, Permissions-Policy - sent on init so wp-admin, REST and admin-ajax carry them too; no Content-Security-Policy, deliberately) and the filter that removes the /wp/v2/users routes for anonymous requests, which otherwise list the administrator's login name. See Section 8. |
includes/status-registry.php | 142 | Status code -> label/explanation/orderability registry; pegi_product_orderable(); guest-gating enforcement on add-to-cart and cart check; pegi_guest_orderable_codes filter. |
includes/admin-book-data.php | 305 | "Book data" tab in the product editor (ISBNs, dates, RRP, Special price, status). On books, Woo's Regular price stays visible with a note and mirrors the RRP both ways (Sale price hides - nothing reads it); non-book products keep Woo's fields untouched, since the Regular price is their actual price. ISBN-13->10 derivation. Admin-only load. |
includes/promotions.php | 870 | pegi_promo_items table (schema + version gate) and its whole API (pegi_promo_replace_items(), …_item_ids(), …_item_numbers(), …_sections(), …_resolve_refs()); the promotion edit screen as five numbered boxes - grouped details, the office-column importer (pegi_promo_source_query(): column, number window, unique-numbers-only, type/age/status filters, renumbering, Check and Import), the editable items table with CSV import/export, and the section-ranges repeater. |
includes/standing-orders.php | 294 | Standing-order title lists from the office's numbering: the package-family registry, the numbering rule, and the cached slot / month / title lookups behind /standing-orders/titles/ and the book page's Standing Order row (§4.7a). |
includes/wording.php | 487 | Settings → Wording: the one register of staff-editable customer-facing explanations. Five tabs, 22 phrases plus the stock-status table (built from the codes the catalogue really uses, with book counts and a NEEDS WORDING flag). pegi_wording() is the only reader; each tab saves by merging, so one tab cannot blank another. |
includes/hero-sections.php | 233 | Hero-section edit screen for pegi_hero: style select (9 styles), wording fields, lead-promotion select, colour pickers, background/media pickers, Preview button. |
includes/shelf-rails.php | 474 | Shelf-rail edit screen for pegi_rail: max books, sort (the listing's full 11-option vocabulary), Released and Published windows (preset or month range, stored canonically), stock-status and taxonomy checklists, typed Categories/Series fields with a comma-aware term autocomplete (staff-only AJAX lookup pegi_rail_terms). |
includes/schools.php | 1,290 | Customers and access: the pegi_school CPT (labelled Website customer), the access table (login ↔ customer with role, dbDelta, version-gated), the roles (Purchaser / Accounts — one source for labels, powers and the shared select), the per-browser selected customer, pegi_customer_display_name(), the registration endpoint (the school or organisation typed in - name and full address - becomes a provisional record, deduplicated only against other provisional records), the Customer details box (read-only for an sbas customer), the customer screen’s Logins box, the Users list Access column and customer picker. |
includes/customer-picker.php | 150 | The one search-as-you-type customer control for wp-admin and the lookup behind it — used wherever a screen asks “which customer?”. |
includes/provisional.php | 330 | Provisional customers and how the office’s own work settles them: the shop-order link says which customer a web order was keyed under; the login that placed it moves there with its own orders and the provisional record goes once its last login has left; an order keyed under a customer that reaches the website afterwards waits for the number and settles when it lands; the Customers panel of Needing attention; the Office customer box on a record. No merge screen. |
includes/invitations.php | 560 | Customers → Invite: the office’s CSV of people becomes logins with access and the branded welcome email, in batches through Action Scheduler; preview, runs and results, the 30-day invitation link and its landing, the editable welcome words with test send. Self sign-ups get the same email. |
includes/logins.php | 589 | Logins: the email is the login (username renamed with the email, hidden everywhere, email-only sign-in on the shop's form), blocked addresses (option, enforcement at sign-in / reset / registration, Users → Blocked addresses, Block/Unblock on the Users list), Users list filters, the login card, last sign-in stamp, the editable Access box on a login's profile. |
includes/order-statuses.php | 184 | The three account-order statuses (Sent to shop / With the shop / Closed by shop) registered the documented WooCommerce way, kept out of payment lists, customer self-cancel switched off, lines editable only while Sent to shop, one-off migration. |
includes/sync-log.php | 150 | Customers → Sync log: the one list of what the office sync changed on the website (sign-ups settled, logins and orders moved, customers created / renamed / retired / returned, shop orders raised against web references), version-gated table, 30-day nightly purge. |
includes/web-orders.php | 545 | The web-order panel of Customers → Needing attention (reasons, grouping, Give access / Leave as is / Close with required reasons, the Left-as-is and Closed-here review tabs; the screen also hosts the Customers and Card payments panels; menu badge and notices counting all three) and the Customer column + picker filter on WooCommerce's Orders list (HPOS and legacy). |
includes/card-payments.php | 625 | The money half of the same screen (card payments the office has not applied or downloaded, with the Record as applied decision) and the Customers → Card payments register. |
includes/promo-pdf.php | 700 | Catalogue-PDF configuration and data assembly: the five styles as colour themes, cover kinds per style, the bundled photographs and Term artworks, sidebar notices, the price choice, the promotion's 5. PDF catalogue box + Generate button, pegi_promo_generate_pdf(), image flatten-cache. |
includes/promo-pdf-renderer.php | 700 | Pure FPDF layout, loaded only during generation: the one ledger drawn in the style's theme, the cover pages (photo under the wash, Term artwork, plain, own artwork), the lockup and wash as alpha PNGs, the sidebar index and notices, and all drawing helpers (text transliteration, clipping, pills, cover-cropping). Touches no database. |
includes/list-order.php | 120 | Drag-to-order on list screens (promotions): the handle column, the sortable rows, the order-saving endpoint, and the one order every listing of those types uses. |
includes/site-health.php | 25 | Site Health tuned to the host: the persistent-object-cache suggestion stays off while the server has no Redis, Memcached or APCu to give it. |
includes/no-comments.php | 75 | Comments off everywhere: no content type accepts them, existing ones closed, product reviews off, the Comments menu and bubble gone, comment feeds and the REST comments routes removed. |
includes/duplicate.php | 95 | The Duplicate row action on list screens: a draft copy of a page, post, promotion, hero section, shelf rail or banner with its fields, terms, meta and (for a promotion) title list. |
includes/admin-notice.php | 30 | Admin warning when WooCommerce is inactive. |
includes/helpers.php | 144 | Shared helpers loaded first: set-or-clear meta, the count-cache bump, the one plain-text money formatter, ISBN normalisation and ISBN-13→10, the cover-file convention, the shared wp-admin image picker. |
includes/notice-bar.php | 219 | Settings → Store notice: the message, on/off switch and inclusive date window for the bar above the header; pegi_notice_active() is the one place the schedule is evaluated. |
includes/newsletter.php | 260 | The footer sign-up form's handler and the hand-off to FluentCRM (pending + double opt-in for the footer; subscribed straight onto Customers for registrations and orders), and the filters that put FluentCRM's confirmation and manage-subscription emails in the shop's own header and footer (see 4.17). |
includes/mail-safety.php | 300 | Settings → Email safety net: the testing-period wp_mail filter that restricts every outgoing email to an allow-list of addresses/domains and redirects the rest to a fallback address; the held/trimmed log; the wp-admin warning and admin-bar badge while on (see 4.18). |
includes/bob.php | 92 | BOB (Box of Books): the one definition of the bonus tiers and the basket/order hooks that carry the school's chosen option. |
includes/invoice-payments.php | 420 | Paying synced invoices by card: the invoice-payment order type (one fee line per invoice), the one owing rule, in-flight detection with the 30-minute grace window, the pay and cancel handlers, the emails switched off for the type, the subtotal row dropped from its totals. |
includes/payment-recovery.php | 224 | The quarter-hourly sweep that asks eWAY about payments still awaiting a browser hand-back and completes the ones eWAY approved - two tries, then it stops; never marks anything failed. |
includes/term-janitor.php | 93 | Nightly cron deleting empty office-owned terms, with a permanent keep list for site-structural slugs. |
includes/product-derived.php | 252 | Values worked out from a book's own data: the library-rules sort key (kept in step on every save), the age-band grouping table with its automatic filing, and the class-set year labels. |
includes/admin-sync-mappings.php | 210 | Settings → Office sync mappings: the filing tables (age groups, class-set year levels, stock-status wording) plus a Re-file terms action. Each table overrides the code defaults through the same filter, so clearing one restores shipped behaviour. |
includes/office-api.php | 653 | The office sync API (/wp-json/pegi/v1/office/*): HMAC request verification, bulk product upsert/delete, contributor upsert, the office status-family mapping and the fingerprint reconcile feed. Applies everything through WordPress/WooCommerce PHP APIs only - see Section 4.16. |
includes/office-trade.php | 1,700 | Trading data synced from the office: invoices, orders (every channel, each carrying its web reference and the customer number) and accounts (keyed by the customer number: name, balances, address, standing order, last invoice), behind one entity spec; the mirror step that makes every sbas customer a website customer (find by number, else by name for a record from before the number travelled, else create; retire on delete); the weborders inbox feed (account orders once sent and retail orders once paid, shaped as the old website’s order tables; acknowledgements back); the shop-order link that moves a web reference’s status and customer with the facts and hands a provisional record’s settling to provisional.php; the Invoices and orders box on the customer record and the number search on the customers list; dashboard readers with role scoping; the availability-wording map; the Website customers list’s columns and views. Back orders are derived (open lines, last 12 months), never stored. |
lib/fpdf/ | ~1,934 | Bundled FPDF 1.86 (pure-PHP PDF engine) + its 14 core-font metric files + permissive licence. Loaded lazily, only while generating. |
lib/cover-bg/ | 5 files + thumbs | term-1.jpg … term-4.jpg — the School Term cover artworks (the client's, at A4) and their admin thumbnails; wash-forest.png — the photo cover's green gradient wash as an RGBA PNG. |
lib/cover-photos/ | 7 JPGs + thumbs | The bundled bookshop photographs for the photo cover (any JPEG dropped here becomes a choice) and their admin-picker thumbnails. |
lib/brand/ | 3 PNGs | The shop's lockup with real transparency in cream, white and green, trimmed to one proportion and drawn to one height on every masthead and cover. |
The plugin ships no CSS/JS asset files - admin scripts are small inline blocks inside the screens that use them (jQuery, wp.media, colour picker).
5.2 Theme - wp-content/themes/pegi-shelf/ (v0.30.8)
| File | ~Lines | Contents |
|---|---|---|
functions.php | 446 | Bootstrap: requires all inc/*.php, PEGI_SHELF_VERSION (also busts CSS/JS caches), no-cache headers for signed-in pages. Shared helpers: price line, money (through the plugin's one formatter), the delivery rule and the wording built from its two figures, cover resolution + the branded owl-logo placeholder, status badges, contributor lookup, cached book fetcher. Enqueues the stylesheet/JS with the AJAX nonce. |
inc/archive.php | 1,711 | The listing engine (pegi/archive-listing): all query shaping (pre_get_posts, posts_clauses, posts_search), FULLTEXT search + index rebuild on save, the shared drawer-filter builder (pegi_shelf_drawer_filters(), incl. cats[]/ser[]), sorts, category/filter chips with filter-aware counts, cached counts, zero-results panel, promotion context (?promo=), contributor context (?contrib=), the Released/Published date windows (?rel=/?pub=), browser-tab titles. |
inc/pages.php | 1,057 | Content-page blocks: promotions archive, promotion landing (legacy, item-less promos), standing orders (packages + PAYG + the What's in Each Month cards) and the standing-order titles page (family tabs, year and month strips, the month's list), teacher resources, curriculum, categories index, contact form, old-URL redirects. |
inc/blocks.php | 656 | The display blocks (notice, search, account links + compact login button, basket button, mobile drawer, hero books, categories sidebar + mega menu, promo cards, month band, new-releases strip, shelf rails incl. the custom-rail renderer); the category REST route; the hourly cache-warming cron. |
inc/nav.php | 261 | The main navigation: registers the "Main navigation" menu location, puts Appearance -> Menus back (block themes hide it), builds one nav tree from the assigned menu, renders the desktop bar (pegi/mainnav) and supplies the mobile drawer's items. Holds the coded fallback nav and the one-time seed that turns it into an editable menu. |
inc/basket.php | 407 | Basket page + all cart AJAX (quantity, remove, empty, in-basket check) and saved lists (save/restore/delete; save and delete responses re-render the restore dialog in place). Gated-line handling that swaps checkout for sign-in. |
inc/checkout.php | 978 | Checkout page: form beside a live order summary, retail and account branches (customer stated with a Change control, delivery/pickup cards with the prefilled adjustable address, the Your name box, PO reference), the shared-basket warning, the no-access card, inline validation banners, the custom order-creation POST handler (wc_create_order, shipping line, order stamps, Sent-to-shop status, its own confirmation emails), the hand-off to eWAY, the order-pay and order-received endpoints (firing the gateway's completion hook before output, logged to pegi-payments), and the confirmation and payment-receipt pages. |
inc/detail.php | 378 | Book detail page: byline + About dialogs, description, chips, details grid (incl. the Standing Order row), sticky price panel with live tier totals, teacher-notes callout, more-by-author rail. |
inc/hero.php | 342 | Homepage hero rendering: default waves hero, the nine hero-section styles (promo statements self-filled from live promotions, media band serving originals), colour-override CSS variables, staff preview endpoint. |
inc/home-modules.php | 575 | Homepage layout manager: module registries for the three zones (incl. per-rail and per-promotion entries, the single info cards, the New Releases side card and the banner rotator), side_ok eligibility, Appearance -> Homepage layout admin, zone rendering. |
inc/account.php | 1,241 | Account dashboard: sign-in (email only), lost-password page, customer picker, staff search over every office customer, stat strip, the sortable/searchable trading panels (invoices with pay tickboxes and payment-state pills, orders with their web references, still-to-come) with ISBN columns and product links, payment-outcome notices, saved lists, standing-order card, the no-access card, the trimmed My Account menu; the registration form renderer. |
inc/class-sets.php | 365 | The Class Sets listing context (hero band + year-level cards over the ordinary listing) and the About page's dynamic bookends: the birthday arithmetic, the hero with its confetti and CSS-drawn cake, and the From James's Shelf covers. |
inc/contributor.php | 16 | The 301 that sends every contributor permalink to the filtered shop listing (contributor bios surface only in the detail-page About dialog). |
templates/ (22 files) | 12–30 ea | Thin block-template shells: header part -> one pegi/* block -> footer part. front-page.html hosts the three homepage zones; the rest map one page/archive each (shop, search, taxonomy, product, promotion, basket, checkout, order confirmation, account, school registration, standing orders, class sets, curriculum, categories, about, contact, privacy policy). page.html is the catch-all: any page made in wp-admin without a template of its own renders through it, with the site's layout and the footer pinned to the bottom of the window. page-cart, page-checkout, order-confirmation and product-search-results are WooCommerce's own template names, overridden here. |
parts/header.html / parts/footer.html | 51 / 30 | Site chrome: the main-navigation block (a WordPress menu, see 4.9), search, account/basket buttons, mega menu, mobile drawer; footer hours/address/links and the development build stamp. |
woocommerce/checkout/form-pay.php · woocommerce/emails/ (6 files) | 164 · ~300 | Template overrides: the themed pay page (fee lines priced correctly, WooCommerce's lilac payment box beaten on specificity, a Cancel button) and the six email templates that give every WooCommerce mail the shop's masthead, footer, cover thumbnails and address handling. |
assets/css/pegi.css | 1,867 | The whole front-end stylesheet (also the editor stylesheet): design tokens (incl. the lime rail dividers), header/mega/drawers, listings (tile⇄list), detail, basket/checkout, account, homepage incl. all hero-section styles, responsive rules (breakpoints 520–1700), reduced-motion support. |
assets/js/pegi.js | 1,146 | Vanilla JS, no dependencies: drawers/menus/dialogs, the shop's own confirm dialog, live tier pricing (it picks the row from the server's tier payload and holds no rate of its own), one money formatter, AJAX add-to-cart with duplicate confirm, basket updates, saved lists, checkout interactions (change school, name chips, live delivery total), sortable/searchable panel tables, tooltips, shelf-rail paging, banner rotator (6s crossfade, paused on hover/focus, off under reduced-motion), view toggle, new-releases strip fitting, sticky sidebar management. |
theme.json | 93 | Block-theme config: content widths, colour palette, self-hosted fonts (Figtree, Chewy, Patrick Hand), template-part registration. |
assets/fonts/ · assets/img/ | - | Four self-hosted WOFF2 files (Figtree upright and italic, Chewy, Patrick Hand); the owl logo, the two wordmark images, the doodle images, the waves SVG, the email cover placeholder, the Australia flag-map (Curriculum card) and the About page photographs. |
reference/ holds the master logo artwork and the restore recipe for the plain "Est. 1987" header lockup - not deployed code. office-sync/ holds the PegiSync agent (.NET 8 Windows service that pushes office catalogue changes to the site, configured and monitored through its own local dashboard), its sync-database SQL and its own README - it runs at the office, not on this server. Not in the repository: WooCommerce, the eWAY gateway, WP Mail SMTP and User Switching (managed as normal plugin updates on the server), the Novamira development connector, uploaded media, cover image files, and generated PDFs.Section 6Database reference
Everything the build stores, beyond stock WordPress/WooCommerce. {prefix} is the WordPress table prefix.
6.1 Custom tables
{prefix}pegi_promo_items - promotion catalogue membership (owner: plugin promotions.php)
| Column | Type | Meaning |
|---|---|---|
promo_id | BIGINT UNSIGNED | Promotion post ID (PK part 1) |
product_id | BIGINT UNSIGNED | Product post ID (PK part 2) |
item_no | INT UNSIGNED | PDF catalogue item number |
Keys: PRIMARY (promo_id, product_id), KEY (promo_id, item_no), KEY (product_id). Deliberately not postmeta: range queries and catalogue-order sorting must be index-served at 138k products. Schema version in option pegi_promo_db_ver; installs via raw CREATE TABLE IF NOT EXISTS on init - a future column change needs an explicit ALTER path. Write only through pegi_promo_replace_items() (or bump pegi_count_ver after direct SQL).
{prefix}pegi_school_members - access: which logins may order for which customers (owner: plugin schools.php)
| Column | Type | Meaning |
|---|---|---|
user_id | BIGINT UNSIGNED | WordPress user - the login (PK part 1) |
school_id | BIGINT UNSIGNED | pegi_school post - the customer (PK part 2) |
role | VARCHAR(20) | school_accounts (labelled Accounts) or orderer (labelled Purchaser, default). pegi_school_roles() is the one source of labels and powers; slugs never change. |
added_at | DATETIME | When access was given |
Many-to-many: one login can order for several customers with a different role in each. Only rows for published customers count. Rows are removed with the login (deleted_user) and with the customer record. Installed via dbDelta(), version option pegi_school_db_ver. Nothing about people is stored beyond the login itself.
{prefix}pegi_office_accounts - every sbas customer, by number (owner: plugin office-trade.php)
Keyed by bcode (sbas Customer.BCODE): name, aged balances, standing order, last invoice, postal address. Refilled by every Accounts pass; the mirror step reads each row onto its customer record. The invoices and orders tables carry bcode too, which is how a customer record shows its documents and how a sign-up’s provisional record settles.
The invoice, not the order, is the financial source of truth - backorders and unavailable items mean they can legitimately differ.
{prefix}pegi_office_invoices, {prefix}pegi_office_orders, {prefix}pegi_office_accounts - trading data from the office (owner: plugin office-trade.php)
- Three tables behind one entity spec, written only by the office sync and read-only on the site.
- Invoices and orders keep their lines as JSON in a
line_itemscolumn, and hold ahashso an unchanged row costs nothing to re-send. - Accounts holds the balance, the aged buckets, the postal address,
dir_id(the school number the office picked),standing_orderandlast_invoice. When a row lands the customer’s website record is created or brought up to date — every sbas customer is a website customer. - Orders also carry
web_order_no, the web reference they were raised from. That is what puts the Web ref beside each order on the dashboard, and what lets a school reassignment made at the office follow through to the website order. - Installed via
dbDelta(), version optionpegi_trade_db_ver.
lines is a reserved word in MariaDB, so the line column is line_items. The original CREATE failed silently inside dbDelta() — the version option was set with two of three tables missing. If a custom table ever seems not to exist after a deploy, suspect a reserved word before anything else.Tables in the sync database (office SQL Server, not WordPress)
Beside the agent's own state lives the weborders inbox — web.orders and web.oitems, column-for-column the old website's dbo_orders / dbo_oitems that the office's WebTransfer already reads, filled by the agent (ids offset by 100,000,000; 90-day retention; card brand and the gateway’s transaction reference, never a number). Full DDL and column notes are in office-sync/inbox-schema.sql and the office-sync README.
{prefix}pegi_search_index - full-text search (owner: theme inc/archive.php)
| Column | Type | Meaning |
|---|---|---|
product_id | BIGINT UNSIGNED (PK) | The product this row indexes |
search_text | TEXT, FULLTEXT index | Title + author + illustrator + ISBN-13 + ISBN-10 + categories + series + book type. Deliberately not the description. (Named search_text because blob is a reserved word.) Also carries denormalised author_sort/illus_sort name copies (indexed) that the author/illustrator sorts and the A–Z jump strip read - the save hook's REPLACE must write every column, and a contributor rename refreshes their books' rows. |
Rebuilt per product on save. Its collation differs from core tables - add an explicit COLLATE when joining against postmeta in raw SQL.
6.2 Options (wp_options)
| Option | Owner | Stores |
|---|---|---|
pegi_status_registry | plugin | Status code -> label / explanation / orderable / badge. Staff-edited meaning of each stock code, on Settings → Wording. |
pegi_wording | plugin | Every staff-editable explanation, key -> text (Settings → Wording). A missing or emptied key falls back to the phrase shipped in the code, so the option only ever holds what has actually been changed. |
pegi_home_layout | theme | The homepage arrangement: {hero:[…], side:[…], main:[…]} of module IDs. User-curated - never reset programmatically. Missing zone keys fall back to defaults; explicitly emptied zones stay empty. |
pegi_count_ver | both | Integer cache-buster all listing/rail transients key on. Bumped on product save/delete and promotion item change. Must be bumped after any direct SQL into pegi_promo_items. |
pegi_promo_db_ver / pegi_school_db_ver / pegi_trade_db_ver / pegi_order_status_ver | plugin | Schema and migration versions gating custom-table installation and one-off data moves on init (deploys never re-fire activation hooks, so schema must not depend on them). |
pegi_blocked_logins | plugin | Blocked addresses and domains: value => {value, by, when, note}. Independent of any login, so a block outlives the account it was placed on. Managed under Users → Blocked addresses. |
pegi_author_of_month / pegi_book_of_month | theme | Contributor / product ID for the homepage month modules. No admin UI - set directly. |
pegi_notice | plugin | The store notice bar: message, on/off switch and inclusive date window, edited at Settings → Store notice. The dismissal id is derived from the message and dates, so an edited notice reappears for everyone. |
pegi_nav_seeded | theme | Marks the one-time seeding of the coded default navigation into an editable WordPress menu. |
pegi_email_copy_ver / pegi_email_brand_ver / pegi_email_new_order_line_cleared | plugin | Version gates that write the shop's closing lines and colours into WooCommerce's email settings once, so staff edits stick. |
pegi_term_janitor_last | plugin | The nightly term janitor's last-run report. |
pegi_map_age_groups · pegi_map_class_sets · pegi_map_status_families | plugin | The office-sync filing tables, saved from Settings → Office sync mappings. Each overrides the code defaults; delete one and the shipped behaviour returns. |
pegi_info_cards | theme | Wording for the four info cards (Curriculum, Standing Orders, Class Sets, Teacher's Notes) shown on the homepage and the promotions page. Edited under Appearance → Homepage layout. |
pegi_bob_image / pegi_bob_page / pegi_bob_page_done | plugin | The BOB (Box of Books) artwork, the page the homepage card and the emails point at, and the once-only flag that created it. |
pegi_office_api_keys | plugin | Office API credentials: key id -> shared secret (the one key is office). Set directly (not autoloaded, no admin UI); the API answers 503 until at least one key exists. Secrets never travel in requests - only HMAC signatures do. |
6.3 Post types & taxonomies
| Registered | Kind | Notes |
|---|---|---|
contributor | CPT, public | Authors/illustrators (name, bio, photo, _pegi_contributor_url). Permalinks 301 to the filtered shop listing. |
pegi_promotion | CPT, public | Campaign copy + meta; archive at /promotions/. Classic editor (the screen is a form). |
pegi_hero | CPT, private (UI only) | Homepage hero sections; title + meta only, rendered only through the hero zone. |
pegi_rail | CPT, private (UI only) | Staff-defined shelf rails (title = heading + filter/sort/window/max meta), rendered only through the homepage main zone. |
pegi_school | CPT, private (UI only), labelled Website customer | publish = a customer that can be ordered for. One record per sbas customer, created and kept current by the office sync and titled with the sbas name; plus provisional records made by sign-ups for schools sbas does not have yet (created at once from what was typed; settled by the office’s first order). A customer removed in sbas is retired to draft, never deleted. Cannot be deleted while it has open web orders. |
book_type, age_band, book_format, award | Taxonomies on product | Registered hierarchical for the checkbox UI. Only age_band carries real parent terms (the four age groups); everything else is flat. |
series | Taxonomy on product | Non-hierarchical (thousands of terms). |
product_cat | Woo core | The browsing tree. |
6.4 Post meta
Products (books)
| Key | Meaning |
|---|---|
_pegi_isbn / _sku | ISBN-13, the primary identifier - kept equal to the Woo SKU. |
_pegi_isbn10 | ISBN-10; cover filenames + search. Derived from ISBN-13 on save when blank. |
_pegi_rrp | RRP - every calculated price flows from it; saving also syncs the Woo regular price (keeps price sorting correct). Its presence marks a product as a book to the cart hook. |
_pegi_special_price | Office Price 3 — the price at any quantity when set, for everyone, standing-order customers included. |
_pegi_class_set | Office PROMO12 year-level code; labels via pegi_class_set_sections(). |
_pegi_title_sort | Library-rules sort key (punctuation and symbols removed, leading The/A/An dropped), written on every save. |
_pegi_promo1 … _pegi_promo20, _pegi_topten_raw | The office's twenty numbered promo columns and its unwindowed topten, synced as-is (non-empty values only); the catalogue importer reads them. |
_pegi_office_hash | The sync's fingerprint of the office row, for the unchanged short-circuit and the reconcile. |
_pegi_pubdate / _pegi_pubdate2 | Publication date (displayed) / release date (New Releases ordering + pre-release pricing). Stored Y-m-d; compared as strings, never CAST. |
_pegi_status_code / _pegi_status_raw | Registry code (import-owned) / raw back-office text shown as the banner title. |
_pegi_author_ids / _pegi_illustrator_ids (+ scalar _pegi_author_id / _pegi_illustrator_id) | Contributor links. The array is authoritative; the scalar mirror exists because serialized-array scans took 20s+ at 138k products - keep both in sync (the mirror is written import-side). |
_pegi_publisher, _pegi_qty_on_hand, _pegi_series_number, _pegi_teacher_notes, _pegi_class_set_pdf | Catalogue extras (publisher, quantity on hand, series position, teacher-notes URL, class-set PDF URL). The office's supplier is not synced — nothing on the site uses it. |
_pegi_highlight | Highlights number: position in the homepage "This Month's Highlights" rail (1 shows first; absent = not in the list). Owned by the office sync - office "topten" values 21–40 map to 1–20; the Book data panel field is overwritten on the next sync. |
_pegi_so_package, _pegi_so_option, _pegi_so_save, _pegi_so_early_bird, _pegi_so_months | On the hidden standing-order package products only. |
Promotions (pegi_promotion)
| Key | Meaning |
|---|---|
_pegi_state | live | archive - drives homepage/hero visibility. |
_pegi_tag / _pegi_one_liner | Kicker and subtitle used on cards, heroes and PDF covers. |
_pegi_hero_hide | 1 = live but kept off the homepage hero (blank = shown). The list order (menu_order, set by dragging) is what the hero and the promotion listings follow. |
_pegi_accent / _pegi_accent_2 | Gradient colours for cards/hero band. |
_pegi_hero_image_id | Landing header banner (featured image = card art). |
_pegi_promo_sections | JSON [{label, from, to}] - the PDF catalogue's named item-number ranges. |
_pegi_pdf_url | Public URL of the generated catalogue (set by Generate). |
_pegi_pdf_style, _pegi_pdf_cover, _pegi_pdf_cover_photo, _pegi_pdf_cover_photo_id, _pegi_pdf_cover_image_id | PDF style + cover per promotion: the style (blank = forest), the cover kind (blank = the style's own), the bundled photograph key or custom with the own-photo attachment, and the finished-artwork attachment. |
_pegi_pdf_notices | Sidebar notices as JSON [{heading, text, url}]; blank = none. |
_pegi_pdf_intro, _pegi_pdf_footer, _pegi_pdf_prices | Introduction, footer line (blank = the shop's name and web address), and which prices print (blank = RRP and School Price; rrp, school, none). |
_pegi_linked_term, _pegi_vanity_slug | Landing-page collection link (taxonomy:slug) for a promotion without items, and short-URL slug. |
_pegi_promo_src_col / _from / _to / _unique / _types / _age / _status / _renumber / _ran / _ran_n | The office-column importer's rules and last-import stamp. |
Hero sections, schools, contributors
| Post type | Keys |
|---|---|
pegi_hero | _pegi_hero_style (one of 9), wording overrides (_pegi_hero_kicker / _heading / _copy / _cta_label / _cta_url), _pegi_hero_lead_promo (which promotion leads), media (_pegi_hero_media_id / _media_link), colour/background overrides (_pegi_hero_bg / _bg_2 / _text / _accent / _bg_image_id). Blank values are deleted, not stored empty. |
pegi_rail | _pegi_rail_max (default 10, no upper cap; homepage rail only), _pegi_rail_sort (the listing's 11-option sort vocabulary), _pegi_rail_rel / _pegi_rail_pub (Released / Published windows in the canonical ?rel=/?pub= vocabulary), slug lists _pegi_rail_types / _age / _format / _award / _cats / _series (empty = no filter on that taxonomy). Sort and window values double as the listing's ?sort= / ?rel= / ?pub= values. |
pegi_school | _pegi_school_address / _suburb / _state / _postcode / _phone (the office's postal address wins for a linked customer); _pegi_school_so (standing-order subscriber -> flat 0.8×RRP); _pegi_bcode (the customer number, sbas Customer.BCODE — the key) + _pegi_sbas_customer + _pegi_link_source (the sbas customer this record is, written only by the sync); _pegi_office_last_invoice (for the dormant view); _pegi_provisional / _pegi_school_signed_up (a sign-up’s provisional record, by whom); _pegi_office_gone (removed in sbas, record retired to draft); _pegi_merged_signup (sign-ups settled into this customer). |
contributor | _pegi_contributor_url (external website). |
Orders & users
| Where | Keys |
|---|---|
| Order meta (checkout, then the sync and staff) | _pegi_order_type (school-invoice | retail), _pegi_school_id, _pegi_school_name, _pegi_ordered_by (the Your name box), _pegi_po_number, _pegi_order_notes, _pegi_delivery (post | pickup); _pegi_office_downloaded (retail orders, stamped by the inbox acknowledgement - account orders change status instead); _pegi_shop_orders ([shop order no => sbas flag]), _pegi_shop_order_gone, _pegi_moved_from (the shop-order link); _pegi_attention_left / _pegi_attention_left_reason / _pegi_closed_here (decisions made on the attention screen); order-item meta Month on standing-order PAYG lines. Card payment: _pegi_order_type = invoice-payment with _pegi_paid_invoices; _card_brand (brand only, never a number); _pegi_eway_access_code, _pegi_recovery_tries / _pegi_recovery_checked / _pegi_payment_recovered (the recovery sweep); _pegi_payment_applied / _pegi_payment_applied_by / _pegi_payment_applied_note (a receipt recorded as applied by hand on the attention screen). |
| User meta | _pegi_selected_school (the login's default customer; the live selection is the per-browser pegi_school session cookie, validated against live access on every read); _pegi_account_login (this login registered to order on account - with no access left it sees the not-attached card, not the retail shop); _pegi_last_login; _pegi_cart_touch (which browser last changed the shared basket, for the checkout warning); _pegi_saved_lists (saved baskets: [{name, created, items}]); legacy fallbacks _pegi_so_subscriber, _pegi_school_account, _pegi_org_name (overridden by real access rows). |
6.5 Transients & generated files
| Cache | Life | Purpose |
|---|---|---|
pegi_books_{hash} | 12 h | Homepage rail / module book-ID sets (warmed hourly by cron). |
pegi_cnt_{ver}_{hash} / pegi_ctxcnt_{ver}_{hash} | 6 / 12 h | Listing totals and type-chip counts; {ver} is pegi_count_ver, so one bump invalidates all. |
pegi_so_slots_{family}_v{ver} / pegi_so_month_{family}_{slot}_v{ver} | 12 h | Standing-order months that exist per package family, and one month's titles by position; {ver} is pegi_count_ver. |
pegi_about_favs | 1 day | The From James's Shelf covers on the About page, looked up by ISBN-10. |
pegi_cats_json | 12 h | Category list served by the REST route for the sidebar/mega menu. |
pegi_promo_import_{user} | 60 s | CSV import result carried to the next admin screen for the notice. |
pegi_attention_count | 5 min | How many web orders need attention - the menu badge and the admin notice; cleared on any order status change and whenever a shop order links. |
pegi_customer_delete_refused_{user} | 60 s | Carries the "this customer has open web orders" refusal to the notice after a blocked delete. |
uploads/catalogues/{slug}.pdf | until regenerated | The generated catalogue at a stable public URL. |
uploads/catalogues/.covers/*.jpg | mtime-gated | Flattened JPEG cache of every image placed in a PDF (FPDF rejects alpha PNGs). |
Section 7Endpoints & URL parameters
7.1 Public URL parameters (theme, on /shop/ unless noted)
| Parameter | Meaning |
|---|---|
?s= & post_type=product | Full-text search. |
?promo={id} (+ from/to) | Promotion catalogue listing, optionally filtered to an item-number range; defaults to catalogue-order sort with the catalogue numbers shown. |
?contrib={id} | All books by an author/illustrator. (Named contrib - contributor collides with the post type.) |
?rel= / ?pub= | Date windows over the release / publication date: tm (this month), lm, Nm (rolling 1–99 month span), YYYY-MM, or YYYY-MM..YYYY-MM (either side may be blank). The filter drawer submits {field}_tm / {field}_from / {field}_to instead; both spellings resolve identically. |
?view=list|tile | Opens the listing in that layout (server-painted, then persisted as the user's preference). Shelf-rail Show all / More links use view=list. |
?shelf= | Names the listing heading (kicker "Shelf") - shelf-rail Show-all links pass the rail's title so the page reads "New Picture Books", not "All Books". Any filter-changing link (chip removal, category chips, clear-all, the filter drawer) drops it, since the shelf name only describes the original filter set; the heading then falls back to the remaining filters. |
?highlights=1 | The curated Highlights list (books with a Highlights number), defaulting to the curated order. The Highlights rail's Show all. |
?class_sets=1 / ?class_set= | Class Sets: every class-set title, or one year level. Rendered like a promotion catalogue - hero band, year-level cards, then the ordinary listing. /class-sets/ redirects here. |
?type= | One book type, by its slug - the office's own precise type (Australian Novel, Middle Novel, General Information and so on). The chips build themselves from the types that exist, so there is no fixed set and no "Other" bucket. |
?sf= | Search scope from the header dropdown: title, author, cat, illus, series, isbn; blank = All fields. |
?type= · ?types[]/age[]/format[]/award[]/status[] · ?notes=1 · ?cats[]/ser[] | Type chips and the filter drawer; cats[] (categories) and ser[] (series - not series, which is that taxonomy's own query var) have no drawer checkboxes and arrive from shelf-rail Show-all links. |
?sort= | title(-desc), title-lib(-desc) (library rules - ignores punctuation and symbols entirely, and a leading The/A/An), author(-desc), illus(-desc), avail, newest, oldest, price(-desc) - all offered in the sort select; blank = context default (search Relevance, promo Catalogue order, date-windowed Newest, else Title A–Z). |
/standing-orders/titles/?list=&yr=&month= | Standing-order title lists: list = premium | secondary | popular | graphic, yr = year, month = feb … nov. Anything missing or impossible falls back to the newest month with titles. yr, never year — that is WordPress's own date-archive variable and 404s a page. |
?pegi_hero_preview={id} | Staff-only single hero-band preview (any post status). |
7.2 AJAX, REST & cron (theme)
| Endpoint | Purpose |
|---|---|
pegi_cart_qty / pegi_cart_remove / pegi_cart_empty / pegi_in_basket | Basket AJAX (nonce pegi-basket); pegi_in_basket powers the duplicate-add confirm. |
pegi_list_save / pegi_list_restore / pegi_list_delete | Saved lists (logged-in only). |
pegi_so_payg | Standing-order pay-as-you-go add with chosen month. |
GET /wp-json/pegi/v1/categories | Cached category list for the sidebar and mega menu. |
cron pegi_warm_home_caches | Hourly homepage cache warmer. |
admin-post pegi_contact | Contact form submission (public). |
7.3 Admin-post endpoints (plugin)
| Action | Access | Purpose |
|---|---|---|
pegi_school_apply | public + nonce + honeypot | Registration - the only unauthenticated plugin endpoint. Blocked addresses get the same neutral answer as everyone else. |
pegi_school_select | login with access | Selected-customer switch (a per-browser cookie plus a sticky default, so two people sharing a login can work different customers at once). |
pegi_school_invite / _remove / _role | staff | Give / remove / re-role access on the customer's wp-admin screen (the login's profile does the same through core's profile-update flow). |
pegi_block_login · pegi_blocklist_add / _remove | staff | Block or unblock a login from the Users list; add or remove an address or domain on Users → Blocked addresses. |
pegi_web_order_action | staff | The attention screen's decisions: give access, leave as is, close, reconsider, reopen - reasons required where a decision resolves a row without changing the facts. |
pegi_pay_invoices · pegi_cancel_invoice_payment | login with access | Pay the ticked invoices (amounts recomputed server-side, never trusted from the form); cancel a payment that was started and never reached the gateway. |
pegi_wording_save · pegi_save_notice · pegi_save_sync_mappings / pegi_refile_terms | staff | Settings → Wording (each tab merges), Settings → Store notice, Settings → Office sync mappings. |
pegi_promo_items_csv / pegi_promo_pdf_generate / pegi_duplicate | staff | Promotion items CSV export; catalogue PDF generation; the Duplicate row action. |
pegi_save_home_layout · pegi_save_info_cards (theme) | staff | Appearance → Homepage layout and its info-card wording. |
wp_ajax pegi_rail_terms | staff + nonce | Term search behind the Shelf rails Categories/Series autocomplete (whitelisted to those two taxonomies, top 20 by count). |
The plugin registers no shortcodes. Its two cron events are the nightly term janitor and the quarter-hourly payment-recovery sweep; its one AJAX handler is the authenticated term search above - every entry point is nonce-checked, and PDF generation is never automatic. Its REST routes are the HMAC-authenticated office API below; nothing public.
7.4 Office sync API (plugin REST, /wp-json/pegi/v1/office/)
| Endpoint | Purpose |
|---|---|
GET health | Connectivity and contract check the agent runs before anything else (contract number, plugin version, product count). |
POST products | Bulk upsert/delete of books, keyed by ISBN-13. Idempotent batches; office-deleted books are drafted, never deleted. |
POST contributors | Bulk upsert of author/illustrator records. |
POST invoices | Office invoices, with their lines. Rolling 24-month window; rows leaving the window arrive as deletes. |
POST orders | Office orders from every channel, with their lines. Same 24-month window. |
POST accounts | Every customer as an organisation, keyed by its number: name, balances, aged buckets, postal address, standing order, last invoice. The whole file, not windowed; each row becomes or updates a website customer record, found by the number. |
GET weborders | Orders for the office inbox: account orders that are Sent to shop, and retail orders once paid. Shaped as the old website's order tables. An order stays in the feed until acknowledged. |
POST weborders/ack | The agent confirms orders written to the inbox; an account order becomes With the shop, a retail order is stamped downloaded, and each gets an order note. |
GET fingerprints | Paged {key, hash} feed of office-synced records for the agent's weekly reconcile. |
Every request is signed: X-Pegi-Key / X-Pegi-Timestamp (±300 s) / X-Pegi-Nonce (replay-blocked) / X-Pegi-Signature (HMAC-SHA256 over method, path, timestamp, nonce and body hash, using the secret registered in pegi_office_api_keys). A failed signature never reaches a handler, and with no keys registered the whole API answers 503. Header-based signing passes cleanly through Cloudflare and does not look like a login attempt to security plugins; secrets never appear in URLs or request bodies.
Section 8Operations & environment
- Repository:
pegi-core,pegi-shelf, the PegiSync agent (office-sync/), this handbook and two reference files are tracked. WooCommerce, the eWAY gateway, WP Mail SMTP and User Switching update as normal plugins on the server. Uploaded media, cover files and generated PDFs are content, not code. - Deploys are self-verifying. A fatal error in the theme takes the whole site down - including the remote-management connection used to deploy - so every code install runs through a safe installer that backs up the files it is about to overwrite, checks the syntax of each one, copies them, then re-requests the home page and the API and puts the backup straight back if either is unhealthy. A bad deploy therefore cannot outlive the request that installed it. A companion error-logging drop-in records the real cause, which WordPress otherwise hides. Both are development tools and come out at go-live.
- Versioning: the theme version is bumped in
style.css's Version header (functions.phpreads it intoPEGI_SHELF_VERSION, which also cache-busts CSS/JS); the plugin version inpegi-core.php's header +PEGI_CORE_VERSION. Bump on every release. - Schema changes never rely on activation hooks. Deploys copy files without re-activating, so custom-table schema is version-gated on
init(optionspegi_promo_db_ver/pegi_school_db_ver/pegi_trade_db_ver). Follow the same pattern for any new table. - Documentation discipline:
pegi-core/README.mdis updated in the same commit as any contract change. Keep that habit - it is why this handbook could be written. - Security posture (verified live and in code): every form and AJAX handler is nonce-checked and capability- or ownership-gated; the office API is HMAC-SHA256 with a five-minute window and replay protection; every database query with a variable is prepared;
wp-config.phpis 0600; XML-RPC, directory listings and stray files are refused at the web server; registration is closed and application passwords are off.pegi-coreitself sends the security headers (HSTS,nosniff,X-Frame-Options,Referrer-Policy,Permissions-Policy) on every response and closes the/wp/v2/usersroutes to anonymous requests (includes/security-headers.php). Comments are off everywhere (includes/no-comments.php): nothing accepts one, product reviews included, and the comments routes and screens are gone. There is deliberately no Content-Security-Policy - WooCommerce's admin, the block editor and the gateway inline scripts freely. At go-live: deactivate and delete the Novamira remote-management plugin and its sandbox directory (it is a remote code-execution path by design - the deploy tool), turnWP_DEBUGoff andDISALLOW_FILE_EDITon, take eWAY out of test mode, delete the inactive plugins, and consider login rate-limiting or two-factor onwp-login.php. - Privacy policy. Published at
/privacy-policy/, linked from the bottom of every page, and set as both WordPress's and WooCommerce's privacy page so the checkout's privacy line points at it. It is written for the whole business - website, phone, email, in-shop and school accounts - as Pegi Williams Book Shop, ABN 76 048 485 891, withsales@pegiwilliams.com.auas the address for access, correction, unsubscribes and complaints. Three things in it are deliberately ahead of the build (client): it says the shop uses analytics and may use Google Ads and the Meta pixel, that shop news goes to customers who have bought from the shop as well as anyone who asks, and that the shop handles personal information in line with the Australian Privacy Principles. Check all three still describe the shop before go-live, and change the date at the top of the page whenever the policy changes. - Dev server quirks (pegiwilliams.it-tek.com.au): no WP-CLI (
proc_opendisabled); code deployed within a request loads on the next request; nginx page-caches signed-out requests for 60 minutes (proxy_cache_valid ... 60m, keyed on the URL alone, cached from the first request, and not purgeable from PHP) - cache-bust with a query string when verifying; the host hasmbstringbut noticonv(the PDF renderer's transliteration depends on this). - Wordfence is installed, in learning mode, and has no firewall rules until the free licence is registered at wordfence.com with the shop's email — do that first. It sees visitors' real addresses behind the hosting's proxy (checked), so a block can never catch everyone at once. The server's own address is allowlisted, and the development deploy tool's two endpoints are allowlisted against every rule — remove the deploy tool, and those entries, before go-live.
- HTTP basic auth gate: when the dev site's cPanel Directory Privacy gate is switched on, browsers attach an
Authorization: Basicheader (the site password) to every request, and WordPress core treats that header on REST calls as an application-password login, 401ing even public routes.pegi-core.phptherefore disables application passwords (wp_is_application_passwords_available→ false); nothing on the site uses them. - Big-catalogue rules of thumb: never attach 138k images to the media library; never
CAST()postmeta in ORDER BY/WHERE (kills the index - use the custom tables or lookup joins); never create per-product transients; keep the author-array/scalar meta mirrors in sync.
Section 9Known notes for future developers
Small, honest footnotes - nothing affects operation, but knowing them saves time.
- While the site is in development the footer carries a small build stamp - both package versions, the date the code last changed and a link to this handbook. The date comes from the files themselves, so it is never out of step. Remove the
wp:pegi/build-stampblock fromparts/footer.htmlat go-live — and switch off Settings → Email safety net (4.18) the same day. - Text size across the whole site is one number. Every font size in the theme is a rung on a shared ladder, and each rung is its base size times a single scale value (
--type-scaleinassets/css/pegi.css). The site currently runs at 1.15 - about 15% larger than the original - and setting it back to1restores the previous sizes exactly. A second value,--display-scale, lifts the big fluid hero headings more gently (1.08) because they already scale with the window. Anything new should use the nearest rung rather than a new size. Cover placeholder text and the large decorative year numerals are deliberately excluded - their sizes are tied to fixed box dimensions, not to reading text. - The theme version lives in
style.css's Version header -functions.phpreads it intoPEGI_SHELF_VERSION, so there is exactly one place to bump. - The header wordmark is the client's exact artwork as two images (
theme assets/img/pegi-wordmark-1/-2.png, transparent background, 32px tall) so the name wraps to two lines on narrow screens - no webfont matches the lettering; replace the PNGs to change it (the master artwork isreference/logo-master.png). Below 900px the header row never wraps (the name stacks instead of Account/Basket dropping); below 420px the basket goes icon-only. The tagline is italic. pegi_author_of_monthandpegi_book_of_monthhave no admin UI - they are set directly.- Say "PDF catalogue", never "printed catalogue" - the shop doesn't print them.
pegi_promo_itemsinstalls via rawCREATE TABLE IF NOT EXISTS(not dbDelta) - a future column change needs a version bump and an explicit ALTER path.- Open items at handover, all of them at the office end or awaiting a decision. Work to do in the office: install the PegiSync agent there; and repeat on the office's live
WebTransfer2003andPegiXPthe changes already made and proven on the local copies - relinkingdbo_orders/dbo_oitemsto the inbox, carryingWebOrderNoandContEmailthrough every order split, and the append query that names all 61 columns. The website, the agent and card payment are complete and live. Decisions needed: the four 2026 special catalogues the office re-numbered out of its own columns before the export (Blabey, Tintin, Australian Picture Books, Aboriginal - their lists survive nowhere the website can read, so they need a fresh office export or the ISBNs pasted into the promotion screen); whether customers should be able to filter Australian content across every book type rather than only through the office's own single type per book; and whetherCOMast.OrderStatusshould keep receiving a delivery address, which is the office's own long-standing convention.
Current as at commit d3cd5e5 (pegi-core 0.46.1, pegi-shelf 0.30.8), 13 September 2026. This handbook describes the system as it is today - it is maintained alongside the code (with pegi-core/README.md) whenever systems change, and is tracked in the repository as pegi-handbook.html.