WP Manifestindependent plugin directory
manifest / unclassified / kaupang-review-images

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

0stars
0forks

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.zip

Readme

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 2x srcset, 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

  1. Upload the kaupang-review-images folder to /wp-content/plugins/
  2. Activate through the Plugins menu
  3. 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

  1. Write a product review as usual
  2. Your Photo (optional) — upload an avatar
  3. Product Image (optional) — upload a photo of the product
  4. 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.jsblock/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-expanded flips, aria-controls points at that instance's <blockquote>. Ids come from wp_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: 6 in Quote, 4 in Compact.
  • Under None the button appears but nothing clamps — the clamp lives in the variations, and None ships 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> carries get_block_wrapper_attributes(), so editor-set classes (align, spacing, wp-block-*) merge with kaupang-review.
  • star-rating is WooCommerce's own class, around wc_get_star_rating_html(). It is deliberately not wc_get_rating_html() — the theme filters that one to an empty string (myrvann/inc/woocommerce.php:18) to kill loop ratings. role="img" + aria-label sit on the span, as in WooCommerce's own review-rating template; there is no second aria-label on 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-avatar or wcri-gravatar) in addition to kaupang-review__avatar. When the chain returns nothing, no <img> is emitted.
  • kaupang-review__product and its <a> only render when the product still exists.
  • (verified owner) and Rated %s out of 5 use the woocommerce text 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 via REST_REQUEST, because ServerSideRender comes 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.

Read the full README on GitHub →