Skip to content

feat(analytics): Woo Phase 2 revenue UI (single-currency card, per-hotspot, influenced) - #2090

Open
subodhr258 wants to merge 22 commits into
feat/top-productsfrom
feat/phase2-woo-analytics-ui
Open

feat(analytics): Woo Phase 2 revenue UI (single-currency card, per-hotspot, influenced)#2090
subodhr258 wants to merge 22 commits into
feat/top-productsfrom
feat/phase2-woo-analytics-ui

Conversation

@subodhr258

@subodhr258 subodhr258 commented Aug 24, 2026

Copy link
Copy Markdown
Collaborator

What

The single godam (core) secondary PR for Woo Phase 2 — the dashboard/UI side. Base: feat/top-products. Parent: rtCamp/godam-plugin-wp#26. Supersedes the closed per-sub-task PRs #2087/#2088/#2089. Pairs with godam-analytics#262 and godam-for-woo#197.

What's in it

  • Revenue KPI card (A/B)RevenueCard on the dashboard Insights row (single store currency, "excluding N orders" sub-line), reused on the single-video Analytics page for per-video revenue. class-analytics.php forwards base_currency to all four proxied endpoints.
  • Per-hotspot Direct revenue (C) — the layer panel shows each Woo hotspot's revenue; useVideoLayerData maps it onto subs and sums to the parent.
  • Influenced revenue (D) — a separate "Influenced" sub-line in the Top Products Revenue cell, reusing formatRevenue.
  • No-data empty state (F FIX 2) — the Revenue cell shows "No data" (not a bare dash / misleading 0) when the service sends null revenue.
  • Per-placement split (F EASY WIN A) — a compact "Woo Layer X · Reel Pop Y" breakdown under the Revenue cell, shown only when 2+ placements drove revenue.
  • Comparative tips (F EASY WIN B)RevenueTips on the per-video page renders only the favourable comparisons (video-vs-store AOV, revenue per viewer).

All money rendering reuses the single shipped formatRevenue(minor, currency) — no new formatter.

Video-to-Purchase + Funnels (Phase 2, added)

  • PlacementFunnelCard — the Funnel by placement dashboard card: a Play→Cart→Purchase funnel per placement (Shoppable Video block / Woo hotspot layer / Reel Pop) with each placement's reach, Played/Added/Purchased counts and % of players, and its play-to-purchase rate. Fetches its own data via a new fetchPlacementFunnels RTK query (heavier per-placement queries, so they don't block the main dashboard), proxied through a new placement-funnels REST route.

  • VideoToPurchaseCard — mirrors VideoToCartCard: the count of distinct viewers who played then bought an attributed product, plus % of viewers, with the purple Woo badge and the same null-vs-zero guard. Rendered Woo-gated in the per-video and dashboard Insights rows (reads the new video_to_purchase key). The Insights row now wraps into a grid on large screens (lg:flex-wrap + lg:[&>*]:basis-40) so the extra card wraps instead of cramping the previously 4-card cap.

  • PurchaseFunnelCard — the Purchase Funnel card (the play-to-cart-to-purchase flow): left-aligned bars in a grey track, the Added-to-cart bar split into Direct (added in-video, dark blue) + Assisted (clicked out then added, light blue), the count + % of players on the right, drop-off annotations ("X% advanced", "N did not add to cart", a red "N abandoned after adding" pill), and a legend. Rendered account-wide on the Dashboard and per-video on the analytics page (a scope prop switches the descriptor). Keeps the "still counting" note.

