feat(analytics): Woo Phase 2 revenue UI (single-currency card, per-hotspot, influenced) - #2090
Open
subodhr258 wants to merge 22 commits into
Open
feat(analytics): Woo Phase 2 revenue UI (single-currency card, per-hotspot, influenced)#2090subodhr258 wants to merge 22 commits into
subodhr258 wants to merge 22 commits into
Conversation
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).
This was referenced Aug 24, 2026
🔍 WordPress Plugin Check Report
📊 Report
❌ Errors (1)📁 readme.txt (1 error)
|
| 📍 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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
RevenueCardon 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.phpforwardsbase_currencyto all four proxied endpoints.useVideoLayerDatamaps it onto subs and sums to the parent.formatRevenue.RevenueTipson 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 newfetchPlacementFunnelsRTK query (heavier per-placement queries, so they don't block the main dashboard), proxied through a newplacement-funnelsREST route.VideoToPurchaseCard— mirrorsVideoToCartCard: 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 newvideo_to_purchasekey). 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 (ascopeprop switches the descriptor). Keeps the "still counting" note.Design polish (matching the finalized ticket design)
RevenueCardrebuilt 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.MetricTrendfooter 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.revenue_direct_minor/revenue_assisted_minorkeys), shown only when the service sent the split; the two amounts are added to the CSV export.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. Newdata-test-ids on the revenue surfaces for E2E.Image-block hotspots (added)
godam-layer-analyticsbundle (assets/src/js/godam-player/layer-analytics-runtime.js+ entry) registerswindow.GoDAM.addLayerInteractionand the page-hide flush WITHOUT the video player, since image pages never loadgodam-player-analytics.render.phpenqueues it when the image has layers and stampsdata-block-source="godam-image"on the frame; the flush element lookup now also finds.godam-image__framebydata-id, so the surface tag survives the flush.video_idalready carries the image's attachment id viagetVideoKey, so ingest accepts the event unchanged. Foldinganalytics.jsonto the shared runtime to drop the remaining duplication is a follow-up.woo-layer) and Image Woo Layer (godam-image) so the two media read apart.Verified on the dev site: a synthetic image
type=3event (no play) rolled up asgodam-imageand 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)
Final adversarial review fixes (image-hotspot pass)
viewedbeacon the product rollup reads was never emitted.render-image-frame.jsnow callsemitLayerVisible()once per Woo layer on render, andrender.phpmakes the layer-analytics runtime a dependency of the renderer sowindow.GoDAMexists before it fires.analytics.js's flush wins and itsfindVideoElementByIdhad no image fallback, so image events flushedblock_source=''. Added the.godam-image__frame[data-id]fallback there too, matching the standalone runtime.