Account pages
Sixteen views make up the account section: the dashboard, the seller’s listings, alerts, the inbox, a conversation, the profile form, the three settings pages, sign in, register, the two password-reset steps, a member’s public page, the slot a plugin’s account page renders into, and API access.
A theme that ships all sixteen never sees any of this. A theme that ships none of them used to produce blank pages: core asked for a file, found nothing, and printed an empty document.
Core now has a fallback page for every one of them.
What decides who renders
Section titled “What decides who renders”Per view, first hit wins:
- your theme ships the view: your file renders, unchanged. Nothing below this runs.
- your parent theme ships it: the parent’s file renders, with the parent’s asset URLs, exactly as it always has.
- you have chrome: your header and footer, with core’s page between them.
- otherwise: core’s own standalone page.
So adding a view to your theme takes the page back, at any time, with no migration. Deleting one hands it to core. There is no registration step and nothing to declare.
Step 3 is the interesting one: it is the same header, the same footer, the same typography and the same widgets as the rest of your site, wrapped around markup core owns. The whole theming job is CSS.
The class vocabulary
Section titled “The class vocabulary”Core’s markup carries the classes below. These names are a permanent contract,
the same promise as the osc_* helpers and the admin’s class names. You can
restyle them freely; they will not be renamed or removed.
Every rule core ships is scoped .oe-page .name, so match that specificity when
you override. A bare .oe-list-item {} loses to core’s .oe-page .oe-list-item {}.
Structure
Section titled “Structure”| Class | Wraps | You may assume |
|---|---|---|
.oe-page |
everything core renders | the outermost element; present on every fallback page |
.oe-doc |
the page’s column inside .oe-page |
core bounds and pads it; neutralise both if your own spine already does |
.oe-h1 |
the page heading | exactly one per page |
.oe-account |
an account page: content column then nav | two children, content first in source order |
.oe-account-main |
the content column of .oe-account |
the page’s own markup, nothing else |
.oe-account-nav |
the account section nav | a <nav> holding an <h2> and one <ul>; the current entry carries aria-current="page" |
.oe-form-page |
a page that is one form: sign in, register, reset | no nav beside it |
.oe-contact |
the contact page | holds .oe-contact-main and, when filled, .oe-contact-aside |
Records
Section titled “Records”| Class | Wraps | You may assume |
|---|---|---|
.oe-list |
a list of records: listings, alerts | a <ul>; no bullets, no padding |
.oe-list-item |
one record | an <li>; holds a thumb, an .oe-list-body and an .oe-price |
.oe-list-body |
the middle column of a record | holds the <h3> title and .oe-meta |
.oe-meta |
a record’s secondary line | date, status, category, row actions; wraps freely |
.oe-dashboard-intro |
the dashboard’s “Signed in as” line | a <p> |
.oe-dashboard-actions |
the dashboard’s two buttons under it | also carries .oe-actions |
.oe-dashboard-title |
the dashboard’s “Your latest listings” heading (“Your recent orders” for a shopper in shop mode) | an <h2> |
.oe-dashboard-more |
the dashboard’s “See all” link line | also carries .oe-muted |
.oe-bill |
a shopper’s order table on the dashboard, the same table as the orders page | holds one .oe-scroll and its <table> |
.oe-row-actions |
a record’s action links | also carries .oe-meta; .oe-danger-link marks the destructive one |
.oe-row-promote |
the paid-upgrades line under a listing | also carries .oe-row-actions |
.oe-row-primary |
the one action a row leads with, such as Publish on a listing waiting for payment | also carries .oe-btn and .oe-btn--primary; the action array has 'primary' => true |
.oe-link-btn |
a button that looks like a link | inside an .oe-inline-form |
.oe-inline-form |
a one-button form in a line of links | display: inline |
.oe-tabs |
a status filter over a list | a <nav> of links; the current one carries aria-current="page" |
.oe-thumb |
a record’s image | fixed 6/5 ratio; also on the placeholder |
.oe-thumb-empty |
the no-image placeholder | carries .oe-thumb too |
.oe-price |
a listing’s price | one already-formatted string, currency included |
.oe-badge |
a status flag | always paired with a word, never colour alone; modifiers paid pending failed cancelled refunded |
.oe-threads |
the inbox’s list of conversations | a <ul>; no bullets, no padding |
.oe-thread |
one conversation in the inbox | an <li> that also carries .oe-panel; holds .oe-thread-title and an .oe-meta line |
.oe-thread-title |
a conversation’s listing title | a heading; a link to the thread in the inbox |
.oe-thread-last |
the date of a conversation’s last message | a <span> in the .oe-meta line |
.oe-unread |
the unread count on a conversation | also carries .oe-badge; always paired with a word |
.oe-messages |
the messages of one conversation, oldest first | an <ol> carrying the data-osc-messages attributes the thread script reads |
.oe-message |
one message | an <li>; .is-mine for the viewer’s own (right), .is-held for one waiting for review |
.oe-message-body |
a message’s text | plain text, line breaks kept with white-space: pre-wrap |
.oe-message-meta |
who wrote a message and when | a <p> holding the name, a <time> and, for a held message, a badge |
.oe-compose |
the reply form under a conversation | a <form> with one textarea name="body" and a send button |
.oe-push |
the “Notify me on this device” switch on the messages pages, shown only while web push is on | a <form hidden> that push.js reveals once the browser supports push; holds one button and a note line |
.oe-msg-link |
the header link to the inbox from osc_messaging_link() |
an <a> outside .oe-page, so core ships no style for it; holds the theme’s icon, an .oe-msg-label and an .oe-unread badge |
.oe-msg-label |
the word “Messages” in .oe-msg-link |
hide it visually for an icon-only link; the link keeps its aria-label |
.oe-thread-actions |
the Block, Unblock and Report links under a conversation | holds the shared row actions and a <details> for the report form |
.oe-pager |
the paging strip | core’s paginator markup inside, or a Newer and an Older link on the credits pages |
.oe-empty |
an empty state | replaces the list, never sits beside it |
.oe-panel |
a bordered block grouping related content | also used by the credits pages and the checkout address picker |
.oe-panel__title |
a panel’s heading | an <h2>; also on .oe-dashboard-title and .oe-thread-title |
.oe-table |
a table of records, such as orders or credit history | a <table>; on the credits pages it sits in .oe-bill and stacks on a phone |
.oe-bill-howto |
how to pay a pending order, such as bank details | a <tr> under the order’s row, one cell across every column |
.oe-copy |
a value to copy by hand, such as an IBAN or a payment reference | holds an .oe-copy-value <code> and an .oe-copy-btn that stays hidden until the browser can copy |
.oe-callout |
a short notice that must be read, such as a payment deadline or the listing limit | a <p> or <div>; not dismissible |
.oe-limit-notice |
My listings’ one notice for the listing limit, the next listing’s price and paused bumps | a <div> of .oe-muted and .oe-meta lines; also carries .oe-callout when the seller cannot post for free |
.oe-muted |
secondary prose | a paragraph, not a control |
.oe-address |
one saved address on the addresses page | also carries .oe-panel; holds an <h2> (with an .oe-badge on the default), .oe-address-lines and the row actions |
.oe-address-lines |
a saved address on one line | a <p> |
| Class | Wraps | You may assume |
|---|---|---|
.oe-field |
a label and its control | one control, or a country/region pair |
.oe-label |
the field’s <label> |
for always matches a real control id |
.oe-input |
a control core renders itself | absent on controls UserForm renders (see below) |
.oe-hint |
help text under a field | bound with aria-describedby |
.oe-field-error |
why a field was refused | also carries .oe-hint; the control has aria-invalid |
.oe-profile-head |
the top of a public profile | holds the .oe-avatar, an .oe-meta line, and the owner’s Edit button |
.oe-dialog-close |
a dialog’s close button | carries data-osc-dialog-close |
.oe-contact-open |
the profile head’s Message button | a link to #oe-contact-dialog |
.oe-form-error |
why a contact form’s send failed, inside the form | role="alert"; the same text is also a flash message |
.oe-dialog |
a <dialog> core opens as a modal |
holds .oe-dialog-head and .oe-dialog-body; shows in place without JavaScript |
.oe-group |
a titled group of form fields | a <fieldset> with a <legend> |
.oe-grid |
fields laid out two to a row where there is room | inside an .oe-group |
.oe-avatar-field |
the profile picture and its upload control | holds the .oe-avatar and the file input |
.oe-avatar-empty |
the placeholder picture when the member has none | also carries .oe-avatar |
.oe-avatar |
the account holder’s current picture on the profile page | a square image; core sizes and rounds it |
.oe-danger |
the destructive block at the foot of a page | separated by a rule; holds a heading, a line of copy and one danger button |
.oe-check |
a checkbox and its label on one line | the <label> wraps the control |
.oe-actions |
a form’s buttons | the submit is first |
.oe-btn |
a button or a link acting as one | add .oe-secondary for the quiet one, .oe-btn-danger for the destructive one |
.oe-btn--primary |
the main button of a form or a block | also carries .oe-btn |
#error_list |
the register form’s client-side errors | core’s own validator fills it; empty until it has one, so style :not(:empty) |
Notices
Section titled “Notices”Core’s flash messages keep the class names they have always had:
flashmessage and flashmessage-{ok,error,warning,info}. Style those; there is
no second name for the same thing.
They carry role="status", or role="alert" on an error, so the message
announces itself with no JavaScript. Each message includes a dismiss link
(.flashmessage a.ico-close). Core loads its own small script
(osc-flash-dismiss) on any page that prints one, which makes the link work by
click and keyboard, so a theme needs no script of its own for it.
If your header already calls osc_show_flash_message(), core’s own call is a
no-op: whichever runs first prints the message, and there is no double render.
Controls UserForm renders
Section titled “Controls UserForm renders”UserForm::name_text() and its siblings emit a bare <input> with no class of
core’s own, because their names are core’s contract and a theme must not
hand-roll them. Reach them through the wrapper:
.oe-page .oe-field :is(input, select, textarea) { /* yours */ }Core’s own defaults for those controls are declared inside :where(), which
gives them zero specificity. So any rule you write wins, including a bare
input {}. That is deliberate: inside your theme these should look like your
fields, not like core’s.
A worked example
Section titled “A worked example”The whole of what makes these pages look native to a theme:
/* the account layout is the theme's existing two-column object */.oe-page .oe-account { display: grid; grid-template-columns: 1fr 18rem; gap: 2rem 3.5rem; }/* the nav is the theme's existing facet column */.oe-page .oe-account-nav > h2 { text-transform: uppercase; letter-spacing: .07em; }.oe-page .oe-account-nav [aria-current] { background: var(--tint); font-weight: 600; }/* a listing row is the theme's existing record */.oe-page .oe-list-item { display: grid; grid-template-columns: auto 1fr auto; }/* core bounds its own column; the theme's spine already does */.oe-page .oe-doc { max-inline-size: none; margin: 0; padding: 0; }Rendering one yourself
Section titled “Rendering one yourself”osc_gui_account_view(string $themeView): bool runs the resolution above for
one view name and returns false when core has no page for it:
if (!osc_gui_account_view('user-login.php')) { // core owns no fallback for this one}Controllers call it; a plugin serving its own account route can too.
Sending a visitor back after sign-in
Section titled “Sending a visitor back after sign-in”osc_user_login_url($return) and osc_register_account_url($return) take a page on this site,
as a URL or a path, and add it as return. After signing in or registering the visitor lands
there. Only a path under the site’s base URL is kept; another host, //host, a scheme or a
backslash is dropped. A theme with its own sign-in or register form fires user_login_form or
user_register_form inside the form, which prints the hidden return field.
<a href="<?php echo osc_esc_html(osc_user_login_url(osc_route_url('my-checkout'))); ?>">Sign in</a>With e-mail validation on, a new account lands on the home page after it is validated.
What core’s pages do not do
Section titled “What core’s pages do not do”- Almost no JavaScript. The profile form calls
UserForm::location_javascript()so the region list follows the country without a reload. A listing list prints one line that asks before a link markeddata-osc-confirmis followed. Both pages work without them. - No assets. One small stylesheet, printed inline once per request through
the
headerhook. Nothing to enqueue, nothing to cache-bust. - No layout opinions you cannot undo. Every rule is one class deep.
Extending the account nav
Section titled “Extending the account nav”The nav is built from the same user_menu_filter list osc_private_user_menu()
uses, and fires the user_menu hook, so a plugin that already adds an account
entry appears in it with no change. Core’s own credits links arrive that way.
osc_add_filter('user_menu_filter', function ($options) { $options[] = array( 'name' => __('My orders', 'my-plugin'), 'url' => osc_route_url('my-orders'), 'class' => 'opt_my_orders', );
return $options;});The opt_logout entry is always moved last, whatever the filter returns.
A theme that draws its own nav reads the same list from osc_account_nav_items(): the
filtered entries, log out last, each with a current flag for the page being shown.
In shop mode core drops the seller entries from a shopper’s menu: opt_items,
opt_publicprofile, opt_billing_wallet, opt_billing_buy, opt_api_access and
opt_messages, and adds opt_billing_orders (Your orders) when billing is on. A shopper
keeps opt_alerts: the alerts page lists only the products they follow, each with its
product. It does
this in one user_menu_filter callback at priority 20, so plugin entries stay. In every
mode an entry whose class is already in the list is dropped, so two sources of one entry
show once. osc_is_shopper() is true for a signed-in user with no admin session in shop
mode; use it to guard hard-coded links in your own account chrome. A seller page a shopper
opens redirects to the dashboard.
Adding to a page
Section titled “Adding to a page”Every account page fires two actions inside its content column. The argument is
the page: user-dashboard, user-items, user-alerts, user-profile,
user-signin, user-custom, user-api_access, user-messages, user-conversation, user-delete_account, and on the credits pages
billing-wallet, billing-buy, billing-orders or billing-publish.
osc_add_hook('account_page_before', function ($page) { if ($page === 'user-items') { echo '<p class="oe-muted">Listings renew for free for 30 days.</p>'; }});account_page_after is the same, at the foot of the column.
The profile form
Section titled “The profile form”Core’s profile form has the account type (b_company), the neighbourhood
(cityArea) and an About field for each language. Other languages sit in a
<details> under the current one. The form is grouped in <fieldset class="oe-group">s: Photo, Your details,
Contact, Location and About you. The Location group carries data-location-cascade,
so a theme script can find the address fields.
user_avatar_form($user) fires after the picture field and its Remove box. A
“Your data” panel links to osc_user_export_url().
Social sign-in
Section titled “Social sign-in”A sign-in plugin makes the account with AccountService::registerVerified($form, $actor). It
takes the same form as a sign-up, without the passwords, and makes an active account with no
activation mail. Pass only the e-mail from the provider’s verified claim: the account takes the
guest listings posted with it. A taken e-mail is refused.
AvatarUpload::store($userId, $path) saves an image file already on disk, such as the provider’s
profile picture, as the user’s avatar. It returns false when avatars are off or the file is not a
usable image.
The public profile contact form
Section titled “The public profile contact form”The public profile has a contact form, unless the visitor owns the profile, or
only registered users may write and the visitor is signed out; then the head shows
a “Sign in to message” link. It posts page=user&action=contact_post with
yourName, yourEmail, phoneNumber and message. After a failed send,
osc_gui_kept() gives back what was typed.
The form sits in <dialog id="oe-contact-dialog" class="oe-dialog">, with
.oe-dialog-head (the <h2> and a close button marked data-osc-dialog-close)
and .oe-dialog-body. The .oe-contact-open button in the profile head opens it
through data-osc-dialog-open="oe-contact-dialog". The dialog opens by itself after
a failed send, with the reason in .oe-form-error at the top of the form. Without
JavaScript it shows in place and the button jumps to it.
user_contact_form($user) fires inside the form, before the button, and
user_contact_form_after($user) after it. Sends are limited to 15 an hour per visitor. The
action_throttle_limit filter changes it for 'user_contact'.
Changing the listing rows
Section titled “Changing the listing rows”Listing lists pass a context: dashboard, user_items, public_profile or
alert. Three filters change a row; each gets the list, the item and the context.
| Filter | Entries |
|---|---|
listing_row_badges |
['label' => …, 'class' => …]; the class is an .oe-badge modifier. Core’s keys: status, premium, highlight, urgent. A listing waiting for payment gets the status “Waiting for payment” on user_items |
listing_row_meta |
['text' => …], plus 'url' for a link or 'datetime' for a <time>. Core’s keys: category, date, views |
listing_row_actions |
['label' => …, 'url' => …, 'class' => …, 'confirm' => …]. Add 'method' => 'post' and 'fields' => [name => value] for a button in a form with the CSRF token. 'group' => 'promote' puts it on the paid-upgrades line. Core’s keys: edit, delete, and on user_items only upgrade_<feature> for each upgrade the owner can buy, or publish for a listing waiting for payment |
The My listings tabs are All, Published, Awaiting validation and Expired, plus Waiting for payment (itemType=unpaid) when pay-at-post is on or the seller has one. Core escapes every value. Add a Renew link on expired listings:
osc_add_filter('listing_row_actions', function ($actions, $item, $context) { if ($context === 'user_items' && osc_item_is_expired()) { $actions['renew'] = array( 'label' => __('Renew', 'my-theme'), 'url' => my_renew_url(), 'method' => 'post', 'fields' => array('id' => $item['pk_i_id']), ); }
return $actions;});Each saved alert has its own actions through alert_row_actions($actions, $alert),
in the same entry shape. Core’s key is unsubscribe. To show an alert yourself,
osc_alert_criteria() gives its search as ['label', 'value'] parts and
osc_alert_summary() gives it as one line.
osc_item_upgrade_offers($item) gives the upgrades as ['feature', 'label', 'credits']
data, for a list you draw yourself. osc_account_upgrade_offers() gives the account
upgrades (extra slots, photos, runtime, no wait) as ['feature', 'label', 'detail', 'credits'];
core’s Credits page lists them with a POST button to osc_account_upgrade_url($feature). A theme
with its own user-billing-wallet.php should show them too. A plugin adds its own through the
account_upgrade_offers filter, which gets the list and the user id (or null) and must return
the list; only a registered account-scoped feature can be bought this way. Leave out an offer the
seller already holds when buying it again would only stack it. osc_gui_listing_list($context, $owned) prints
core’s list for the current items loop, filters included.
To draw the whole list yourself, return a string from listing_list_html. It
gets null, the item rows and the context; anything but a string keeps core’s list.
Rendering your own page in the theme’s chrome
Section titled “Rendering your own page in the theme’s chrome”The helper core uses for these pages is public, so a plugin can put its own page inside the active theme without shipping a view for every theme in existence:
osc_gui_view( 'my-plugin-page.php', // the theme's own view, if it ships one PLUGINS_PATH . 'my-plugin/page.php', // your markup, used when it does not array('heading' => __('My page'), 'title' => __('My page')));Resolution is the same three steps core uses: the active theme’s view if it has
one, otherwise your file inside the theme’s chrome, otherwise core’s own shell.
Your file is markup only: no <html>, no header, no footer.
Inside it, the helpers core’s own pages use print the classes above for you.
They escape every string they are given except $input, which is printed as given.
| Helper | Prints |
|---|---|
osc_gui_field(string $for, string $label, string|Closure $input, string $hint = '', string $error = '', string $class = '') |
An .oe-field row: <label class="oe-label" for="{for}">, then $input (a string, or a Closure that echoes the control), then the hint as .oe-hint#{for}-hint and the error as .oe-hint.oe-field-error#{for}-err when given. $class adds classes to the row. |
osc_gui_panel_open(string $title = '', string $class = '', string $id = '', string $tag = 'section') |
Opens an .oe-panel, with an <h2 class="oe-panel__title"> when $title is given. $tag is section, div or li; anything else is section. |
osc_gui_panel_close(string $tag = 'section') |
Closes it. Pass the same $tag you opened with. |
osc_gui_empty(string $message, string $class = '') |
A <p class="oe-empty"> line. |
osc_gui_actions(array $buttons, string $class = '') |
An .oe-actions row. Each button: label; href (a link; without it a submit button); style, one of primary, secondary, danger or link, primary by default; class (extra classes); attrs (name => value). A javascript: or other non-web href is dropped. |
Two parts take an array and print a row:
$pager = array('prev' => $newerUrl, 'next' => $olderUrl, 'class' => 'my-pager'); // '' hides a linkrequire ABS_PATH . 'oc-includes/osclass/gui/parts/pager.php'; // .oe-pager, Newer / Older
$tabs = array( 'label' => __('Orders'), // for screen readers 'items' => array('open' => array(__('Open'), $openUrl), 'done' => array(__('Done'), $doneUrl)), 'current' => 'open', // gets aria-current="page");require ABS_PATH . 'oc-includes/osclass/gui/parts/tabs.php'; // nav.oe-tabsThe API access page
Section titled “The API access page”user-api_access.php lists the apps signed in to the account through the
REST API and the personal keys the member made. The
member can end any of them, and make a key when the site allows it.
| Fallback file | oc-includes/osclass/gui/account/user-api_access-content.php |
| URL | osc_user_api_access_url() |
| Nav entry | “API access”, class opt_api_access. Shown only when the API is on and the member has a sign-in or key, or the site lets members make keys. |
| Redirects | To the dashboard when the API is switched off. |
A theme that ships its own user-api_access.php gets these view variables:
| Variable | Holds |
|---|---|
api_sessions |
A list of sign-ins and keys, newest first. Each has id, type (token or key), label, prefix (keys only), scopes, created_at, last_used_at, last_ip and expires_at. Times are Unix times or null. |
api_user_keys |
true when members may make keys. |
api_key_scopes |
Scope name to description, for the make-a-key form. Empty when keys are off. |
api_new_key |
The key just made, as text. Empty on every other request. It is shown once. |
Every form posts page=user, action=api_access_post and the CSRF token from
osc_csrf_token_form(). The do field is end (with session set to the session id)
or create (with name, expires as YYYY-MM-DD, scopes[] and password).
The page reuses .oe-panel, .oe-badge, .oe-group, .oe-field, .oe-check and
.oe-link-btn. The two page hooks fire as on the other account pages.
The addresses page
Section titled “The addresses page”user-addresses.php is the member’s address book: saved delivery addresses, the default first,
with Edit, Make default and Delete on each, and a form to add one. The default address is the
account’s own address. At most 10 are kept.
| Fallback file | oc-includes/osclass/gui/account/user-addresses-content.php |
| URL | osc_user_addresses_url() |
| Nav entry | “Addresses”, class opt_addresses. Shown in shop mode only (osc_address_book_enabled()); elsewhere the page is a 404. |
Address formats
Section titled “Address formats”Each country has its own rules in mindstellar\user\AddressFormat: which of line1, line2,
city, state and postcode show, in what order, which are required, the state and postcode
labels, and a postcode pattern, for about 250 countries. Every country has a city field, optional
where its post does not use one. name, phone and country are always required; an unknown
country gets state and postcode as optional fields. The data is generated into
AddressFormatData by tools/gen-address-formats.php; Shopclass’s own changes go in
AddressFormat::OVERRIDES. The server applies these rules on
the account page, at checkout and in the API. A phone is an optional +, then 4 to 15 digits with
spaces and - . ( ) / allowed.
osc_address_formats() returns the map for the site’s countries, labels translated.
osc_address_form_script() prints it once as JSON (#osc-address-formats) with
address-form.js, which reorders, hides, relabels and checks the fields when the country changes.
Mark the form data-address-form and each field’s wrapper data-address-field="<name>"; fields
are found by name. The page works without the script.
The script also fills the State field from the shop’s own regions for the chosen country: a list
when the shop has regions for it, else a text box. A saved name the list lacks stays as its own
option. When the shop has one country, the form shows no Country box and sends that country. On
each country change the form fires osc:address-country (it bubbles) with detail.country,
detail.format and detail.regions, so a theme can build its own widget and only style it.
Product weights are stored in grams. osc_weight_unit() is the shop’s unit (metric or
imperial, Settings → Billing → Shop settings); osc_weight_display(), osc_weight_input() and
osc_weight_parse() turn grams into the shop’s unit for a page or a form, and back.
A theme that ships its own user-addresses.php gets these view variables:
| Variable | Holds |
|---|---|
addresses |
The book, default first. Each has id, label, name, phone, line1, city, state, postcode, country (a two-letter code) and default. |
address_form |
The address being edited (from ?edit=<id>), or the fields just posted back after a refusal. Empty for a new one. |
address_errors |
Field name to message, after a refusal. |
Every form posts page=user, action=addresses_post and the CSRF token. The do field is
save (with id empty for a new address, the fields above and default=1 to make it the
default), default or delete (each with id). osc_address_text($address) prints an address
on one line.
At checkout
Section titled “At checkout”osc_address_picker() prints the saved addresses as radio cards (.osc-address-picker,
.osc-address-card, .osc-address-new, .osc-address-default), the default chosen, and “Use a
new address”. It prints nothing for a buyer with no saved address. Wrap your address fields in an
element with data-address-form: the picker fills fields named name, phone, line1, city,
state, postcode and country from the chosen address, hides the box while a saved one is
chosen, and fires osc:address with {id, address} on every change. osc_address_save_field()
prints the ticked “Save this address” box (.osc-address-save) for a typed address. Send the
chosen id as address.id to POST account/orders, or the typed fields with address.save.
The messages pages
Section titled “The messages pages”user-messages.php is the inbox and user-conversation.php one conversation between a buyer and
a seller. Both need the on-site messaging switch (Users > Settings); with it off, the pages
redirect to the dashboard.
| Fallback files | oc-includes/osclass/gui/account/user-messages-content.php, user-conversation-content.php |
| URLs | osc_messaging_inbox_url(), osc_messaging_thread_url($id) |
| Nav entry | “Messages”, class opt_messages, with the unread count while there is one |
user-messages.php gets these view variables:
| Variable | Holds |
|---|---|
conversations |
Up to 20 conversations, newest first. Each is the stored row plus title, other_name, side, unread and url. |
older |
The link to the next page, or null. |
user-conversation.php gets:
| Variable | Holds |
|---|---|
conversation |
The stored row. s_state is open, held, reported or closed. |
messages |
Messages oldest first. Each has pk_i_id, fk_i_sender_id, s_body (plain text, escape it), s_state and dt_created. The viewer’s own held messages are in the list; the other side’s are not. |
names |
User id to display name. |
other_name, other_initial |
The other person’s display name and its first letter. |
seen_at |
Index in messages of the message that shows “Seen”, or -1. |
title, item_url |
The listing’s title and link; item_url is null once the listing is gone. |
can_send |
'' when the viewer may write, else blocked, closed or not_party. |
my_block, their_block |
Who blocked the conversation. |
The reply form posts page=messages, action=send_post, id and body. Block and unblock post
block_post and unblock_post with id; a report posts report_post with id and a reason
of spam, scam, offensive or other. All carry the CSRF token. Core’s script
oc-includes/assets/osclass/messages.js sends the reply without a page load and adds it to the
.oe-messages list; a theme that wants the same loads it and keeps the data-osc-messages
attributes. It exposes window.oscMessages.appendMessage(row).
The messenger markup
Section titled “The messenger markup”Both pages print one .oe-messenger block: the list pane (parts/messenger-list.php) and the chat
pane (parts/messenger-chat.php). A conversation page adds .is-chat to .oe-messenger, so a
phone shows only the chat; an inbox with no conversations adds .is-empty. The <body> carries
user and messages on both pages, plus messages-chat on a conversation. Style these; do not
copy the markup.
| Class | What it is |
|---|---|
.oe-messenger |
the whole two-pane block |
.oe-messenger-list |
the left pane: head, filter and conversations |
.oe-messenger-chat |
the right pane: one conversation |
.oe-chat-empty |
the right pane on the inbox when no conversation is open |
The list pane:
| Class | What it is |
|---|---|
.oe-list-head |
the pane’s top: the “Messages” heading, the bell, the filter and the tabs |
.oe-list-filter |
the search box; hidden until messages.js runs |
.oe-list-filter-icon |
the magnifier inside the search box |
.oe-list-tabs |
the All and Unread buttons; hidden until messages.js runs |
.oe-list-tabs-count |
the number of unread conversations on the Unread button; hidden at none |
.oe-thread-link |
the link that wraps a whole row |
.oe-thread-photo |
the listing photo in a row; an empty <span> when there is none |
.oe-thread-initial |
the other person’s first letter; also in the chat head; --oe-hue on it is the avatar hue |
.oe-thread-name |
the other person’s name in a row |
.oe-thread-time |
the time of the last message |
.oe-thread-preview |
the last message, cut short; starts with “You: “ when it is yours |
.oe-thread-flag |
a state word on a row: Closed, Reported, Awaiting review, Blocked or Removed |
The chat pane:
| Class | What it is |
|---|---|
.oe-chat-head |
the top bar of a conversation |
.oe-chat-back |
the link back to the list; shown on phones |
.oe-chat-name |
the other person’s name, the pane’s heading |
.oe-chat-role |
“Buyer” or “Seller” under the name |
.oe-icon-btn |
a round icon-only button; the menu toggle |
.oe-menu |
the options menu: a <details> |
.oe-menu-panel |
the open menu; also carries .oe-thread-actions |
.oe-menu-item |
one menu entry: Notify, Block, Unblock or Report |
.oe-menu-note |
why Notify is missing when the browser blocks notifications; hidden otherwise |
.oe-menu-report |
the Report entry; opens the report form |
.oe-listing-card |
the listing strip under the head; .is-removed once the listing is gone |
.oe-listing-photo |
the listing photo |
.oe-listing-title |
the listing title, a link while the listing exists |
.oe-listing-price |
the listing price |
.oe-listing-link |
the “View listing” link |
.oe-day |
a date line between messages |
.oe-held |
the “Awaiting review” tag on your own held message |
.oe-seen |
the “Seen” tag under your last message, once they have read it |
.oe-typing |
the typing indicator after the message list; hidden until a note arrives, and printed only while live delivery is on |
.oe-typing-bubble |
the dots inside .oe-typing |
.oe-stopbar |
the line that replaces the composer when nobody can write |
.oe-compose-row |
the textarea and the send button |
.oe-compose-send |
the icon-only send button |
.oe-compose-foot |
the line under the composer |
.oe-compose-hint |
the “Enter to send” hint |
.oe-compose-counter |
the characters left; filled by messages.js |
.oe-sr-only |
text for screen readers only |
.oe-message also gets .is-first and .is-last for the first and last message of a run by one
person (same sender, same day, within ten minutes). The meta line shows only under the last one.
Besides send_post, block_post, unblock_post and report_post, messages.js calls these AJAX
actions: messages_send, messages_since, messages_check and messages_typing. messages_typing
is a POST with id and the CSRF token; it tells the other person you are typing, answers 204 and
does nothing while live delivery is off. The page prints data-typing-url on .oe-messages only
when live delivery is on.
Both messages pages print the partial oc-includes/osclass/gui/account/parts/push-optin.php: a
<form class="oe-push" data-osc-push hidden> that prints nothing while osc_push_enabled() is
false. Core’s oc-includes/assets/osclass/push.js reads its attributes, reveals it when the
browser supports push, and toggles the subscription. A theme styles it and does not copy it.
push.js reads these:
| Attribute | Holds |
|---|---|
data-key |
The public key, from osc_push_public_key() |
data-worker-url |
The service worker script (push_worker) |
data-subscribe-url, data-unsubscribe-url, data-status-url |
The push_subscribe, push_unsubscribe and push_status AJAX actions |
data-inbox-url |
Where a click on the notification goes |
data-on-label, data-off-label, data-on-note, data-blocked-note, data-error-note |
The texts, already translated |
The form holds one <button type="submit"> and a [data-osc-push-note] line, and carries the CSRF
token. The worker is oc-includes/assets/osclass/push-worker.js, served by PHP so it can be
switched off.
The pages reuse .oe-panel, .oe-badge, .oe-field, .oe-check, .oe-actions and .oe-link-btn.
The two page hooks fire as on the other account pages.
Messaging in a theme
Section titled “Messaging in a theme”Core owns the messaging markup and scripts; a theme places them and styles the .oe-* classes,
light and dark, phone and desktop. A theme never writes its own messaging form, link, badge or
script.
- The pages. Ship no
user-messages.phporuser-conversation.php. Core’s partials render inside your chrome. - The header link.
osc_messaging_link($args)returns the link to the inbox, or''while messaging is off, for a guest and for a shopper.$args['icon']is your icon’s HTML, printed as given;$args['class']adds classes. The markup isa.oe-msg-link[data-osc-messages-link] > span.oe-msg-label + span.oe-badge.oe-unread[data-osc-unread]. The badge hashiddenat 0, so keep[hidden]hidden if you setdisplayon it. - The live count. The first
osc_messaging_link()on a page loadsmessages.js, which sets every[data-osc-unread]badge and the link’saria-labelafter each check, on every page. Do not add a script for it. - The contact form. Call
osc_item_contact_form(). When the listing uses messaging it shows only the message, the captcha and the button, with a “You are writing as” line above. Otherwise it is the e-mail form.
Messaging needs core 7.1. To keep a theme working on 7.0, check function_exists() once, in one
helper in functions.php, and call that helper from your views:
function mytheme_messaging_link(array $args = array()): string{ return function_exists('osc_messaging_link') ? osc_messaging_link($args) : '';}Pass the contact form’s option as
function_exists('osc_item_uses_messaging') && osc_item_uses_messaging(). A 7.0 core ignores
the key and prints the e-mail form.
The pay step
Section titled “The pay step”When a seller posts over the listing limit and the site takes payment at the end of posting,
the listing is saved hidden and the seller lands on its pay step,
osc_billing_publish_url($itemId) (page=billing&action=publish&itemId=N). Only the owner can
open it; anyone else goes to their own listings.
| Theme view | user-billing-publish.php |
| Fallback file | oc-includes/osclass/gui/billing/publish-content.php, render target billing/publish |
| Redirects | To My listings with a flash when the listing is not the seller’s. |
A theme that ships its own view gets these view variables:
| Variable | Holds |
|---|---|
item |
id, title, and url once the listing is live |
state |
step, quote, order and failed. step is pay, waiting (an order is pending), failed (paid, but not published yet), published or closed. quote holds credits, balance and shortfall. |
packages |
Enabled packages that cover the shortfall, smallest first |
gateways |
Payment methods, id to gateway |
order, checkoutHtml |
After a gateway that shows its instructions in the page. Print checkoutHtml as is. |
Forms post to osc_base_url(true) with page=billing, action=publish, itemId, the CSRF
token and do: credits (spend the balance and publish) or order (with packageId and
gateway). The price always comes from the server. Delete is the listing’s own delete
(page=item&action=item_delete&id=N).
The page adds .oe-bill-publish, .oe-bill-publish-card, .oe-bill-publish-status,
.oe-bill-publish-more, .oe-bill-amount (with .oe-bill-amount-label and the
.oe-bill-amount-figure headline), .oe-bill-deadline (an .oe-callout) and
.oe-bill-delete-form with its .oe-bill-delete link to the billing classes. Its package form is
gui/billing/parts/package-picker.php, the same one the Buy credits page uses.
An admin sees the same listing as Waiting for payment and can publish it free. A reminder e-mail goes to the owner a day before the hold ends.
One page, three routes
Section titled “One page, three routes”user-change_email.php, user-change_username.php and user-change_password.php all
resolve to a single Sign-in details page. Each keeps its own form and its own POST
action, so nothing about the controllers changed; the route that was asked for gets
autofocus on its field, so an old link still lands where it used to.
A theme that ships any one of those three views still wins for that route, exactly as before: the consolidation is core’s fallback shape, not a rule imposed on themes.
Deleting an account stays on its own page. It is destructive and irreversible, and nothing dangerous should sit a misclick away from changing an email address.
Pages outside the account section
Section titled “Pages outside the account section”osc_gui_page_view() does the same job for four pages that are not account pages:
| View | What it is |
|---|---|
custom.php |
the mount point a plugin’s page renders into |
contact.php |
writing to whoever runs the site |
item-contact.php |
writing to a seller about one listing |
item-send-friend.php |
passing a listing on to someone else |
contact-message.php is the page the links in message mail open: Send my message for a
held message, and Report the sender. Core’s fallback is gui/contact-message-content.php;
it reads message_mode (confirm or report), message_done and message_token.
The contact page, the seller contact page and the public profile draw one shared
form. After a failed send, each shows the reason in .oe-form-error and refills what
was typed. A plugin handling its own contact-style form can do the same with
osc_keep_form($values, $error) before it redirects back.
The contact page and the share form fire contact_form and then
admin_contact_form inside the form. The seller contact form fires
item_contact_form with the item id. A theme that puts the seller form in its own
layout, such as a dialog on the item page, calls osc_item_contact_form(): it prints
the e-mail form, or only the message field when the listing uses messaging. Pass
prefix for the field ids or hint for the text under the e-mail field. The contact page also fires contact_form_top before the
Subject field and contact_form_after below the form.
The contact page is <div class="oe-contact">, holding .oe-contact-main with the
form. Whatever contact_page_aside prints goes in an <aside class="oe-contact-aside">
beside it, for an address or links; the aside is left out when nothing prints.
Ship either view and yours wins, exactly as with the account pages. The
save-this-search field osc_alert_form() prints falls back the same way, so a
theme that calls it without shipping alert-form.php gets core’s field rather
than nothing. Core’s form carries data-osc-alert-form, so a theme script can find it
without depending on its class.