Design polish (matching the finalized ticket design)

  • Detailed Revenue cardRevenueCard rebuilt to match the finalized design: headline total, a Direct/Assisted split bar with per-tier amounts, and the account-level Influenced figure in its own dashed box (reported separately, never added to the Direct + Assisted total). The per-video card shows the same split without Influenced (a product-page concept). Moved to its own full-width row above the funnel.
  • Trend badges — a shared MetricTrend footer shows the period-over-period % change (green ↗ / red ↘ + "vs prev N days") on Video-to-Cart, Video-to-Purchase and the Revenue card whenever a bounded range is picked; all-time shows the range label instead. The Insights range now drives every card there (VtC/VtP/Revenue/funnels), not just the three KPI tiles.
  • Top Products i-icons — info tooltips on the Source, Product Views, Add to Cart and Revenue column headers, each explaining the metric in plain words.
  • Per-product Direct/Assisted revenue — a "X direct · Y assisted" sub-line under each product's revenue (from the new revenue_direct_minor / revenue_assisted_minor keys), shown only when the service sent the split; the two amounts are added to the CSV export.
  • Copy + layout — Video-to-Cart/Purchase subtitles read "carts/orders from video, N% of viewers who played"; the funnel card is titled "Purchase Funnel"; VtC and VtP sit together on their own row below the KPI tiles.

Tests

Full core jest suite passes. New/updated: RevenueCard.test.js, MetricTrend.test.js, RevenueTips.test.js, TopProductsTable.test.js (revenue, influenced, No-data, placement split, Direct/Assisted tier split), useVideoLayerData.test.js (per-hotspot mapping), VideoToCartCard.test.js / VideoToPurchaseCard.test.js (subtitle + trend), PurchaseFunnelCard.test.js. Each sub-task was revert-proofed on its original branch. phpcs: 0 errors on the changed PHP.

Depends on

godam-analytics#262 to populate the revenue / order / influenced / placement / tips fields; degrades cleanly against an older service build (fields absent → no card / no sub-line).

Notes

@since n.e.x.t; no CHANGELOG/POT during development. New data-test-ids on the revenue surfaces for E2E.

Image-block hotspots (added)

  • Capture. A small standalone godam-layer-analytics bundle (assets/src/js/godam-player/layer-analytics-runtime.js + entry) registers window.GoDAM.addLayerInteraction and the page-hide flush WITHOUT the video player, since image pages never load godam-player-analytics. render.php enqueues it when the image has layers and stamps data-block-source="godam-image" on the frame; the flush element lookup now also finds .godam-image__frame by data-id, so the surface tag survives the flush. video_id already carries the image's attachment id via getVideoKey, so ingest accepts the event unchanged. Folding analytics.js onto the shared runtime to drop the remaining duplication is a follow-up.
  • Source labels. Top Products Source chips now read Video Woo Layer (woo-layer) and Image Woo Layer (godam-image) so the two media read apart.

Verified on the dev site: a synthetic image type=3 event (no play) rolled up as godam-image and rendered as the "Image Woo Layer" chip. The bundle is built and served and registers the buffer API; the live in-image interaction path is unit-tested but not yet exercised on a real page (no authored image-hotspot content exists on the dev site).

Review fixes (adversarial review)

  • Correct funnel share. The Purchased row's "% of players" showed purchased ÷ carts (the cart→purchase conversion — a large overstatement that contradicted its own bar); it now shows purchased ÷ players, matching the bar and the server stage rate. purchased/carts stays only as the drop-row "advanced" figure.
  • Funnel can't render inverted. With the backend now guaranteeing purchased ≤ added ≤ played, bar fractions clamp to [0,1] and the "advanced" % clamps to 100 as a defensive guard against a deploy skew.
  • Honest labels. The Purchased stage drops the false "net of refunds" descriptor (revenue/counts are gross by design) and is labelled "of those who added" (the nested cohort). The layer panel shows the parent layer's revenue without an order total — summing per-hotspot distinct counts overcounts an order spanning two hotspots; a single hotspot still shows its exact distinct count.

Final adversarial review fixes (image-hotspot pass)

  • Image impressions were always 0: a still image fires no visibility transition, so the parent-layer viewed beacon the product rollup reads was never emitted. render-image-frame.js now calls emitLayerVisible() once per Woo layer on render, and render.php makes the layer-analytics runtime a dependency of the renderer so window.GoDAM exists before it fires.
  • block_source dropped on mixed pages: on a page with both a video and an image, analytics.js's flush wins and its findVideoElementById had no image fallback, so image events flushed block_source=''. Added the .godam-image__frame[data-id] fallback there too, matching the standalone runtime.

