Kaupang Review Images
Review image and avatar uploads for WooCommerce product reviews — customers attach a product photo and a profile picture to their review, stored as ordinary media attachments; conditional Gravatar loading keeps default avatars off the page. Part of the Kaupang suite.
by Lasse Jellum · github.com/nytafar/kaupang-review-images · website
Install
No release zip yet. The repository archive installs, but the folder name will carry the branch suffix and updates will not flow:
wp plugin install https://github.com/nytafar/kaupang-review-images/archive/refs/heads/main.zipReadme
Kaupang Review Images
Customers attach a product photo and a profile picture to their WooCommerce review; both are stored as ordinary media-library attachments referenced from comment meta. Conditional Gravatar loading keeps default mystery-person avatars off the page. A Curated Review block lets an editor place one hand-picked review anywhere on the site.
Part of the Kaupang suite: Kaupang\ReviewImages\, KAUPANG_REVIEW_IMAGES_*, Composer-less
PSR-4, slash-namespaced kaupang/review-images/* seams.
Features
- Review image uploads — one photo per review, 2 MB ceiling
- Custom avatar uploads — takes precedence over Gravatar, 1 MB ceiling
- Conditional Gravatar loading — a review author with no real Gravatar and no upload gets
no
<img>at all, rather than the default silhouette - Retina avatars — base size plus a
2xsrcset, for both custom uploads and real Gravatars - Curated Review block — place one specific review anywhere; the theme owns presentation
- Admin surface — an "Image" column on the Comments screen plus review-image and avatar meta boxes on the comment-edit screen
- JPEG, PNG, GIF and WebP, nonce- and MIME-guarded on upload
- HPOS-compatible (declared; the plugin never touches order data)
Requirements
- WordPress 6.7 or higher
- WooCommerce 9.0 or higher (tested up to 11.0)
- PHP 8.1 or higher
There are no settings, no options, no admin menu, no REST routes and no cron. Everything is configured through the filters below.
Installation
- Upload the
kaupang-review-imagesfolder to/wp-content/plugins/ - Activate through the Plugins menu
- The upload fields appear in the product review form automatically
block/build/ is committed, so deploys are copy-only — no node on the server. See
Building if you change the editor JS.
Usage
For customers
- Write a product review as usual
- Your Photo (optional) — upload an avatar
- Product Image (optional) — upload a photo of the product
- Submit. Both appear with the review once it is approved.
For administrators
Go to Comments. The "Image" column shows the review photo; opening a review shows the "Review Image" and "Review Author Avatar" meta boxes. Uploads are ordinary attachments, so they are managed and deleted from the Media Library like anything else.
For editors — the Curated Review block
Editorial, not a feed. An editor picks one review and places it: a testimonial on the home page, a callout in a blog post, a trust signal on a landing page. The same review can appear in three places looking completely different, because the block decides only which data elements are present and the theme decides how they look.
Insert Curated Review (Widgets category), pick a product in the sidebar, then one of its reviews. On a product page the product is pre-filled — an editor convenience, not a different default.
The review picker reads GET /wc/v3/products/reviews, whose permission is moderate_comments.
Administrator, Editor and Shop manager pass; Author and Contributor get a 403 and the picker says
so. The frontend renders for everyone — only choosing is gated.
Known cap: the picker requests per_page=100, so a product with more than 100 approved reviews
truncates the list.
The block
Identity
| Block name | kaupang-review-images/curated-review |
| Render callback | Kaupang\ReviewImages\Block\CuratedReview::render() |
| Metadata | block/block.json |
| Editor source | block/src/index.js → block/build/ (committed) |
| Kill switch | kaupang/review-images/enable_block |
The PHP render callback is the single source of truth for the markup. The editor previews it
through <ServerSideRender />, so there is no JS-side copy that can drift.
Style variations
Three choices in the editor's Styles panel. None is the default and ships no CSS whatsoever — neither stylesheet selector matches a block without a variation, so an unstyled block is exactly as bare as it was before the stylesheet existed.
| Style | For | What it does |
|---|---|---|
| None | theme owns everything | nothing at all |
Quote (is-style-quote) |
a hero, a cover block, a testimonial band | body leads at 1.35em on a 34ch measure, attribution collapses to one byline row |
Compact (is-style-compact) |
a one-third column, a sidebar | byline and rating share the top row, body clamped to four lines |
What the two variations ship is structure only: reading order, a round avatar, a photo that
stays inside its column, margin: 0 on a <blockquote> the browser would otherwise indent 40px.
No colour, no background, no border, no shadow, no font family. That is chrome, and chrome is the
theme's — myrvann/scss/plugins/_kaupang-review.scss is where it lands on this site.
One exception earns its place: the two variations ship a star-rating floor.
WooCommerce's own .star-rating CSS is scoped under .woocommerce, and a page or post carrying
this block is not — so without it the rating renders as the literal words "Rated 5 out of 5"
overlapping themselves. That is broken, not merely unstyled. The floor is wrapped in :where() so
it has zero specificity: any theme with stars of its own wins on a single class.
color in particular is never set, not even to inherit: these selectors carry two classes and
would out-specify a theme's own .kaupang-review { color: … }. A block inherits its surroundings
without help.
Every value is a custom property, so a theme retunes by setting one rather than out-specifying a rule:
| Property | Quote | Compact |
|---|---|---|
--kaupang-review-avatar-size |
2.5rem |
2rem |
--kaupang-review-gap |
1rem |
0.5rem |
--kaupang-review-body-size |
1.35em |
— |
--kaupang-review-measure |
34ch |
— |
--kaupang-review-meta-size |
0.875em |
— |
--kaupang-review-body-lines |
— | 4 |
--kaupang-review-photo-size |
— | 4.5rem |
The variations never hide anything. Presence is the toggles' job; Compact caps the photo and the product footnote small rather than removing them, so an editor who leaves them on gets something proportionate instead of something invisible.
To drop the plugin's stylesheet entirely and style .is-style-quote / .is-style-compact yourself,
the variations stay registered either way:
add_filter( 'kaupang/review-images/enqueue_block_styles', '__return_false' );
There are still no inline styles on anything the block authors. The one you will see inside the
rating is WooCommerce's own width:X% star encoding from wc_get_star_rating_html(), which is
data, not presentation.
History. Spec §2 §I originally said the block ships zero CSS in every configuration. That was reversed deliberately: the default still ships nothing, and the two variations ship the minimum a review needs in order to read as a review.
Attributes
All twelve, with the defaults from block/block.json:
| Attribute | Type | Default | Attribute | Type | Default | |
|---|---|---|---|---|---|---|
reviewId |
number | 0 |
showAuthor |
boolean | true |
|
productId |
number | 0 |
showAvatar |
boolean | true |
|
showBody |
boolean | true |
showVerified |
boolean | false |
|
showReviewImages |
boolean | false |
showDate |
boolean | true |
|
showRating |
boolean | false |
showProductName |
boolean | true |
|
expandable |
boolean | false |
showProductImage |
boolean | false |
Defaults are uniform — they do not vary by context. The render callback reads them back off the
block registry, so block.json is the only place they are defined.
Read more / Les mer
expandable shortens a long review to a few lines and adds a button that expands it. It exists
because Quote has no length ceiling of its own: a 1500-character review will happily swallow a
hero.
The order of fallbacks matters more than the feature does:
| No JavaScript | full text, no button, nothing clamped |
| JavaScript, short review | full text, no button — the clamp never bit, so offering to expand would be a lie |
| JavaScript, long review | clamped text plus a real <button> |
PHP renders the whole review and a hidden button; block/view.js compares scrollHeight against
clientHeight and only then unhides the button and sets data-expanded="false" on the root, which
is what the clamp is keyed on. Nobody is ever left with truncated text and no way to reach the rest.
- The
<button>is a real button — keyboard-operable,aria-expandedflips,aria-controlspoints at that instance's<blockquote>. Ids come fromwp_unique_id(), because the same review may legitimately appear twice on one page. - Both labels ship as
data-label-more/data-label-less, translated server-side. - Collapsing from below the fold scrolls the review back into view, rather than dumping the reader wherever the page happened to shorten to.
- Line counts are
--kaupang-review-body-lines:6in Quote,4in Compact. - Under
Nonethe button appears but nothing clamps — the clamp lives in the variations, andNoneships no CSS. Supply the clamp in the theme, or use a variation. The button hides itself when nothing overflows, so it fails quietly rather than confusingly.
block/view.js is a plain file with no build step — the same convention as
assets/js/review-form-toggle.js — registered as viewScript, so WordPress loads it only on pages
that actually contain the block.
Class contract
kaupang-review__*, BEM, vendor spelled out. Document order is fixed — body, images, rating,
attribution, product. Toggles decide presence, never position; CSS order/grid makes visual
position free. This is the reading order for screen readers and text extraction.
<article class="kaupang-review" data-review-id="482" data-rating="5" data-verified="1" data-has-images="1">
<blockquote class="kaupang-review__body" cite="https://…/produkt/kakaonibs/">
<p>…</p>
</blockquote>
<figure class="kaupang-review__images">
<img class="kaupang-review__image" …>
</figure>
<p class="kaupang-review__rating">
<span class="star-rating" role="img" aria-label="Rated 5 out of 5">…</span>
</p>
<footer class="kaupang-review__author">
<img class="kaupang-review__avatar avatar …" …>
<cite class="kaupang-review__author-name">Kari N.</cite>
<em class="kaupang-review__verified">(verified owner)</em>
<time class="kaupang-review__date" datetime="2026-04-12T09:31:00+02:00">12. april 2026</time>
</footer>
<div class="kaupang-review__product">
<a class="kaupang-review__product-link" href="…">
<img class="kaupang-review__product-image" …>
<span class="kaupang-review__product-name">…</span>
</a>
</div>
</article>
Notes on what is actually emitted:
- The root
<article>carriesget_block_wrapper_attributes(), so editor-set classes (align, spacing,wp-block-*) merge withkaupang-review. star-ratingis WooCommerce's own class, aroundwc_get_star_rating_html(). It is deliberately notwc_get_rating_html()— the theme filters that one to an empty string (myrvann/inc/woocommerce.php:18) to kill loop ratings.role="img"+aria-labelsit on the span, as in WooCommerce's own review-rating template; there is no secondaria-labelon the wrapping<p>competing with it.- The avatar comes back from
get_avatar(), which runs the full custom-upload → real-Gravatar → nothing chain, so it carries the chain's own classes (avatar avatar-custom wcri-custom-avatarorwcri-gravatar) in addition tokaupang-review__avatar. When the chain returns nothing, no<img>is emitted. kaupang-review__productand its<a>only render when the product still exists.(verified owner)andRated %s out of 5use thewoocommercetext domain on purpose: they are verbatim WooCommerce strings and arrive already translated.
State attributes
data-review-id, data-rating, data-verified, data-has-images render on the root
*regardless of the corresponding `showtoggle** — a hidden rating still exposes data-rating`. The theme can hang the cascade on state it cannot see.
data-verified and data-has-images are "1"/"0". data-rating is the raw integer, "0"
when the review has no rating.
Empty state
Missing means any of: reviewId 0, comment deleted, unapproved, spam, trashed, or not a review
on a product.
- Frontend: the empty string. An empty wrapper is itself a style decision and this block ships none.
- Editor: a plain
<p class="kaupang-review__notice">saying why — detected viaREST_REQUEST, becauseServerSideRendercomes through/wp/v2/block-renderer/.
No structured data
No schema.org microdata, no JSON-LD, deliberately. Designation is carried by HTML semantics
instead: <article> around a <blockquote cite>, <cite> for the reviewer, <time datetime>
for the date, the rating as real text behind an aria-label. The reasoning and its primary
sources are recorded in docs/suite-seams/kaupang-review-images.md — read that before adding
schema back.