GoDAM commits to a single store currency (no cross-currency conversion).

- WP proxy (class-analytics.php): pass base_currency = get_option('woocommerce_currency')
  to /dashboard/metrics/fetch/ and /dashboard/top-products/, so the service returns
  base-currency revenue and a count of orders in other currencies. The `revenue`
  object already forwards through the dashboard-metrics array_merge.
- RevenueCard.js: a WooCommerce-only Insights KPI card showing total video-attributed
  revenue via the existing formatRevenue(minor, currency), with an
  "excluding N orders in other currencies" sub-line when that count is > 0 (and
  nothing when 0, i.e. a single-currency store). Renders nothing if the payload is
  absent, so it never shows a misleading 0.
- Dashboard.js: render RevenueCard in the Insights row, gated on hasWooProducts.

Base branch: feat/top-products (where #2086 shipped the Top Products revenue UI +
formatRevenue). Pairs with godam-analytics#257.
Sub-task C (core). Consumes the per-hotspot revenue the layer-analytics endpoint
now returns.

- useVideoLayerData: map revenue_minor/orders/currency onto each sub-hotspot; the
  parent carries the sum of its hotspots' revenue (currency from any child).
- SubHotspotRail: a small secondary line per hotspot, "<amount> · N orders",
  shown only when that hotspot has orders (Woo layers only).
- LayerDetailPanel: a headline "This hotspot/layer drove <amount> across N orders"
  above the funnel, with an InfoTooltip that it is the Direct in-video contribution
  only, not the product's total (Assisted purchases are excluded). Reuses the
  shipped formatRevenue (correct ISO fraction digits), full numbers.
- WP proxy: pass base_currency = get_option('woocommerce_currency') to
  /processed-layer-analytics/.

Base branch feat/top-products (#2086). Pairs with godam-analytics#259.
Each Woo composite row carries revenue_minor/orders/currency from the
endpoint; assert groupRows maps them onto the sub, sums them onto the
parent (currency from a base-currency child), and a non-base order that
the server excluded (revenue_minor 0) is never blended in. Also assert
the fields default to 0/empty when the endpoint sends none.
Render the third attribution tier as a separate sub-line in the Revenue
cell of the Top Products table, shown only when there is a real match
(influenced_revenue_minor > 0) so an absent/zero match renders nothing,
never a misleading currency-zero. Reuses the shipped formatRevenue with
the row's own influenced_currency; never folded into the product's own
revenue/orders. Carries a (provisional) marker when the service flags it.

Reads influenced_revenue_minor/_currency/_orders/_provisional from the
Top Products row (godam-analytics #260). data-test-id on the sub-line for
E2E. 7 new jest tests for the hasInfluenced gate, the order-count label,
and that the amount formats via the shipped formatRevenue (no new
formatter).
@github-actions

github-actions Bot commented Aug 24, 2026

Copy link
Copy Markdown

🔍 WordPress Plugin Check Report

❌ Status: Failed

📊 Report

🎯 Total Issues ❌ Errors ⚠️ Warnings
17 1 16

❌ Errors (1)

📁 readme.txt (1 error)
📍 Line 🔖 Check 💬 Message
0 outdated_tested_upto_header Tested up to: 7.0 < 7.1. The "Tested up to" value in your plugin is not set to the current version of WordPress. This means your plugin will not show up in searches, as we require plugins to be compatible and documented as tested up to the most recent version of WordPress.

⚠️ Warnings (16)

📁 readme.txt (2 warnings)
📍 Line 🔖 Check 💬 Message
0 mismatched_plugin_name Plugin name "GoDAM - Organize WordPress Media Library & File Manager with Unlimited Folders for Images, Videos & more" is different from the name declared in plugin header "GoDAM".
0 trademarked_term The plugin name includes a restricted term. Your chosen plugin name - "GoDAM - Organize WordPress Media Library & File Manager with Unlimited Folders for Images, Videos & more" - contains the restricted term "wordpress" which cannot be used at all in your plugin name.
📁 composer.json (1 warning)
📍 Line 🔖 Check 💬 Message
0 missing_composer_json_file The "/vendor" directory using composer exists, but "composer.json" file is missing.
📁 assets/build/blocks/godam-gallery-v2/render.php (2 warnings)
📍 Line 🔖 Check 💬 Message
15 WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedVariableFound Global variables defined by a theme/plugin should start with the theme/plugin prefix. Found: "$inner_block_video_ids".
23 WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedVariableFound Global variables defined by a theme/plugin should start with the theme/plugin prefix. Found: "$inner_block_video_ids".
📁 assets/build/css/main.css (1 warning)
📍 Line 🔖 Check 💬 Message
0 EnqueuedStylesScope This style is being loaded in all contexts.
📁 assets/src/libs/analytics.min.js (5 warnings)
📍 Line 🔖 Check 💬 Message
0 EnqueuedScriptsScope This script is being loaded in all frontend contexts.
0 NonBlockingScripts.NoStrategy This script on http://localhost:8880 (with handle analytics-library) is loaded in the footer. Consider a defer or async script loading strategy instead.
0 NonBlockingScripts.NoStrategy This script on http://localhost:8880/2026/08/26/hello-world/ (with handle analytics-library) is loaded in the footer. Consider a defer or async script loading strategy instead.
0 NonBlockingScripts.NoStrategy This script on http://localhost:8880/sample-page/ (with handle analytics-library) is loaded in the footer. Consider a defer or async script loading strategy instead.
0 NonBlockingScripts.NoStrategy This script on http://localhost:8880/demo-attachment-post/ (with handle analytics-library) is loaded in the footer. Consider a defer or async script loading strategy instead.
📁 assets/build/js/main.min.js (5 warnings)
📍 Line 🔖 Check 💬 Message
0 EnqueuedScriptsScope This script is being loaded in all frontend contexts.
0 NonBlockingScripts.NoStrategy This script on http://localhost:8880 (with handle rtgodam-script) is loaded in the footer. Consider a defer or async script loading strategy instead.
0 NonBlockingScripts.NoStrategy This script on http://localhost:8880/2026/08/26/hello-world/ (with handle rtgodam-script) is loaded in the footer. Consider a defer or async script loading strategy instead.
0 NonBlockingScripts.NoStrategy This script on http://localhost:8880/sample-page/ (with handle rtgodam-script) is loaded in the footer. Consider a defer or async script loading strategy instead.
0 NonBlockingScripts.NoStrategy This script on http://localhost:8880/demo-attachment-post/ (with handle rtgodam-script) is loaded in the footer. Consider a defer or async script loading strategy instead.

🤖 Generated by WordPress Plugin Check Action • Learn more about Plugin Check

…-task B)

Reuses RevenueCard (single store currency) on the per-video Analytics Insights
row, Woo-gated, mapping the record's flat revenue/revenue_currency/
revenue_excluded_orders keys to the card payload. class-analytics.php forwards
base_currency on the per-video fetch proxy. Adds the missing RevenueCard test
(6: null-vs-zero, formatRevenue amount, excluded sub-line singular/plural).
The service now sends null revenue for a product with no orders yet; render
'No data' in the Top Products Revenue cell instead of a bare dash.
…EASY WIN A)

A compact 'Woo Layer X · Reel Pop Y' breakdown under the Revenue cell, shown only
when 2+ placements drove revenue. revenuePlacements() sorts by revenue desc and
drops zero-revenue placements; reuses sourceLabel + formatRevenue. 3 tests.
…(F EASY WIN B)

RevenueTips renders only the favourable comparisons (video-vs-store AOV, revenue
per viewer) from record.revenue_tips, Woo-gated, nothing when there is nothing to
say. Reuses formatRevenue. 4 tests.
VideoToPurchaseCard mirrors VideoToCartCard: the count of distinct people who
played a video and then bought an attributed product, plus the % of viewers,
with the purple Woo badge and the same null-vs-zero guard (renders nothing when
the video_to_purchase payload is absent, so it never asserts a misleading 0).
Reads the new video_to_purchase key. Rendered Woo-gated on both the per-video
Insights row (after Revenue) and the dashboard Insights row.

The Insights row now wraps into a grid on large screens (lg:flex-wrap +
lg:[&>*]:basis-40) instead of forcing a single non-wrapping line, so the extra
card wraps to a second row rather than cramping the previously 4-card cap.

VideoToPurchaseCard.test.js mirrors VideoToCartCard.test.js (null/undefined ->
renders nothing; a real payload leads with the count; a measured 0 still renders).
PurchaseFunnelCard renders the video_funnel payload as three proportional
distinct-visitor stages (Viewers -> Added to cart -> Purchased), each bar sized
to its share of viewers, with the drop-off between stages and a 'still counting'
note that toggles on the backend still_counting flag (recent ranges are not
final for up to 30 days). Built as a semantic HTML/CSS funnel rather than a d3
chart: three discrete bars read better as real text (accessible, testable) than
a plotted curve. Rendered Woo-gated in the per-video analytics stack, next to
the Viewer Retention Curve, fed by rangedAnalyticsData.video_funnel.

PurchaseFunnelCard.test.js: null guard, the three stages + counts + shares, the
drop-off between stages, and the still-counting note toggling on the flag.
Rebuild PurchaseFunnelCard to the real design: left-aligned bars in a grey
track, the Added-to-cart bar split into Direct (added in-video, dark blue) and
Assisted (clicked out then added, light blue), the count + % of players on the
right, drop-off annotations between stages ('X% advanced', 'N did not add to
cart', and a red 'N abandoned after adding' pill), and a legend. A scope prop
switches the top descriptor between 'any GoDAM video' (account) and 'this
video'. Rendered account-wide on the Dashboard AND per-video on the analytics
page. Keeps the still-counting note.

Replaces the earlier centered single-colour bars, which were from a draft
mockup, not the design.

PurchaseFunnelCard.test.js: stages/counts, the Direct/Assisted split bar, the
drop annotations incl. the abandoned pill, the legend, the account/video
descriptor, and the still-counting toggle.
PlacementFunnelCard renders a Play->Cart->Purchase funnel per commerce placement
(Shoppable Video block / Woo hotspot layer / Reel Pop): each placement shows its
reach (instances + videos), the Played / Added to cart / Purchased counts with
their % of players, and the play-to-purchase rate, matching the mockup. Fetches
its own data via a new fetchPlacementFunnels RTK query (the per-placement queries
are heavier, so they don't block the main dashboard load), gated on WooCommerce.
Proxied through a new godam/v1/analytics/placement-funnels REST route.

PlacementFunnelCard.test.js: a funnel per placement with counts/reach/rate, the
loading state, and nothing when a loaded store has no placement activity.
phpcs: 0 errors.
…st labels

- The Purchased row's '% of players' showed purchased/carts (the cart->purchase
  conversion), a ~22x overstatement that contradicted its own bar. Show
  purchased/players (buyShare), the same denominator the bar and the server
  stage rate use; keep purchased/carts only as the drop-row 'advanced' figure.
- Defensive clamps against a deploy skew (the backend range_video_funnel now
  guarantees purchased <= added <= played): bar fractions clamp to [0,1] and the
  advanced % clamps to 100, so the funnel can never render inverted.
- Drop the false 'net of refunds' descriptor (revenue/counts are gross by
  design); the Purchased stage is now labelled 'of those who added', matching
  the nested cohort.
- The layer panel no longer prints an order total for the PARENT aggregate
  (summing per-hotspot distinct counts overcounts an order spanning two
  hotspots); a single hotspot still shows its exact distinct count.

Add tests for the of-players share and the advanced-% clamp.
…d card spacing

Rename the account/per-video funnel heading from 'Play to Cart to Purchase'
to the simpler 'Purchase Funnel'. Give the Purchase Funnel and Funnel-by-
placement cards a 1.5rem top margin (and the placement card a matching bottom
margin) so they no longer butt against the grid above and the Top Videos table
below on the dashboard; scoped to .dashboard-container so the per-video page is
unaffected.
Rebuild RevenueCard from a compact KPI tile into the finalized full-width card:
title 'Video-Attributed Revenue', headline base-currency total, a Direct/Assisted
split bar with per-tier amounts, the 'before refunds' label (plus the
excluded-orders line when a multi-currency store has non-base orders), an info
tooltip, and an account-wide Influenced side box (dashboard only; the per-video
payload omits influenced so that box is hidden). Move it out of the compact
Insights row into a full-width slot on both the dashboard and the per-video page,
reading the new direct_minor / assisted_minor / influenced_minor fields.
Subtitle now reads 'carts from video, X% of viewers who played' and 'orders from
video, X% of viewers who played' (was just 'X% of viewers'), naming what the
number is and that the share is of players, per the finalized mockup.
…/VtP

Add a shared MetricTrend footer (green up / red down arrow, % change, 'vs prev'
label) reading the new 'change' field on the video_to_cart / video_to_purchase /
revenue payloads. It appears only on a bounded range (all-time has no comparison
window) and falls back to the range label on the tiles. Group Video-to-Cart and
Video-to-Purchase onto their own row below the core Insights metrics, side by
side, per the design (they were wrapping 4 + 1 after Revenue moved out to its own
full-width card).
- Add info tooltips (i-icons) to the Source, Product Views, Add to Cart
  and Revenue column headers, so each metric explains itself in place.
- Render a 'X direct . Y assisted' sub-line under each product's revenue
  from the new revenue_direct_minor / revenue_assisted_minor keys, shown
  only when the service sent the split (never a misleading zero split).
- Add the two amounts to the CSV export alongside the existing revenue
  columns.

Unit tests cover the new hasRevenueTierSplit gate.
…se2-woo-analytics-ui

# Conflicts:
#	pages/dashboard/components/TopProductsTable.js
…in Top Products

The Woo hotspot layer ships on two media, so the Source chip now names the
medium: 'woo-layer' -> 'Video Woo Layer', 'godam-image' -> 'Image Woo Layer'
(was 'Woo Layer' / 'Image'). Naming the layer, not just the medium, leaves room
for other image layer types later.
Image pages never load the video-player analytics bundle, so the emit calls in
the shared hotspot managers were guarded no-ops and nothing was recorded. Add a
small standalone layer-analytics runtime that registers
window.GoDAM.addLayerInteraction + the page-hide flush without the video player,
enqueue it from the godam/image block when it has layers, and stamp
data-block-source='godam-image' on the frame so the surface tag survives the
flush (the flush's element lookup now also finds .godam-image__frame by data-id).

video_id already carries the image's attachment id via getVideoKey, so ingest
accepts the event unchanged. The runtime is idempotent and does not overwrite the
video bundle's registration; folding analytics.js onto this shared module to drop
the remaining duplication is a tracked follow-up.

Not yet verified end-to-end on a live image+hotspot page (needs the plugin build
watcher restarted for the new entry); rollup + reads already land image rows.
Adversarial review found two image-capture gaps:

- Impressions always 0: a still image fires no visibility transition, so the
  parent-layer 'viewed' beacon (which the product rollup reads for impressions)
  was never emitted. render-image-frame.js now calls emitLayerVisible() once per
  Woo layer on render, and render.php makes the layer-analytics runtime a
  dependency of the renderer so window.GoDAM is present before it fires.
- block_source dropped on mixed pages: on a page with BOTH a video and an image,
  analytics.js's flush wins and its findVideoElementById had no image fallback,
  so image events flushed block_source=''. Added the .godam-image__frame[data-id]
  fallback there too, matching the standalone runtime.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant