Răsfoiți Sursa

Analytics: web and product analytics in Studio, on the site and in NEO (#5856)

* Analytics: web analytics foundation (sessions, pageviews, platforms, dashboard)

Phase 1 of turning bx_analytics into Rybbit-style web and product analytics
kept in the site's own database, with the client platform on every hit.

Collecting
- Tables bx_analytics_sessions, bx_analytics_events and bx_analytics_salts
  (InnoDB, declared explicitly).
- One collector for every entry point: m/analytics/collect for the classic
  UI (same origin, cross-site and foreign-host hits dropped) and the public
  API service bx_analytics/collect for NEO, the apps and other clients.
- Platforms core-web, next-web, rn-ios, rn-android, api, plus core for
  server-side events (never accepted from clients).
- Client IP from proxy headers only behind a trusted proxy (private network,
  configured list, or a request with the API key); used for the visitor id
  and dropped, never stored.
- User agent parser (browser, OS, device, bots), traffic source and channel
  classifier (search, social, AI, email, paid, referral, UTM, click ids),
  country, region and city from CDN headers.
- Cookieless visitor ids salted daily, member visits linked to the profile
  (a guest who signs in continues the same session), opt-in persistent ids.
- Do Not Track and GPC, excluded levels, IP ranges and paths.
- Classic tracker injected on every page: pageviews on load and htmx
  navigation (not preloads), engaged time, bx_analytics.track() events.
- Hourly pruning of old salts and data past the retention period, in
  batches with a time limit; deleted profiles are unlinked.

Reports
- Filter shared by all blocks through the page URL: ranges, timezone with
  exact local buckets across daylight saving changes, platform, dimension
  filters; previous period for changes.
- Overview block (filters, online now, six key figures, chart with the
  current period dashed) and tabbed top lists for pages, sources,
  locations, devices and platforms; clicking a row filters every block.
- Page "analytics" laid out as the dashboard; Studio gets Overview and
  Content reports tabs; the Studio dashboard gets a Traffic block.
- Site administrators always see analytics; others need "use analytics".

Fixes
- get_canvas no longer calls exit on access denied (broke API page JSON).
- Most active profiles passed the author column twice, shifting the dates.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Analytics: Studio sections, traffic tables, realtime and map

Studio's analytics page becomes a sectioned app: the side menu groups
Overview, Traffic (realtime, pages, sources, audience, map, bots), Behavior
(sessions, users, events), Conversion (goals, funnels, journeys,
retention), Revenue (revenue, subscriptions, sales, credits), Health
(errors, performance), Community (content reports), Explore (query, API,
dashboards) and Configure (setup, settings). Sections keep Studio's own
menu items; on phones they fold into Studio's single tab row.

Every report is one data method and one template, served the same way to
page-builder blocks, Studio sections and dashboard reloads (and NEO next):
- The filter bar is its own block, so any section or page can carry it.
- Pages and Sources tables with every metric and paging: entries, exits,
  entry bounce rate and time on page for pages; sessions, bounce rate,
  pages per session and duration for sources.
- Realtime: visitors online per platform, pageviews per minute for 30
  minutes, a live feed; it refreshes itself while the page is visible.
- Map: visitors per country (approximate centres) or per place when the
  edge sends coordinates (stored rounded to ~10 km), on the site's
  Leaflet tile provider; a live mode for the last 30 minutes.
- Every report is in the page builder's block list.

Also: a setting for query parameters left out of page paths (defaults to
the dashboard's own), and a test for the pages table.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Analytics: bots and agents

Bots don't run the tracker, so the page counts them as it renders (and a
headless browser that does run it is counted as a bot, not a visit). They
are kept as daily totals per bot and platform, and per bot category and
page, never as sessions, so crawler floods can't skew visitor numbers.

- Classifier for search engines, AI crawlers (GPTBot, ClaudeBot,
  PerplexityBot, CCBot...), AI assistants fetching a page for a person
  (ChatGPT-User, Claude-User, Perplexity-User...), SEO tools, link
  previews, monitors, feed readers and scripts; unlisted crawlers keep
  their own name.
- Bots and agents section: requests, bots, AI requests and their share
  of all requests, requests per day by category, the bots with their
  platforms and last visit, and the pages they ask for, by category.
- Setting to switch it off; pruned with the rest of the data.
- Charts in the dashboard script now come in kinds (a metric over time,
  stacked bars, lines per category) with a categorical palette that
  meets contrast in both themes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Analytics: sessions, users and events, with site actions as events

Behavior section:
- Sessions: visits latest first with who, platform, place, device and
  source; a visit opens as a timeline of its pages (with time on each),
  custom events and site actions.
- Users: members who visited, with their visits, pageviews, time and
  platforms; a member opens with lifetime totals, platform split, their
  events and actions, and their visits.
- Events: custom events and site actions with counts, visitors and per
  visitor; an event opens with its chart over time, platform split and
  the most common values of its properties.
- Sessions and users show members by name, so they need the new ACL
  action "view analytics people" (administrators by default).

Site actions become events on the server, with the client that did them:
sign-up, login, profile created, content created (every content module,
subscribed per module and kept in step when modules are switched on or
off), comment, reaction and connection. NEO can name its client in
X-Neo-Platform; otherwise the API key means NEO web and the native HTTP
stack tells iOS from Android; cron and background jobs are "core". An
action joins the actor's running visit, so it counts against bounce.

Fixes: table cells shared the __value__ placeholder with the cell that
filters or opens, so those rows filtered by an empty value.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Analytics: Lucide glyphs in the Studio menu, 16px inside section blocks

- The Studio side menu draws each section's Lucide glyph (sys_lucide
  iconset) on Studio's gray tile, like Studio's own pages, through a
  small Studio menu subclass; the glyphs take the tile's colour, not the
  iconset's blue.
- Section content sits 16px from the block edge (the block's own 8px
  plus 8px).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Analytics: analytics page, blocks and dashboard item for NEO

enable-app.sql exposes the analytics page's filter bar, overview and top
list blocks and the account dashboard's Analytics item to the API. Each
API block describes itself (report, block parameters, the get_report
endpoint, ranges, platforms, metric titles and UI strings) and the NEO
element fetches its data with the page's filters. Guests get an empty
page and a 403 from get_report.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Analytics: journeys and retention

- Journeys: pick a start page (the top entry pages are listed) and see
  where visitors went next, with the share that left; pick a next page
  to see the step after it. The most common three-page paths below. A
  page reloaded in place counts once; very busy periods follow the
  latest visits and say so.
- Retention: weekly cohorts by first visit for the last 12 weeks and the
  share of each that came back in the weeks after, for members or all
  visitor ids (with a note that cookieless guest ids reset daily). Cells
  are tinted by their share.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Analytics: goals and funnels

Goals count the visits that reach a page (path, * for any characters) or an
action (custom event or site action, name@module for one module), with the
conversion rate and its change against the previous period. Funnels follow
2 to 6 steps in order within a visit and show where visitors drop off.
Managers ("manage analytics" or site administrators) add, edit and delete
them in place; saves are POSTs with a form token.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Analytics: NEO elements and tracker

NEO shows the analytics page as the Dashboard's Analytics subtab, on web and
in the apps: analytics_filters (period, platform, removable filter chips,
online now), analytics_overview (key figures with the change against the
previous period, and an SVG chart of the selected one) and analytics_top
(tabbed top lists; a row filters every block). The blocks on a screen share
one filter state, kept out of the URL so the Conductor keeps the subtab
selected. Each block fetches its data from bx_analytics/get_report.

The analytics page moves to layout 12 like the other account dashboard
pages: NEO's dashboard tabs show the center and the right bar and drop the
top area. The page now selects its own item in the dashboard submenu.

The NEO tracker sends pageviews (screen views on native), engaged time and
custom events (trackEvent) to bx_analytics/collect, with settings from the
new safe service get_tracker_config. NEO requests carry X-Neo-Platform
(web, ios, android), so site actions record the client; the API allows the
header in CORS preflights. Report time zones accept the legacy names that
Intl still returns on some runtimes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Analytics: NEO key figures sized by the block, tabs collapse into More

The key figure tiles take 2, 3 or 6 per row by the block's own width, not the
viewport's, so the labels fit in a column. Top-list tabs that don't fit the
right bar move into a More menu instead of a clipped scroll row.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Analytics: revenue, subscriptions, sales and credits

Analytics keeps its own revenue ledger from the payment alerts on the system
unit (register_payment, refund_payment, cancel_order, the subscription
alerts and finalize_checkout): one row per money movement with the order,
item, seller, buyer, provider, the amount in the order's currency and in the
site's default currency (the order's rate snapshot), and the platform, visit
and first and last traffic source of the buyer. The payment module deletes
refunded transactions, mixes credits payments in with money and records no
client, so its tables can't answer these questions themselves.

Reports, for levels with the new "view analytics revenue" action
(administrators by default): Revenue (gross, refunds, net, orders, paying
customers, revenue per customer; by source module, provider, platform, first
and last visit source and campaign), Subscriptions (MRR, paying, trials, new,
cancelled, churn, plans, cancellation reasons), Sales and sellers (top items,
sellers, vendor volume against commissions, invoices) and Credits (bought,
spent, granted, sent, withdrawn, the balance members hold). Modules report
their own economy through get_analytics_revenue; credits and payment
(commission invoices) implement it. Users show lifetime value with the same
permission, and sales become purchase and renewal events for goals and
funnels, without amounts for levels without it.

Past payments are imported through the new paged payment services
get_transactions_after and get_subscriptions_after, in steps from the hourly
cron or from the Revenue page. The cancel_subscription alert now carries the
reason, and the payment time tracker marks a failed renewal unpaid through
overdue(), which raises overdue_subscription. The Journeys start page
parameter is jfrom, so it no longer clashes with a custom date range, and the
page builder blocks NEO can render are visible to the API.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Analytics: errors and web vitals, trials in the revenue ledger

Health section. Errors from the classic site, NEO web and the apps are
grouped by a fingerprint that survives query strings, ids and bundle
hashes, with occurrences, visits, visitors, platforms, first and last seen,
a trend and a status (open, resolved, ignored; a resolved error that comes
back reopens). Each error shows its stack (to levels that may see people),
routes, browsers, systems, devices, countries and latest occurrences, and the
tiles show the share of visits with errors and crash-free app visits.
Performance shows the 75th percentile and the good / needs improvement /
poor split of LCP, INP, CLS, FCP and TTFB on the web, and screen load and
app start in the apps, by route and platform and over time.

The classic tracker captures errors, unhandled rejections, failed htmx
requests and own script loads, and web vitals including soft (htmx)
navigations; it is minified into the public cache. It also listens to htmx
4's history events, so pageviews after htmx navigation are sent again. NEO
captures window errors before hydration, Next's error pages (global-error
now renders its own html and body), UNA responses that fail with 500 and
above, React render errors through a root and a page boundary on native,
ErrorUtils (fatal errors are kept and sent on the next start) and Hermes
rejections in release builds, and reports vitals, screen load and app start.
Messages, URLs and stacks are stripped of query strings, tokens and emails
on the client and again on the server; errors are capped per page, visit and
hour. Settings for both are in a new Health category.

Trials: a recurring order's registration at the start of a trial is a ledger
row of kind trial, left out of every revenue figure, and the first charge
after the trial is the sale.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Analytics: Query with CSV, API playground, dashboards, setup

Explore > Query shows any metric by any of the session and page dimensions,
sorted either way, as a paged table with click-to-filter and a chart of the
top five values over time, and downloads it as CSV (same access checks as
the reports, identifiers from whitelists only, formula cells escaped, at
most 10,000 rows). Explore > API lists every report and its parameters,
builds the bx_srv, api.php and NEO request forms and runs the call as the
admin. Explore > Dashboards lists the site pages that hold analytics blocks,
with how many of them NEO shows, and creates a new analytics page laid out
for both the classic site and NEO. Configure > Setup shows which platforms
are sending data, what each client needs, custom event snippets for the
classic site, NEO and PHP, a dry run of the collector's checks for this
browser and a test event. These three are admin tools: they are not reports,
so get_report and the blocks never reach them.

The new track service stores custom events from server code. The
collector's checks now live in one method that collect() and the dry run
share, and site actions are no longer stored on a platform switched off in
Settings. Tables page past page 1 in Studio, platform rows set the platform
filter, and Studio sections keep 16px to the edge at phone width (cards fit
the column and wide tables scroll inside them).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Analytics: rate limits on the collector; report cache and faster retention

The public collector limits each client address (a keyed hash with the
day's salt, never the IP; IPv6 by /64) to 300 hits a minute and 100 new
visits an hour, both settings. Requests over a limit are dropped quietly
with a success answer, so a short spike from a shared address doesn't stop
NEO tracking for the rest of the visit. Site actions, revenue and the track
service are not limited. One upsert per request; the hourly cron prunes the
buckets.

From the 10-million-event load test, in progress: a short per-report result
cache (live reports excluded) and a retention query that aggregates cohorts
in SQL instead of loading every visitor.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Analytics: Revenue tab in the account dashboard, in NEO too

A Revenue page (analytics-revenue, one column) joins the Analytics tab in the
account dashboard with the filters and the Revenue, Subscriptions, Sales and
sellers and Credits blocks. Its menu item shows to levels with "view
analytics revenue" (the new is_revenue_avaliable service), and it stays
visible without a payment module, since other revenue sources are expected;
the reports then show an empty state. The page and its blocks, the menu item
and the four revenue library blocks are available to the API.

NEO renders the four reports with one element (analytics_revenue,
analytics_subscriptions, analytics_sales, analytics_credits): key figures with
their change, multi-series line charts and stacked bars in the report's
currency (negative values, future buckets and small amounts handled), tables
with tabs, typed columns, share bars and in-app links, and the notes. Money
shows the currency's sign where Hermes prints its code.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Analytics: load-tested report queries, privacy and audit fixes

Load test on 10 million synthetic events: report queries find matching
visits once instead of per row, group pages with SQL_BIG_RESULT, read
journeys backwards up to their limit, fetch member ids before their visits,
count funnels in one query, and use wider covering keys (events: path,
title and profile_id keys; the unused object key dropped). Most 7 and 30
day reports dropped from seconds or minutes to under a second or a few;
the table is in the plan. Reports are cached for a few minutes as JSON
files, not PHP files in opcache, and the cache really is dropped when goals,
funnels or error statuses change. The query chart for pages groups by page.

Privacy: stored page paths drop secret parameters (reset keys, confirmation
codes, tokens, passwords, session ids) and are redacted like error text.
Site actions respect Do-Not-Track, excluded addresses, bots and excluded
levels. Deleting a profile gives each of its visits a new random visitor id
and drops the account and profile ids its actions were about. Signing in
takes over a guest visit only if it was active in the last two minutes or
is the device's own. Error groups keep their latest text, and old sample
stacks are cleared with the retention.

Hardening: client IPs are read from X-Forwarded-For right to left past
trusted hops, as core does since #5845; collect bodies over 256 KB are
refused unread; the daily salts are read once per request; pruning handles
the small tables first; the dashboard keeps the viewer's timezone in a
cookie, so reports aren't computed twice on every visit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Analytics: SonarCloud gate, Credits consistency, calendar comparisons

Code that would fail the SonarCloud gate: sha256 instead of sha1/md5 for
the bot page and report cache hashes, crypto-random vitals sampling in the
classic and NEO trackers, a protocol-relative URL and an anchored regex in
the source parser (with bx_mb_strpos), and an explicit constructor
visibility in the Studio page. NEO chart and trend colours come from new
tokens (chart-good, trend-good, trend-bad) instead of hex values.

The Credits block covers all platforms (the credits module can't split
spends by platform), says so when a platform is selected, and explains
credits spent outside the payment ledger. The previous period of every
comparison steps back by calendar days or months in the viewer's timezone,
so changes are right across daylight saving and leap years. Importing past
payments and deleting a profile drop the cached reports.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Analytics: pre-PR review fixes for privacy, trusted headers and report cost

Stored paths also drop UNA's invite code (icode) and parameter names that
end in key, code, token, secret, password, session, email and the like
without a separator, so short invite keys no longer reach Pages and
Journeys.

The visitor location can come from one CDN's headers only (Cloudflare,
Vercel or CloudFront), since an edge passes other CDNs' headers through.
NEO's web proxy forwards to UNA only the address and location headers the
edge in front of it set (NEO_EDGE: vercel, cloudflare, cloudfront, proxy
or none); UNA trusts them on requests with the API key, and a browser
talking to next start directly could otherwise pick its own address.

Dropping cached reports starts a new generation instead of deleting by
prefix, which empties a whole Memcache(d) server and scans every Redis key
on each profile deletion. Cache names keep only the parameters a report
reads, not the whole page URL. Subscriptions skip unpaid ones that lapsed
before the period, goals are capped at 20, forgetting a profile finds its
creation by a new events key (type, name, object_id), and new dashboards
keep each block's own active_api. Fire-and-forget promises in the
dashboard and Studio tools scripts get rejection handlers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Analytics: one description list per fact in the session and error details

SonarCloud's Web:ItemTagNotWithinContainerTagCheck wants a <dt>/<dd> wrapper
<div> directly inside <dl>; the bx_repeat tag between them failed the
reliability rating. Each fact is now its own <dl>, laid out by the same grid.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Analytics: sign-in, page-filter chart and persistent-mode fixes from Bugbot

Signing in during a guest visit now gives the visit's earlier events the
member's visitor id and profile, not only the visit itself. Page, event
and goal reports counted one visit as two people, and the member's
actions left out what happened before signing in. A login recorded in the
sign-in request takes the guest visit over too. Error hits keep the
guest id.

With a page or title filter the overview chart counts that page's views
per bucket, as the Pageviews figure does, instead of every view of the
visits that saw the page.

In persistent identity mode site requests now carry the device's visitor
id: the classic tracker keeps it in a bx_analytics_vid cookie as well as
localStorage, and NEO's fetcher sends X-Analytics-Vid where it sends
X-Neo-Platform. Sign-ups, logins and a guest's own actions join the
device's visit, so funnels that end in them count again.

No database migration.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Analytics: NEO rounds a change of half a percent away from zero, as Studio does

Math.round(-15.5) is -15 in JavaScript, so NEO printed -15% where Studio and
the classic site print -16%. Round the absolute value instead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Analytics: NEO's client header test in TypeScript

The test ran as .mjs, outside the type check. As .ts it is checked with the
web app (allowImportingTsExtensions, since Node's type stripping needs the
.ts in the import path) and still runs with node --test on Node 24.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Analytics: concurrent hits, one visit per first hits, funnel order and Greptile fixes

Hits of one visit sent at once (tabs opened together, an event with a
pageview, a leave beacon racing the next page) now add to the session's
counters in SQL instead of writing back the row read before them, so no
pageview, event, bounce change or time is lost, and the exit page is the
visit's latest pageview by time whichever request is written last. Site
actions do the same.

A new visit is created under a named lock per visitor and platform, taken
only when a hit would start one and checked again under the lock, so a
pageview and an event sent together no longer start two visits.

Funnels order steps by second and then by the order events were stored:
a step recorded before the one before it in the same second no longer
counts, and one event reaches one step. Goals don't count an action
outside a visit (session 0) as a visit. Day and week buckets start at
local midnight whenever the period has a daylight saving change inside,
not only when its ends differ. The classic tracker sends outbound_link
and file_download events on link clicks.

No schema change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Analytics: multibyte helper for the settings lists

The ignore list and the other list settings dropped blank entries with
strlen; get_mb_len keeps them within UNA's text rule with the same result
("0" is kept). A line split that fails on invalid UTF-8 gives an empty list
instead of a TypeError.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Analytics: sha256 for the visit lock name

SonarCloud's php:S4790 flags md5 as a weak hash and failed the security
rating; the name only needs to be short and stable, so a cut sha256 works.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Analytics: look for the visit after a lock timeout; UNA download links

A first hit that can't get the visit lock now looks for the visit again
before starting one: the timeout most likely means another request was
starting it.

The classic tracker counts downloads by UNA's routes with permalinks off
(the file name in the r or f parameter, as Files' modules/?r=files/download/
<token>/<id>.<ext>), and masks the download token in the reported link, so
it can't open the file from a report.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Andrey Yasko 1 săptămână în urmă
părinte
comite
a3607cfcc8
100 a modificat fișierele cu 21541 adăugiri și 128 ștergeri
  1. 1 1
      inc/utils.inc.php
  2. 29 0
      modules/boonex/analytics/classes/BxAnalyticsAlertsResponse.php
  3. 1316 0
      modules/boonex/analytics/classes/BxAnalyticsCollector.php
  4. 363 7
      modules/boonex/analytics/classes/BxAnalyticsConfig.php
  5. 80 0
      modules/boonex/analytics/classes/BxAnalyticsCsv.php
  6. 2636 0
      modules/boonex/analytics/classes/BxAnalyticsDb.php
  7. 352 0
      modules/boonex/analytics/classes/BxAnalyticsErrors.php
  8. 297 0
      modules/boonex/analytics/classes/BxAnalyticsFilter.php
  9. 82 0
      modules/boonex/analytics/classes/BxAnalyticsGeo.php
  10. 1730 96
      modules/boonex/analytics/classes/BxAnalyticsModule.php
  11. 3 1
      modules/boonex/analytics/classes/BxAnalyticsPage.php
  12. 3006 0
      modules/boonex/analytics/classes/BxAnalyticsReports.php
  13. 436 0
      modules/boonex/analytics/classes/BxAnalyticsRequest.php
  14. 1107 0
      modules/boonex/analytics/classes/BxAnalyticsRevenue.php
  15. 280 0
      modules/boonex/analytics/classes/BxAnalyticsSource.php
  16. 55 0
      modules/boonex/analytics/classes/BxAnalyticsStudioMenu.php
  17. 49 0
      modules/boonex/analytics/classes/BxAnalyticsStudioMenuItems.php
  18. 128 0
      modules/boonex/analytics/classes/BxAnalyticsStudioPage.php
  19. 1952 4
      modules/boonex/analytics/classes/BxAnalyticsTemplate.php
  20. 311 0
      modules/boonex/analytics/classes/BxAnalyticsUserAgent.php
  21. 176 0
      modules/boonex/analytics/classes/BxAnalyticsVitals.php
  22. 13 0
      modules/boonex/analytics/install/installer.php
  23. 685 0
      modules/boonex/analytics/install/langs/en.xml
  24. 12 1
      modules/boonex/analytics/install/sql/disable.sql
  25. 32 0
      modules/boonex/analytics/install/sql/enable-app.sql
  26. 152 6
      modules/boonex/analytics/install/sql/enable.sql
  27. 303 0
      modules/boonex/analytics/install/sql/install.sql
  28. 3 0
      modules/boonex/analytics/install/sql/uninstall.sql
  29. 770 0
      modules/boonex/analytics/js/dashboard.js
  30. 362 0
      modules/boonex/analytics/js/studio_tools.js
  31. 750 0
      modules/boonex/analytics/js/tracker.js
  32. 24 0
      modules/boonex/analytics/template/api_params.html
  33. 14 0
      modules/boonex/analytics/template/bars.html
  34. 1 0
      modules/boonex/analytics/template/block.html
  35. 15 0
      modules/boonex/analytics/template/bots.html
  36. 7 0
      modules/boonex/analytics/template/code.html
  37. 21 0
      modules/boonex/analytics/template/credits.html
  38. 1683 0
      modules/boonex/analytics/template/css/dashboard.css
  39. 45 0
      modules/boonex/analytics/template/css/studio.css
  40. 43 0
      modules/boonex/analytics/template/error.html
  41. 4 0
      modules/boonex/analytics/template/error_missing.html
  42. 2 0
      modules/boonex/analytics/template/errors.html
  43. 25 0
      modules/boonex/analytics/template/event.html
  44. 35 0
      modules/boonex/analytics/template/filters.html
  45. 76 0
      modules/boonex/analytics/template/funnels.html
  46. 61 0
      modules/boonex/analytics/template/goals.html
  47. 1 0
      modules/boonex/analytics/template/icon.html
  48. 8 0
      modules/boonex/analytics/template/import.html
  49. 59 0
      modules/boonex/analytics/template/journeys.html
  50. 30 0
      modules/boonex/analytics/template/map.html
  51. 8 0
      modules/boonex/analytics/template/member.html
  52. 5 0
      modules/boonex/analytics/template/notes.html
  53. 15 0
      modules/boonex/analytics/template/overview.html
  54. 38 0
      modules/boonex/analytics/template/performance.html
  55. 48 0
      modules/boonex/analytics/template/query.html
  56. 1 0
      modules/boonex/analytics/template/rating.html
  57. 44 0
      modules/boonex/analytics/template/realtime.html
  58. 30 0
      modules/boonex/analytics/template/retention.html
  59. 8 0
      modules/boonex/analytics/template/revenue.html
  60. 16 0
      modules/boonex/analytics/template/sales.html
  61. 32 0
      modules/boonex/analytics/template/session.html
  62. 28 0
      modules/boonex/analytics/template/setup_check.html
  63. 29 0
      modules/boonex/analytics/template/setup_status.html
  64. 1 0
      modules/boonex/analytics/template/spark.html
  65. 55 0
      modules/boonex/analytics/template/studio_api.html
  66. 46 0
      modules/boonex/analytics/template/studio_dashboards.html
  67. 8 0
      modules/boonex/analytics/template/studio_menu.html
  68. 10 0
      modules/boonex/analytics/template/studio_section.html
  69. 60 0
      modules/boonex/analytics/template/studio_setup.html
  70. 36 0
      modules/boonex/analytics/template/studio_traffic.html
  71. 21 0
      modules/boonex/analytics/template/subscriptions.html
  72. 57 0
      modules/boonex/analytics/template/table.html
  73. 5 0
      modules/boonex/analytics/template/tabs.html
  74. 16 0
      modules/boonex/analytics/template/tiles.html
  75. 23 0
      modules/boonex/analytics/template/top.html
  76. 1 0
      modules/boonex/analytics/template/tracker.html
  77. 17 0
      modules/boonex/analytics/template/user.html
  78. 64 0
      modules/boonex/credits/classes/BxCreditsDb.php
  79. 104 0
      modules/boonex/credits/classes/BxCreditsModule.php
  80. 9 0
      modules/boonex/credits/install/langs/en.xml
  81. 2 1
      modules/boonex/credits/install/sql/install.sql
  82. 46 0
      modules/boonex/payment/classes/BxPaymentDb.php
  83. 60 2
      modules/boonex/payment/classes/BxPaymentModule.php
  84. 26 0
      modules/boonex/payment/classes/BxPaymentOrders.php
  85. 28 1
      modules/boonex/payment/classes/BxPaymentSubscriptions.php
  86. 4 0
      modules/boonex/payment/install/langs/en.xml
  87. 681 0
      modules/boonex/russian/data/langs/bx_analytics/ru.xml
  88. 9 0
      modules/boonex/russian/data/langs/bx_credits/ru.xml
  89. 4 0
      modules/boonex/russian/data/langs/bx_payment/ru.xml
  90. 2 1
      neo/AGENTS.md
  91. 1 0
      neo/README.md
  92. 2 0
      neo/apps/expo/app/(tabs)/_layout.js
  93. 14 1
      neo/apps/expo/index.js
  94. 2 0
      neo/apps/next/app/[...path]/page.js
  95. 9 2
      neo/apps/next/app/error.tsx
  96. 74 4
      neo/apps/next/app/global-error.tsx
  97. 3 0
      neo/apps/next/app/providers.js
  98. 18 0
      neo/apps/next/instrumentation-client.ts
  99. 38 0
      neo/apps/next/lib/client-headers.test.ts
  100. 58 0
      neo/apps/next/lib/client-headers.ts

+ 1 - 1
inc/utils.inc.php

@@ -2666,7 +2666,7 @@ function bx_api_check_access()
 
             if ('OPTIONS' == $_SERVER['REQUEST_METHOD']) {
                 header('Access-Control-Allow-Methods: POST, GET');
-                header('Access-Control-Allow-Headers: Accept-Encoding, Authorization, Cache-Control, Connection, Host, Origin, Pragma, Referer, User-Agent, X-Custom-Header, X-Requested-With');
+                header('Access-Control-Allow-Headers: Accept-Encoding, Authorization, Cache-Control, Connection, Host, Origin, Pragma, Referer, User-Agent, X-Custom-Header, X-Neo-Platform, X-Requested-With');
                 exit;
             }
         }

+ 29 - 0
modules/boonex/analytics/classes/BxAnalyticsAlertsResponse.php

@@ -0,0 +1,29 @@
+<?php defined('BX_DOL') or die('hack attempt');
+/**
+ * Copyright (c) UNA, Inc - https://una.io
+ * MIT License - https://opensource.org/licenses/MIT
+ *
+ * @defgroup    Analytics Analytics
+ * @ingroup     UnaModules
+ *
+ * @{
+ */
+
+class BxAnalyticsAlertsResponse extends BxDolAlertsResponse
+{
+    protected $_sModule;
+
+    public function __construct()
+    {
+        parent::__construct();
+
+        $this->_sModule = 'bx_analytics';
+    }
+
+    public function response($oAlert)
+    {
+        BxDolModule::getInstance($this->_sModule)->onAlert($oAlert);
+    }
+}
+
+/** @} */

+ 1316 - 0
modules/boonex/analytics/classes/BxAnalyticsCollector.php

@@ -0,0 +1,1316 @@
+<?php defined('BX_DOL') or die('hack attempt');
+/**
+ * Copyright (c) UNA, Inc - https://una.io
+ * MIT License - https://opensource.org/licenses/MIT
+ *
+ * @defgroup    Analytics Analytics
+ * @ingroup     UnaModules
+ *
+ * @{
+ */
+
+/**
+ * Takes a batch of hits from a tracker, decides whether to keep them, adds what the server
+ * knows (visitor id, member, country, browser, traffic source) and writes sessions and events.
+ *
+ * Payload, as JSON from the classic tracker, NEO or any API client:
+ *   platform     core-web | next-web | rn-ios | rn-android | api
+ *   app, app_version   bundle id or client name and its version (apps)
+ *   referrer     document.referrer or the app's referrer, for a new session
+ *   language     navigator.language
+ *   screen       "1440x900"
+ *   vid          random visitor id kept by the client, used in persistent identity mode only;
+ *                site requests carry it in the bx_analytics_vid cookie or the X-Analytics-Vid
+ *                header, so the site's own actions join the device's visit
+ *   os, os_version, device   sent by native apps, which have no browser user agent
+ *   hits         list of {type: pageview|event|leave, url, title, name, value, props, engaged,
+ *                module, object_id, context_id, age}; one hit may also be given at the top level
+ *
+ * Health hits (see BxAnalyticsModule::serviceGetTrackerConfig for the client settings: send
+ * them only when `errors` is true, and vitals only for `vitals_sample` percent of page loads):
+ *
+ *   {type: 'error', url, kind, message, source, line, col, stack, component_stack, fatal,
+ *    repeats, status, age, deferred}
+ *     kind       js (uncaught error), promise (unhandled rejection), react (error boundary),
+ *                native (RN global handler), http (a request answered 500 or more), resource
+ *                (a script or style that failed to load)
+ *     message    the error's message, up to 500 characters (255 are kept); for http
+ *                "HTTP 502 /api.php" (no query string), or empty with `status` set
+ *     source, line, col   the script and position, when known
+ *     stack      the stack as the runtime prints it (V8/Hermes "at fn (file:1:2)" or
+ *                Firefox/Safari "fn@file:1:2"), up to 8000 characters; component_stack is
+ *                React's component stack of an error boundary
+ *     fatal      1 when the app or page could not go on (a root boundary, isFatal on native)
+ *     repeats    how many times it happened on this page or screen, 1 to 100 (deduplicate
+ *                on the client and count instead of sending each)
+ *     status     HTTP status, http kind only
+ *     age        milliseconds since it happened, up to 7 days: an app that crashed sends the
+ *                error on its next launch with `deferred: 1` and the real age
+ *   Error hits never start a visit. They join the visitor's visit on the platform they
+ *   happened in (the running one, or for a deferred error the earlier one it ended), or are
+ *   stored on their own (an app that crashes before its first page). At most 10 per request
+ *   and 50 per visit; messages matching the ignore list are dropped. Strip query strings
+ *   before sending (keep only `i` and `r`); the server strips them again and redacts tokens
+ *   and emails.
+ *
+ *   {type: 'vitals', url, nav, metrics: {ttfb, fcp, lcp, inp, cls, screen_load, app_start}}
+ *     one per page view or screen, sent once when it is left or hidden (with the leave hit);
+ *     timings in milliseconds (0 to 120000), cls a score (0 to 10); leave out what wasn't
+ *     measured. Web pages: ttfb, fcp, lcp, inp, cls on a full load; screen_load (and cls,
+ *     inp) after a soft navigation. Apps: screen_load, from the navigation to the screen's
+ *     content laid out; app_start once per cold start, from process start to the first page.
+ *     nav: navigate, reload, back_forward, prerender, soft, restore.
+ *   Vitals join a running visit and are dropped without one; they count no pageview or event.
+ *
+ * Rate limits: one address (an IPv6 address by its /64 network) may send so many hits a
+ * minute and start so many visits an hour (Settings > Tracking). Past a limit the hits are
+ * dropped quietly and the request is answered as usual, see _isWithinHitLimit. Site actions,
+ * custom events from server code and payments don't come through collect() and aren't limited.
+ */
+class BxAnalyticsCollector
+{
+    protected $_oModule;
+    protected $_oConfig;
+    protected $_oDb;
+    protected $_oRequest;
+    protected $_iNow;
+
+    protected $_aSalts = [];
+    protected $_sIgnoredParams = null;
+
+    /**
+     * Whether the last collect() dropped its hits for a rate limit.
+     */
+    protected $_bLimited = false;
+
+    /**
+     * Query parameters removed from stored paths: campaign tags and click ids are in the
+     * session's source already, and would split one page into many rows.
+     */
+    protected $_aDropParams = [
+        'utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'utm_id',
+        'gclid', 'gbraid', 'wbraid', 'dclid', 'msclkid', 'fbclid', 'ttclid', 'twclid', 'li_fat_id',
+        'yclid', 'mc_cid', 'mc_eid', 'igshid', 'sccid', 'epik', '_ga', '_gl', 'ref', 'source', '_r', '_t',
+    ];
+
+    /**
+     * Query parameters that carry secrets or a person's identity, dropped from stored paths
+     * with their values: a password reset key, an email confirmation or unsubscribe code, an
+     * OAuth code, an invitation or API token, an address, a mail recipient's tracking id.
+     * Reports show paths to everyone who may use analytics, and keep them for months.
+     */
+    protected $_aSecretParams = [
+        'key', 'code', 'token', 'hash', 'secret', 'password', 'pass', 'passwd', 'pwd', 'auth', 'sid', 'sk',
+        'otp', 'email', 'session', 'ticket', 'invite', 'icode', 'confirm', 'signature', 'sig',
+        '_hsenc', '_hsmi', 'mkt_tok', '_ke',
+    ];
+
+    /**
+     * Compound names of such parameters, with or without a separator: api_key, access_token,
+     * reset-code, user.email, apikey, icode (an invitation), promocode, passcode... A few
+     * harmless names that end the same way (zipcode) are dropped too. `sid` only after a
+     * separator, as too many words end in it.
+     */
+    protected $_sSecretParamsPattern = '/(?:key|code|token|hash|secret|password|passwd|pwd|auth|session|otp|email|signature|[_.-]sid)$/';
+
+    /**
+     * Seconds between a guest visit's last hit and a member's hit for the visit to become the
+     * member's (signing in), when the guest is known only by address and browser.
+     */
+    const SIGN_IN_SECONDS = 120;
+
+    public function __construct($oModule, $oRequest = null, $iNow = null)
+    {
+        $this->_oModule = $oModule;
+        $this->_oConfig = $oModule->_oConfig;
+        $this->_oDb = $oModule->_oDb;
+        $this->_oRequest = $oRequest ? $oRequest : new BxAnalyticsRequest();
+        $this->_iNow = $iNow ? (int)$iNow : time();
+    }
+
+    /**
+     * @param array $aPayload decoded tracker payload, see the class description
+     * @param array $aContext what the entry point knows: `platform` (default when the payload
+     *      has none), `trusted` (the request carries a valid API key), `profile_id` (signed-in
+     *      profile), `same_origin` (hits must come from the site's own pages)
+     * @return int number of hits stored
+     */
+    public function collect($aPayload, $aContext = [])
+    {
+        $this->_bLimited = false;
+        if(!is_array($aPayload))
+            return 0;
+
+        $aAccept = $this->_accept($aPayload, $aContext);
+        $bBot = !empty($aAccept['reasons']) && $aAccept['reasons'][0] == 'bot';
+        if(!empty($aAccept['reasons']) && !$bBot)
+            return 0;
+
+        // a flood from one address, a headless bot's too, writes nothing past the limit
+        if(!$this->_isWithinHitLimit($aAccept['ip'], count($aAccept['hits'])))
+            return 0;
+
+        // a bot that runs the tracker (a headless browser): counted with the bots
+        if($bBot) {
+            $this->collectBot($aAccept['user_agent'], $aAccept['hits'][0]['url'], $aAccept['platform']);
+            return 0;
+        }
+
+        $sPlatform = $aAccept['platform'];
+        $aHits = $aAccept['hits'];
+        $sIp = $aAccept['ip'];
+        $sUserAgent = $aAccept['user_agent'];
+        $aClient = $aAccept['client'];
+        $iProfileId = $aAccept['profile_id'];
+
+        $sVid = isset($aPayload['vid']) && is_string($aPayload['vid']) && BxAnalyticsRequest::isVisitorId($aPayload['vid']) ? $aPayload['vid'] : '';
+        $aVisitor = $this->_getVisitor($sIp, $sUserAgent, $iProfileId, $sVid);
+
+        // what an error hit outside a visit records about the client
+        $aClientRow = [
+            'ids' => $aVisitor,
+            'visitor' => $aVisitor['id'],
+            'profile_id' => $iProfileId,
+            'platform' => $sPlatform,
+            'app_version' => $this->_string($aPayload, 'app_version', 32, '/^[a-zA-Z0-9._+ -]+$/'),
+            'browser' => $aClient['browser'],
+            'os' => $aClient['os'],
+            'device' => $aClient['device'],
+            'country' => ($sEdge = $this->_oConfig->getGeoEdge()) !== false ? $this->_oRequest->getCountry($sEdge) : '',
+        ];
+
+        $aSession = $this->_getSession($sPlatform, $aVisitor, $iProfileId, $aPayload, $aClient, $aHits, $sIp);
+        if($this->_bLimited)
+            return 0;
+
+        if(empty($aSession)) {
+            // errors without a visit (an app that crashed before its first page, a beacon after
+            // the visit timed out) are kept on their own; vitals belong to a page view
+            $aErrors = array_filter($aHits, function($aHit) { return $aHit['type'] == BX_ANALYTICS_HIT_ERROR; });
+
+            return $this->_storeErrors([], $aErrors, $aClientRow);
+        }
+
+        return $this->_store($aSession, $aHits, $aClientRow);
+    }
+
+    /**
+     * Whether the last collect() dropped its hits because the address was over a rate limit.
+     */
+    public function isLimited()
+    {
+        return $this->_bLimited;
+    }
+
+    /**
+     * What collect() would do with a payload, without storing anything: the reasons it would
+     * drop the hits (or `ok`), and what it knows about the request. For the Setup section's
+     * check of the admin's own browser.
+     *
+     * @param array $aPayload decoded tracker payload
+     * @param array $aContext as for collect()
+     * @return array reasons (tracking_off, platform_off, dnt, cross_site, excluded_path,
+     *      no_hits, excluded_ip, bot, excluded_level, rate_limited; or ok), notes
+     *      (link_members_off: stored, but not tied to the member), platform, ip (resolved),
+     *      remote_addr, country_header, country, geo_source, identity
+     */
+    public function check($aPayload, $aContext = [])
+    {
+        $aAccept = $this->_accept(is_array($aPayload) ? $aPayload : [], $aContext, true);
+        if($this->_isOverLimit($aAccept['ip']))
+            $aAccept['reasons'][] = 'rate_limited';
+        $sEdge = $this->_oConfig->getGeoEdge();
+        $aCountry = $this->_oRequest->getCountryHeader($sEdge !== false ? $sEdge : '');
+
+        return [
+            'reasons' => !empty($aAccept['reasons']) ? $aAccept['reasons'] : ['ok'],
+            'notes' => $aAccept['notes'],
+            'platform' => $aAccept['platform'],
+            'ip' => $aAccept['ip'],
+            'remote_addr' => $this->_oRequest->getRemoteAddr(),
+            'country_header' => $aCountry ? $aCountry['header'] : '',
+            'country' => $aCountry ? $aCountry['country'] : '',
+            'geo_source' => $this->_oConfig->getGeoSource(),
+            'identity' => $this->_oConfig->getIdentityMode(),
+        ];
+    }
+
+    /**
+     * The guards of collect() in one place: whether a payload's hits are kept, and what the
+     * request tells about the visitor. collect() stops at the first reason; a check collects
+     * them all.
+     *
+     * @param bool $bAll go on after a reason, to list every one
+     * @return array reasons (empty when the hits are kept), notes, platform, hits, ip,
+     *      user_agent, client (false for a bot), profile_id (0 when members aren't linked)
+     */
+    protected function _accept($aPayload, $aContext, $bAll = false)
+    {
+        $aResult = [
+            'reasons' => [],
+            'notes' => [],
+            'platform' => isset($aPayload['platform']) && is_string($aPayload['platform']) ? $aPayload['platform'] : (isset($aContext['platform']) ? $aContext['platform'] : BX_ANALYTICS_PLATFORM_API),
+            'hits' => [],
+            'ip' => '',
+            'user_agent' => '',
+            'client' => false,
+            'profile_id' => 0,
+        ];
+
+        // true when the caller should stop here
+        $fReject = function($sReason) use (&$aResult, $bAll) {
+            $aResult['reasons'][] = $sReason;
+            return !$bAll;
+        };
+
+        if(!$this->_oConfig->isTrackingEnabled() && $fReject('tracking_off'))
+            return $aResult;
+
+        // `core` is the server's own platform, never a client's
+        $sPlatform = $aResult['platform'];
+        if(($sPlatform == BX_ANALYTICS_PLATFORM_CORE || !$this->_oConfig->isPlatformEnabled($sPlatform)) && $fReject('platform_off'))
+            return $aResult;
+
+        if($this->_oConfig->isRespectDnt() && $this->_oRequest->isDoNotTrack() && $fReject('dnt'))
+            return $aResult;
+
+        $bSameOrigin = !empty($aContext['same_origin']);
+        if($bSameOrigin && $this->_oRequest->isCrossSite() && $fReject('cross_site'))
+            return $aResult;
+
+        $aResult['hits'] = $this->_getHits($aPayload, $bSameOrigin);
+        if(empty($aResult['hits']) && $fReject($this->_getNoHitsReason($aPayload)))
+            return $aResult;
+
+        $aResult['ip'] = $this->_oRequest->getIp($this->_oConfig->getTrustedProxies(), !empty($aContext['trusted']));
+        if($aResult['ip'] !== '' && BxAnalyticsRequest::inList($aResult['ip'], $this->_oConfig->getExcludedIps()) && $fReject('excluded_ip'))
+            return $aResult;
+
+        $aResult['user_agent'] = $this->_oRequest->getUserAgent();
+        $aResult['client'] = $this->_getClient($sPlatform, $aPayload, $aResult['user_agent']);
+        if($aResult['client'] === false && !empty($aResult['hits']) && $fReject('bot'))
+            return $aResult;
+
+        $iProfileId = isset($aContext['profile_id']) ? (int)$aContext['profile_id'] : 0;
+        $bExcluded = $iProfileId ? $this->_isExcludedProfile($iProfileId) : in_array(MEMBERSHIP_ID_NON_MEMBER, $this->_oConfig->getExcludedLevels(), true);
+        if($bExcluded && $fReject('excluded_level'))
+            return $aResult;
+
+        if($iProfileId && !$this->_oConfig->isLinkMembers()) {
+            $aResult['notes'][] = 'link_members_off';
+            $iProfileId = 0;
+        }
+        $aResult['profile_id'] = $iProfileId;
+
+        return $aResult;
+    }
+
+    /**
+     * Why a payload has no usable hits: all of them on excluded paths, or none valid.
+     */
+    protected function _getNoHitsReason($aPayload)
+    {
+        $aInput = isset($aPayload['hits']) && is_array($aPayload['hits']) ? $aPayload['hits'] : [$aPayload];
+        foreach($aInput as $aHit) {
+            $aUrl = is_array($aHit) && isset($aHit['url']) && is_string($aHit['url']) ? $this->_parseUrl($aHit['url']) : false;
+            if($aUrl !== false && $this->_isExcludedPath($aUrl['path'], $this->_oConfig->getExcludedPaths()))
+                return 'excluded_path';
+        }
+
+        return 'no_hits';
+    }
+
+    /**
+     * Count a request from a bot. Bots don't run the tracker, so the page itself reports
+     * them as it renders. They are kept as totals per day, bot and page, never as visits.
+     *
+     * @param string $sUserAgent the request's user agent
+     * @param string $sUrl the requested URL or path
+     * @param string $sPlatform where the request came to
+     * @return bool whether it was a bot and was counted
+     */
+    public function collectBot($sUserAgent, $sUrl, $sPlatform = BX_ANALYTICS_PLATFORM_CORE_WEB)
+    {
+        if(!$this->_oConfig->isTrackingEnabled() || !$this->_oConfig->isTrackBots() || !$this->_oConfig->isPlatform($sPlatform))
+            return false;
+
+        $aBot = BxAnalyticsUserAgent::getBot($sUserAgent);
+        if(!$aBot)
+            return false;
+
+        $aUrl = $this->_parseUrl((string)$sUrl);
+        $sPath = $aUrl !== false ? $aUrl['path'] : '';
+        if($sPath !== '' && $this->_isExcludedPath($sPath, $this->_oConfig->getExcludedPaths()))
+            return false;
+
+        return $this->_oDb->addBotHit(gmdate('Y-m-d', $this->_iNow), $aBot['name'], $aBot['category'], $sPlatform, $sPath, $this->_iNow);
+    }
+
+    /**
+     * Record something done on the site (a sign-up, a post, a comment, a reaction) as an
+     * event of type `una`, from whatever client did it. The event joins the actor's running
+     * visit on that client when there is one, so funnels and journeys see it.
+     *
+     * @param string $sName action name: signup, login, profile_add, content_add, comment, vote, connection...
+     * @param string $sModule module or object system the action happened in
+     * @param int $iObjectId content, comment or profile the action is about
+     * @param int $iProfileId who did it
+     * @param array $aContext `platform` (otherwise worked out from the request), `api_key` (the
+     *      request carries an accepted API key), `props`, `value`, `type` (`event` for a custom
+     *      event from server code, which the "track site actions" setting doesn't turn off),
+     *      `request` (whether the request is the actor's own, whose Do Not Track, address and
+     *      user agent apply; by default any request but the server platform's)
+     * @return bool whether it was stored (not when tracking is off, the platform isn't
+     *      enabled, the member's or the guest's level is excluded, or the request isn't
+     *      tracked: Do Not Track when respected, an excluded address, a bot)
+     */
+    public function collectAction($sName, $sModule, $iObjectId, $iProfileId, $aContext = [])
+    {
+        $sType = isset($aContext['type']) && $aContext['type'] === BX_ANALYTICS_HIT_EVENT ? BX_ANALYTICS_HIT_EVENT : BX_ANALYTICS_HIT_UNA;
+        if(!$this->_oConfig->isTrackingEnabled() || ($sType == BX_ANALYTICS_HIT_UNA && !$this->_oConfig->isTrackActions()))
+            return false;
+
+        $sPlatform = !empty($aContext['platform']) ? $aContext['platform'] : $this->_oRequest->getPlatform(defined('BX_API'), !empty($aContext['api_key']));
+        // a platform left out of Settings > Platforms collects nothing, server events included
+        if(!$this->_oConfig->isPlatformEnabled($sPlatform))
+            return false;
+
+        // the same rules as the visitor's own hits; a cron job or a webhook (the server's
+        // platform) has no visitor request to read them from
+        $iProfileId = (int)$iProfileId;
+        $bRequest = isset($aContext['request']) ? (bool)$aContext['request'] : $sPlatform != BX_ANALYTICS_PLATFORM_CORE;
+        if(!$this->isTracked($sPlatform, $iProfileId, !empty($aContext['api_key']), $bRequest))
+            return false;
+
+        if(!$iProfileId && in_array(MEMBERSHIP_ID_NON_MEMBER, $this->_oConfig->getExcludedLevels(), true))
+            return false;
+
+        $iLinkedProfileId = $this->_oConfig->isLinkMembers() ? $iProfileId : 0;
+        list($aVisitor, $aSession) = $this->_getRequestVisit($sPlatform, $iLinkedProfileId, !empty($aContext['api_key']));
+        // signing in (`login`) is a member's action in the guest's visit: the visit is theirs now
+        if(!empty($aSession))
+            $aSession = $this->_takeOver($aSession, $aVisitor, $iLinkedProfileId);
+
+        $aProps = isset($aContext['props']) && is_array($aContext['props']) ? $this->_getProps($aContext['props']) : [];
+
+        $iStored = $this->_oDb->insertEvents([[
+            'session_id' => !empty($aSession) ? (int)$aSession['id'] : 0,
+            'visitor' => !empty($aSession) ? $aSession['visitor'] : $aVisitor['id'],
+            'profile_id' => $iLinkedProfileId,
+            'platform' => $sPlatform,
+            'ts' => $this->_iNow,
+            'type' => $sType,
+            'name' => get_mb_substr((string)$sName, 0, 64),
+            'hostname' => '',
+            'path' => '',
+            'title' => '',
+            'module' => get_mb_substr(preg_replace('/[^a-z0-9_]/', '', mb_strtolower((string)$sModule)), 0, 32),
+            'object_id' => max(0, (int)$iObjectId),
+            'context_id' => 0,
+            'value' => isset($aContext['value']) && is_numeric($aContext['value']) ? (float)$aContext['value'] : 0,
+            'engaged' => 0,
+            'props' => empty($aProps) ? '' : json_encode($aProps, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
+        ]]);
+
+        if($iStored && !empty($aSession))
+            $this->_oDb->addSessionHits($aSession['id'], 0, 1, true, $this->_iNow, false);
+
+        return $iStored > 0;
+    }
+
+    /**
+     * Whether something a member did may be tied to their visits: tracking is on and the
+     * member's level isn't excluded. For the member's own request (`$bRequest`) also: its
+     * platform is enabled, it doesn't ask not to be tracked (when that is respected), its
+     * address isn't excluded and a browser platform isn't a bot.
+     *
+     * @param string $sPlatform platform of the request
+     * @param int $iProfileId the member
+     * @param bool $bApiKey the request carries an accepted API key
+     * @param bool $bRequest the request is the member's own, not a webhook or a cron job
+     */
+    public function isTracked($sPlatform, $iProfileId, $bApiKey = false, $bRequest = true)
+    {
+        if(!$this->_oConfig->isTrackingEnabled())
+            return false;
+
+        if((int)$iProfileId && $this->_isExcludedProfile((int)$iProfileId))
+            return false;
+
+        if(!$bRequest)
+            return true;
+
+        if(!$this->_oConfig->isPlatformEnabled($sPlatform))
+            return false;
+
+        if($this->_oConfig->isRespectDnt() && $this->_oRequest->isDoNotTrack())
+            return false;
+
+        $sIp = $this->_oRequest->getIp($this->_oConfig->getTrustedProxies(), $bApiKey);
+        if($sIp !== '' && BxAnalyticsRequest::inList($sIp, $this->_oConfig->getExcludedIps()))
+            return false;
+
+        if($this->_oConfig->isBrowserPlatform($sPlatform) && BxAnalyticsUserAgent::isBot($this->_oRequest->getUserAgent()))
+            return false;
+
+        return true;
+    }
+
+    /**
+     * The running visit of the request's visitor on a platform, or an empty array.
+     *
+     * @param string $sPlatform platform of the request
+     * @param int $iProfileId the signed-in member, used when members are linked to visits
+     * @param bool $bApiKey the request carries an accepted API key
+     */
+    public function getRequestSession($sPlatform, $iProfileId, $bApiKey = false)
+    {
+        list(, $aSession) = $this->_getRequestVisit($sPlatform, $this->_oConfig->isLinkMembers() ? (int)$iProfileId : 0, $bApiKey);
+
+        return $aSession;
+    }
+
+    /**
+     * Visitor ids of the current request and its running visit on a platform. The server's
+     * own platform has no visits: its visitor is the member, or nobody.
+     *
+     * @return array [visitor ids, session or []]
+     */
+    protected function _getRequestVisit($sPlatform, $iLinkedProfileId, $bApiKey)
+    {
+        if($sPlatform == BX_ANALYTICS_PLATFORM_CORE)
+            return [['id' => $iLinkedProfileId ? $this->_hash('p:' . $iLinkedProfileId, BX_DOL_SECRET) : str_repeat("\0", 16)], []];
+
+        // the device's own id, in persistent mode: the classic tracker keeps it in a cookie, NEO sends it in a header
+        $aVisitor = $this->_getVisitor($this->_oRequest->getIp($this->_oConfig->getTrustedProxies(), $bApiKey), $this->_oRequest->getUserAgent(), $iLinkedProfileId, $this->_oRequest->getVisitorId());
+
+        // a visit that started after the action (a clock given by a test or an import) isn't its visit
+        return [$aVisitor, $this->_findSession($aVisitor, $sPlatform, $this->_iNow - $this->_oConfig->getSessionTimeout(), $this->_iNow, $this->_iNow)];
+    }
+
+    /**
+     * Visitor ids for a hit: `id` to store, `lookup` to find a running session.
+     * Members get one id across devices and days. Persistent mode uses the client's own id.
+     * Cookieless mode hashes the address and user agent with a salt that changes daily, and
+     * also checks yesterday's id so a visit doesn't split at midnight.
+     *
+     * @return array id, lookup, anonymous (the ids of the guest), member (a member's ids),
+     *      device (the guest id is the device's own, not the address and browser)
+     */
+    protected function _getVisitor($sIp, $sUserAgent, $iProfileId, $sVid)
+    {
+        $bDevice = $sVid !== '' && $this->_oConfig->getIdentityMode() == BX_ANALYTICS_IDENTITY_PERSISTENT;
+        if($bDevice)
+            $aAnonymous = [$this->_hash('v:' . $sVid, BX_DOL_SECRET)];
+        else {
+            $sToday = gmdate('Y-m-d', $this->_iNow);
+            $sYesterday = gmdate('Y-m-d', $this->_iNow - 86400);
+            $this->_loadSalts([$sToday, $sYesterday]);
+
+            $aAnonymous = [
+                $this->_hash($sIp . '|' . $sUserAgent, $this->_getSalt($sToday)),
+                $this->_hash($sIp . '|' . $sUserAgent, $this->_getSalt($sYesterday)),
+            ];
+        }
+
+        if($iProfileId) {
+            $sMember = $this->_hash('p:' . $iProfileId, BX_DOL_SECRET);
+            return ['id' => $sMember, 'lookup' => array_merge([$sMember], $aAnonymous), 'anonymous' => $aAnonymous, 'member' => true, 'device' => $bDevice];
+        }
+
+        return ['id' => $aAnonymous[0], 'lookup' => $aAnonymous, 'anonymous' => $aAnonymous, 'member' => false, 'device' => $bDevice];
+    }
+
+    /**
+     * The visitor's latest visit on a platform active since a time. A member's own visit comes
+     * first. A guest visit is the member's (they signed in during it) when the guest id is the
+     * device's own, or else only when the visit was active just before: people behind one
+     * address (a mobile carrier, an office) often have the same browser, and so the same
+     * cookieless id, and one signing in mustn't take over another's visit.
+     *
+     * @param int $iUntil the visit started by then, 0 for any
+     * @param int $iAt when the member's hit happened
+     * @return array the visit or []
+     */
+    protected function _findSession($aVisitor, $sPlatform, $iSince, $iUntil = 0, $iAt = 0)
+    {
+        if(empty($aVisitor['member']))
+            return $this->_oDb->getActiveSession($aVisitor['lookup'], $sPlatform, $iSince, $iUntil);
+
+        $aSession = $this->_oDb->getActiveSession([$aVisitor['id']], $sPlatform, $iSince, $iUntil);
+        if(!empty($aSession))
+            return $aSession;
+
+        if(empty($aVisitor['device']))
+            $iSince = max($iSince, ($iAt ? $iAt : $this->_iNow) - self::SIGN_IN_SECONDS);
+
+        return $this->_oDb->getActiveSession($aVisitor['anonymous'], $sPlatform, $iSince, $iUntil);
+    }
+
+    /**
+     * A guest visit found for a member (see _findSession) becomes the member's: the visit and
+     * every event recorded in it so far get the member's visitor id and profile, so one visit
+     * isn't counted as two people and the member's actions include those before signing in.
+     * Error hits keep the guest's id.
+     *
+     * @return array the visit as it is now
+     */
+    protected function _takeOver($aSession, $aVisitor, $iProfileId)
+    {
+        if(!$iProfileId || (int)$aSession['profile_id'] || $aSession['visitor'] === $aVisitor['id'])
+            return $aSession;
+
+        $this->_oDb->updateSession($aSession['id'], ['profile_id' => $iProfileId, 'visitor' => $aVisitor['id']]);
+        $this->_oDb->assignSessionEvents($aSession['id'], $aVisitor['id'], $iProfileId);
+
+        $aSession['profile_id'] = $iProfileId;
+        $aSession['visitor'] = $aVisitor['id'];
+
+        return $aSession;
+    }
+
+    /**
+     * The visitor's running session on this platform, or a new one. A guest session that
+     * signs in continues as the member's (see _findSession). No new one past the address's
+     * limit of visits an hour (`_bLimited` tells).
+     *
+     * @param string $sIp the client's address, for the rate limit
+     */
+    protected function _getSession($sPlatform, $aVisitor, $iProfileId, $aPayload, $aClient, $aHits, $sIp = '')
+    {
+        $aSession = $this->_findSession($aVisitor, $sPlatform, $this->_iNow - $this->_oConfig->getSessionTimeout());
+        if(!empty($aSession))
+            return $this->_takeOver($aSession, $aVisitor, $iProfileId);
+
+        // a hit that only closes a page, reports an error or a page's timings doesn't start a visit
+        $aFirst = null;
+        foreach($aHits as $aHit)
+            if(!in_array($aHit['type'], [BX_ANALYTICS_HIT_LEAVE, BX_ANALYTICS_HIT_ERROR, BX_ANALYTICS_HIT_VITALS], true)) {
+                $aFirst = $aHit;
+                break;
+            }
+        if($aFirst === null)
+            return [];
+
+        // two first requests of a visit at once (a pageview and an event, tabs opened together)
+        // would both find nothing and start two visits: the second waits for the first and
+        // finds its visit. Without the lock (a timeout, most likely while another request started
+        // this visit) it looks again and then goes on as before rather than drop hits.
+        $sLock = $this->_getVisitLockName($sPlatform, $aVisitor);
+        $bLocked = $this->_oDb->lockVisit($sLock, 2);
+        try {
+            $aSession = $this->_findSession($aVisitor, $sPlatform, $this->_iNow - $this->_oConfig->getSessionTimeout());
+            if(!empty($aSession))
+                return $this->_takeOver($aSession, $aVisitor, $iProfileId);
+
+            return $this->_startSession($sPlatform, $aVisitor, $iProfileId, $aPayload, $aClient, $aFirst, $sIp);
+        }
+        finally {
+            if($bLocked)
+                $this->_oDb->unlockVisit($sLock);
+        }
+    }
+
+    /**
+     * The name of the lock that starts a visit, one per visitor and platform; MySQL allows 64
+     * characters, so the hash is cut to 40.
+     */
+    protected function _getVisitLockName($sPlatform, $aVisitor)
+    {
+        return 'bx_analytics_s_' . substr(hash('sha256', $sPlatform . '|' . $aVisitor['id']), 0, 40);
+    }
+
+    /**
+     * A new visit that starts with a hit.
+     */
+    protected function _startSession($sPlatform, $aVisitor, $iProfileId, $aPayload, $aClient, $aFirst, $sIp)
+    {
+        // a script that changes its user agent or visitor id for every request would start a
+        // visit with each
+        if(!$this->_isWithinVisitLimit($sIp))
+            return [];
+
+        $sReferrer = isset($aPayload['referrer']) && is_string($aPayload['referrer']) ? $aPayload['referrer'] : '';
+        $aSource = BxAnalyticsSource::classify($sReferrer, $aFirst['url'], $this->_oConfig->getInternalHosts());
+
+        $sLanguage = isset($aPayload['language']) && is_string($aPayload['language']) && preg_match('/^[a-zA-Z]{2,3}(-[a-zA-Z0-9]{2,4})?$/', $aPayload['language']) ? $aPayload['language'] : $this->_oRequest->getLanguage();
+        $sScreen = isset($aPayload['screen']) && is_string($aPayload['screen']) && preg_match('/^\d{2,5}x\d{2,5}$/', $aPayload['screen']) ? $aPayload['screen'] : '';
+
+        $aGeo = ['country' => '', 'region' => '', 'city' => '', 'lat' => null, 'lng' => null];
+        if(($sEdge = $this->_oConfig->getGeoEdge()) !== false) {
+            $aCoordinates = $this->_oRequest->getCoordinates($sEdge);
+            $aGeo = [
+                'country' => $this->_oRequest->getCountry($sEdge),
+                'region' => $this->_oRequest->getRegion($sEdge),
+                'city' => $this->_oRequest->getCity($sEdge),
+                'lat' => $aCoordinates ? $aCoordinates[0] : null,
+                'lng' => $aCoordinates ? $aCoordinates[1] : null,
+            ];
+        }
+
+        $iStarted = $aFirst['ts'];
+        $aSession = array_merge([
+            'visitor' => $aVisitor['id'],
+            'profile_id' => $iProfileId,
+            'platform' => $sPlatform,
+            'app' => $this->_string($aPayload, 'app', 64, '/^[a-zA-Z0-9._ -]+$/'),
+            'app_version' => $this->_string($aPayload, 'app_version', 32, '/^[a-zA-Z0-9._+ -]+$/'),
+            'started' => $iStarted,
+            'last_seen' => $iStarted,
+            'duration' => 0,
+            'pageviews' => 0,
+            'events' => 0,
+            'bounce' => 1,
+            'hostname' => $aFirst['hostname'],
+            'entry_path' => $aFirst['path'],
+            'exit_path' => $aFirst['path'],
+            'language' => get_mb_substr($sLanguage, 0, 16),
+            'screen' => $sScreen,
+        ], $aSource, $aGeo, $aClient);
+
+        unset($aSession['bot']);
+
+        $aSession['id'] = $this->_oDb->insertSession($aSession);
+        if(!$aSession['id'])
+            return [];
+
+        return $aSession;
+    }
+
+    /**
+     * Write the hits and add them to the session's counters in one query. The counters change
+     * in the database, not from `$aSession`, which another request of the visit may have
+     * changed since it was read.
+     *
+     * @param array $aClientRow the client's columns for errors that don't belong to the visit
+     */
+    protected function _store($aSession, $aHits, $aClientRow = [])
+    {
+        $iPageviews = 0;
+        $iEvents = 0;
+        $bInteractive = false;
+        $iLastSeen = 0;
+
+        $aRows = [];
+        $aErrors = [];
+        $iStored = 0;
+        foreach($aHits as $aHit) {
+            if($aHit['type'] == BX_ANALYTICS_HIT_ERROR) {
+                $aErrors[] = $aHit;
+                if($this->_isSessionError($aSession, $aHit))
+                    $iLastSeen = max($iLastSeen, $aHit['ts']);
+                continue;
+            }
+
+            $iLastSeen = max($iLastSeen, $aHit['ts']);
+
+            // a page's timings: a row of their own, no pageview or event
+            if($aHit['type'] == BX_ANALYTICS_HIT_VITALS) {
+                $iStored += $this->_oDb->insertVitals(array_merge([
+                    'ts' => $aHit['ts'],
+                    'session_id' => (int)$aSession['id'],
+                    'platform' => $aSession['platform'],
+                    'device' => $aSession['device'],
+                    'path' => $aHit['path'],
+                    'route' => $aHit['route'],
+                    'nav' => $aHit['vitals']['nav'],
+                ], $aHit['vitals']['metrics']));
+                continue;
+            }
+
+            if($aHit['type'] == BX_ANALYTICS_HIT_LEAVE) {
+                if($aHit['engaged'] > 0)
+                    $this->_oDb->addEngagedTime($aSession['id'], $aHit['path'], $aHit['engaged'], $this->_oConfig->CNF['MAX_ENGAGED_MS']);
+                $iStored++;
+                continue;
+            }
+
+            if($aHit['type'] == BX_ANALYTICS_HIT_PAGEVIEW)
+                $iPageviews++;
+            else {
+                $iEvents++;
+                if(empty($aHit['non_interactive']))
+                    $bInteractive = true;
+            }
+
+            $aRows[] = [
+                'session_id' => $aSession['id'],
+                'visitor' => $aSession['visitor'],
+                'profile_id' => (int)$aSession['profile_id'],
+                'platform' => $aSession['platform'],
+                'ts' => $aHit['ts'],
+                'type' => $aHit['type'],
+                'name' => $aHit['name'],
+                'hostname' => $aHit['hostname'],
+                'path' => $aHit['path'],
+                'title' => $aHit['title'],
+                'module' => $aHit['module'],
+                'object_id' => $aHit['object_id'],
+                'context_id' => $aHit['context_id'],
+                'value' => $aHit['value'],
+                'engaged' => 0,
+                'props' => $aHit['props'],
+            ];
+        }
+
+        if(!empty($aRows))
+            $iStored += $this->_oDb->insertEvents($aRows);
+
+        // after the events: the exit page is the visit's latest pageview among them
+        $this->_oDb->addSessionHits($aSession['id'], $iPageviews, $iEvents, $bInteractive, $iLastSeen, $iPageviews > 0);
+
+        if(!empty($aErrors))
+            $iStored += $this->_storeErrors($aSession, $aErrors, $aClientRow);
+
+        return $iStored;
+    }
+
+    /**
+     * Whether an error happened in the visit: not before it started (a deferred error from an
+     * app's previous run, sent when it starts again, belongs to an earlier visit). A minute of
+     * slack covers an error buffered before the first pageview was sent.
+     */
+    protected function _isSessionError($aSession, $aHit)
+    {
+        return !empty($aSession) && $aHit['ts'] >= (int)$aSession['started'] - 60;
+    }
+
+    /**
+     * The visit an error that is not part of the running one happened in: the visitor's last
+     * visit on the platform that had started by then and had not timed out. An app that
+     * crashed sends the error on its next launch, often after that visit timed out.
+     *
+     * @return array the visit, or [] when it happened outside any
+     */
+    protected function _getErrorSession($aClientRow, $aHit)
+    {
+        if(empty($aClientRow['ids']))
+            return [];
+
+        $iTime = (int)$aHit['ts'];
+        $aSession = $this->_findSession($aClientRow['ids'], $aClientRow['platform'], $iTime - $this->_oConfig->getSessionTimeout(), $iTime + 60, $iTime);
+
+        return !empty($aSession) && $this->_isSessionError($aSession, $aHit) ? $aSession : [];
+    }
+
+    /**
+     * Store error hits under their groups. One that belongs to a visit (the running one, or
+     * an earlier one it happened in) raises that visit's error count, and is dropped past the
+     * limit per visit.
+     *
+     * @param array $aSession the running visit, or [] for none
+     * @param array $aErrors error hits from _getHits
+     * @param array $aClientRow visitor ids (ids, see _getVisitor), platform and client columns for errors outside a visit
+     * @return int hits stored
+     */
+    protected function _storeErrors($aSession, $aErrors, $aClientRow)
+    {
+        $aRows = [];
+        foreach($aErrors as $aHit) {
+            $aVisit = $this->_isSessionError($aSession, $aHit) ? $aSession : $this->_getErrorSession($aClientRow, $aHit);
+            $bLinked = !empty($aVisit);
+            if($bLinked && !$this->_oDb->addSessionErrors($aVisit['id'], 1, $this->_oConfig->CNF['MAX_ERRORS_PER_SESSION']))
+                continue;
+
+            $iErrorId = $this->_getErrorGroup($aHit['error'], $aHit['ts']);
+            if(!$iErrorId)
+                continue;
+
+            $aSource = $bLinked ? $aVisit : $aClientRow;
+            $aRows[] = [
+                'error_id' => $iErrorId,
+                'ts' => $aHit['ts'],
+                'session_id' => $bLinked ? (int)$aVisit['id'] : 0,
+                'visitor' => $aSource['visitor'],
+                'profile_id' => (int)$aSource['profile_id'],
+                'platform' => $aSource['platform'],
+                'app_version' => $aSource['app_version'],
+                'browser' => $aSource['browser'],
+                'os' => $aSource['os'],
+                'device' => $aSource['device'],
+                'country' => $aSource['country'],
+                'path' => $aHit['path'],
+                'route' => $aHit['route'],
+                'repeats' => $aHit['error']['repeats'],
+                'fatal' => $aHit['error']['fatal'],
+            ];
+        }
+
+        return $this->_oDb->insertErrorHits($aRows);
+    }
+
+    /**
+     * The id of an error's group, created when it is new. Past the limit of new groups an
+     * hour, a new error goes to one catch-all group, so a flood of random messages can't fill
+     * the table. A resolved error that happens again after it was resolved is open again.
+     */
+    protected function _getErrorGroup($aError, $iTime)
+    {
+        $sFingerprint = BxAnalyticsErrors::getFingerprint($aError);
+
+        $aGroup = $this->_oDb->getErrorGroupByFingerprint($sFingerprint);
+        if(empty($aGroup) && $this->_oDb->getErrorGroupsCreated($this->_iNow - 3600, $this->_iNow) >= $this->_oConfig->CNF['MAX_NEW_ERROR_GROUPS_PER_HOUR']) {
+            $aError = ['repeats' => $aError['repeats']] + BxAnalyticsErrors::getOverflowError();
+            $sFingerprint = BxAnalyticsErrors::getFingerprint($aError);
+            $aGroup = $this->_oDb->getErrorGroupByFingerprint($sFingerprint);
+        }
+
+        $iId = $this->_oDb->saveErrorGroup($sFingerprint, $aError, $iTime, $this->_iNow, !empty($aGroup) ? (int)$aGroup['id'] : 0);
+
+        if($iId && !empty($aGroup) && $aGroup['status'] == 'resolved' && $iTime > (int)$aGroup['status_changed'])
+            $this->_oDb->setErrorStatus($iId, 'open', $this->_iNow, 'resolved');
+
+        return $iId;
+    }
+
+    /**
+     * Validated hits from a payload. A hit without a usable URL, or from another site when
+     * `$bSameOrigin`, is dropped, and so is one on an excluded path.
+     */
+    protected function _getHits($aPayload, $bSameOrigin)
+    {
+        $aInput = isset($aPayload['hits']) && is_array($aPayload['hits']) ? array_values($aPayload['hits']) : [$aPayload];
+        $aInput = array_slice($aInput, 0, $this->_oConfig->CNF['MAX_HITS_PER_REQUEST']);
+
+        $aInternalHosts = $this->_oConfig->getInternalHosts();
+        $aExcludedPaths = $this->_oConfig->getExcludedPaths();
+        $sPlatform = isset($aPayload['platform']) ? $aPayload['platform'] : '';
+        $sApp = $this->_string($aPayload, 'app', 64, '/^[a-zA-Z0-9._ -]+$/');
+
+        $aTypes = [BX_ANALYTICS_HIT_PAGEVIEW, BX_ANALYTICS_HIT_EVENT, BX_ANALYTICS_HIT_LEAVE];
+        if($this->_oConfig->isTrackErrors())
+            $aTypes[] = BX_ANALYTICS_HIT_ERROR;
+        if($this->_oConfig->isTrackVitals())
+            $aTypes[] = BX_ANALYTICS_HIT_VITALS;
+        $aLimits = [
+            BX_ANALYTICS_HIT_ERROR => $this->_oConfig->CNF['MAX_ERRORS_PER_REQUEST'],
+            BX_ANALYTICS_HIT_VITALS => $this->_oConfig->CNF['MAX_VITALS_PER_REQUEST'],
+        ];
+
+        $aHits = [];
+        foreach($aInput as $aHit) {
+            if(!is_array($aHit))
+                continue;
+
+            $sType = isset($aHit['type']) && is_string($aHit['type']) ? $aHit['type'] : BX_ANALYTICS_HIT_PAGEVIEW;
+            if(!in_array($sType, $aTypes, true))
+                continue;
+
+            if(isset($aLimits[$sType]) && $aLimits[$sType]-- <= 0)
+                continue;
+
+            $aUrl = $this->_parseUrl(isset($aHit['url']) && is_string($aHit['url']) ? $aHit['url'] : '');
+            if($aUrl === false)
+                continue;
+
+            // native apps send paths; their "host" is the app
+            if($aUrl['hostname'] === '' && $this->_oConfig->isAppPlatform($sPlatform))
+                $aUrl['hostname'] = mb_strtolower($sApp);
+
+            if($bSameOrigin && !in_array($aUrl['hostname'], array_map('strtolower', $aInternalHosts), true))
+                continue;
+
+            if($this->_isExcludedPath($aUrl['path'], $aExcludedPaths))
+                continue;
+
+            $sName = '';
+            if($sType == BX_ANALYTICS_HIT_EVENT) {
+                $sName = $this->_string($aHit, 'name', 64, '/^[\p{L}\p{N} _.:\/-]+$/u');
+                if($sName === '')
+                    continue;
+            }
+
+            // an error may come late: an app sends the one that stopped it when it starts again
+            $iMaxAge = $sType == BX_ANALYTICS_HIT_ERROR ? $this->_oConfig->CNF['MAX_ERROR_AGE'] : 3600;
+            $iAge = isset($aHit['age']) && is_numeric($aHit['age']) ? (int)$aHit['age'] : 0;
+            if($sType == BX_ANALYTICS_HIT_ERROR && $iAge >= $iMaxAge * 1000)
+                continue;
+            $iAge = $iAge > 0 && $iAge < $iMaxAge * 1000 ? intdiv($iAge, 1000) : 0;
+
+            $aHealth = [];
+            if($sType == BX_ANALYTICS_HIT_ERROR) {
+                $aHealth = ['error' => $this->_getErrorInput($aHit)];
+                if($aHealth['error'] === false)
+                    continue;
+            }
+            elseif($sType == BX_ANALYTICS_HIT_VITALS) {
+                $aHealth = ['vitals' => $this->_getVitalsInput($aHit)];
+                if($aHealth['vitals'] === false)
+                    continue;
+            }
+            // errors and timings keep only the page parameters of a query string, whatever
+            // the client sent
+            if(!empty($aHealth)) {
+                $aUrl['path'] = get_mb_substr(BxAnalyticsErrors::getCleanPath($aUrl['path']), 0, 255);
+                $aHealth['route'] = BxAnalyticsErrors::getRoute($aUrl['path']);
+            }
+
+            $aProps = isset($aHit['props']) && is_array($aHit['props']) ? $this->_getProps($aHit['props']) : [];
+
+            $aHits[] = $aHealth + [
+                'type' => $sType,
+                'name' => $sName,
+                'url' => $aUrl['url'],
+                'hostname' => $aUrl['hostname'],
+                'path' => $aUrl['path'],
+                'title' => $this->_string($aHit, 'title', 255),
+                'module' => $this->_string($aHit, 'module', 32, '/^[a-z0-9_]+$/'),
+                'object_id' => isset($aHit['object_id']) ? max(0, (int)$aHit['object_id']) : 0,
+                'context_id' => isset($aHit['context_id']) ? abs((int)$aHit['context_id']) : 0,
+                'value' => isset($aHit['value']) && is_numeric($aHit['value']) ? (float)$aHit['value'] : 0,
+                'engaged' => isset($aHit['engaged']) ? min(max(0, (int)$aHit['engaged']), $this->_oConfig->CNF['MAX_ENGAGED_MS']) : 0,
+                'props' => empty($aProps) ? '' : json_encode($aProps, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
+                'non_interactive' => !empty($aHit['non_interactive']),
+                'ts' => $this->_iNow - $iAge,
+            ];
+        }
+
+        return $aHits;
+    }
+
+    /**
+     * An error hit's fields, sanitized, or false for noise, an ignored message or nothing usable.
+     */
+    protected function _getErrorInput($aHit)
+    {
+        $sKind = isset($aHit['kind']) && is_string($aHit['kind']) && $aHit['kind'] !== '' ? $aHit['kind'] : 'js';
+        if(!in_array($sKind, BxAnalyticsErrors::KINDS, true))
+            return false;
+
+        $aHosts = $this->_oConfig->getInternalHosts();
+        $fText = function($sKey, $iInput) use ($aHit) {
+            return isset($aHit[$sKey]) && is_string($aHit[$sKey]) ? get_mb_substr($aHit[$sKey], 0, $iInput) : '';
+        };
+
+        $iStatus = $sKind == 'http' && isset($aHit['status']) && is_numeric($aHit['status']) ? min(599, max(0, (int)$aHit['status'])) : 0;
+
+        $sMessage = BxAnalyticsErrors::sanitize($fText('message', BxAnalyticsErrors::MAX_MESSAGE_INPUT), BxAnalyticsErrors::MAX_MESSAGE, $aHosts);
+        if($sMessage === '' && $iStatus)
+            $sMessage = 'HTTP ' . $iStatus;
+        if($sMessage === '')
+            return false;
+
+        $sSource = BxAnalyticsErrors::sanitize($fText('source', 2048), BxAnalyticsErrors::MAX_SOURCE, $aHosts);
+        $sStack = BxAnalyticsErrors::sanitizeStack($fText('stack', BxAnalyticsErrors::MAX_STACK_INPUT), $fText('component_stack', BxAnalyticsErrors::MAX_STACK_INPUT), $aHosts);
+
+        if(BxAnalyticsErrors::isNoise($sMessage, $sStack, $sSource) || BxAnalyticsErrors::isIgnored($sMessage, $this->_oConfig->getErrorsIgnore()))
+            return false;
+
+        return [
+            'kind' => $sKind,
+            'message' => $sMessage,
+            'source' => $sSource,
+            'line' => isset($aHit['line']) && is_numeric($aHit['line']) ? min(10000000, max(0, (int)$aHit['line'])) : 0,
+            'col' => isset($aHit['col']) && is_numeric($aHit['col']) ? min(10000000, max(0, (int)$aHit['col'])) : 0,
+            'status' => $iStatus,
+            'stack' => $sStack,
+            'fatal' => !empty($aHit['fatal']) ? 1 : 0,
+            'repeats' => isset($aHit['repeats']) && is_numeric($aHit['repeats']) ? min(100, max(1, (int)$aHit['repeats'])) : 1,
+        ];
+    }
+
+    /**
+     * A vitals hit's navigation type and the metrics in range, or false when none is.
+     */
+    protected function _getVitalsInput($aHit)
+    {
+        if(!isset($aHit['metrics']) || !is_array($aHit['metrics']))
+            return false;
+
+        $aMetrics = [];
+        foreach(BxAnalyticsVitals::METRICS as $sMetric) {
+            $mixedValue = isset($aHit['metrics'][$sMetric]) ? BxAnalyticsVitals::getValue($sMetric, $aHit['metrics'][$sMetric]) : null;
+            if($mixedValue !== null)
+                $aMetrics[$sMetric] = $mixedValue;
+        }
+        if(empty($aMetrics))
+            return false;
+
+        $sNav = isset($aHit['nav']) && is_string($aHit['nav']) && in_array($aHit['nav'], BxAnalyticsVitals::NAVS, true) ? $aHit['nav'] : '';
+
+        return ['nav' => $sNav, 'metrics' => $aMetrics];
+    }
+
+    /**
+     * Browser, system and device of the client, or false for a bot. Browsers are read from the
+     * user agent; native apps report their system and device themselves.
+     */
+    protected function _getClient($sPlatform, $aPayload, $sUserAgent)
+    {
+        if(!$this->_oConfig->isAppPlatform($sPlatform)) {
+            $aUserAgent = BxAnalyticsUserAgent::parse($sUserAgent);
+            if($aUserAgent['bot'] && $sPlatform != BX_ANALYTICS_PLATFORM_API)
+                return false;
+
+            return $aUserAgent;
+        }
+
+        $sDevice = $this->_string($aPayload, 'device', 16, '/^[a-z]+$/');
+        if(!in_array($sDevice, [BxAnalyticsUserAgent::DEVICE_MOBILE, BxAnalyticsUserAgent::DEVICE_TABLET, BxAnalyticsUserAgent::DEVICE_DESKTOP, BxAnalyticsUserAgent::DEVICE_TV], true))
+            $sDevice = BxAnalyticsUserAgent::DEVICE_MOBILE;
+
+        $sOs = $this->_string($aPayload, 'os', 32, '/^[a-zA-Z0-9 ]+$/');
+        if($sOs === '')
+            $sOs = $sPlatform == BX_ANALYTICS_PLATFORM_RN_IOS ? ($sDevice == BxAnalyticsUserAgent::DEVICE_TABLET ? 'iPadOS' : 'iOS') : 'Android';
+
+        return [
+            'browser' => '',
+            'browser_version' => '',
+            'os' => $sOs,
+            'os_version' => $this->_string($aPayload, 'os_version', 16, '/^[0-9.]+$/'),
+            'device' => $sDevice,
+            'bot' => false,
+        ];
+    }
+
+    protected function _isExcludedProfile($iProfileId)
+    {
+        $aExcluded = $this->_oConfig->getExcludedLevels();
+        if(empty($aExcluded))
+            return false;
+
+        $aMembership = BxDolAcl::getInstance()->getMemberMembershipInfo($iProfileId);
+
+        return !empty($aMembership['id']) && in_array((int)$aMembership['id'], $aExcluded, true);
+    }
+
+    /**
+     * Excluded paths are prefixes, or patterns with * for any characters.
+     */
+    protected function _isExcludedPath($sPath, $aExcluded)
+    {
+        foreach($aExcluded as $sPattern) {
+            if(bx_mb_strpos($sPattern, '*') === false) {
+                if(bx_mb_strpos($sPath, $sPattern) === 0)
+                    return true;
+                continue;
+            }
+
+            if(preg_match('/^' . str_replace('\*', '.*', preg_quote($sPattern, '/')) . '$/', $sPath))
+                return true;
+        }
+
+        return false;
+    }
+
+    /**
+     * Host, path and full URL of a hit. The path keeps its query string, because classic
+     * pages are told apart by it (page.php?i=view-post&id=5), but drops tracking parameters
+     * and secrets (a reset key, a confirmation code, a token), and what is left is redacted
+     * as error paths are (addresses, tokens, long hashes).
+     *
+     * @return array|false
+     */
+    protected function _parseUrl($sUrl)
+    {
+        $sUrl = trim($sUrl);
+        if($sUrl === '' || get_mb_len($sUrl) > 2048)
+            return false;
+
+        $aParts = parse_url($sUrl);
+        if($aParts === false)
+            return false;
+
+        if(isset($aParts['scheme']) && !in_array(mb_strtolower($aParts['scheme']), ['http', 'https'], true))
+            return false;
+
+        $sPath = isset($aParts['path']) && $aParts['path'] !== '' ? $aParts['path'] : '/';
+        if($sPath[0] !== '/')
+            $sPath = '/' . $sPath;
+
+        if(!empty($aParts['query'])) {
+            $sIgnored = $this->_getIgnoredParamsPattern();
+            $aQuery = [];
+            foreach(explode('&', $aParts['query']) as $sPair) {
+                if($sPair === '')
+                    continue;
+                $sKey = mb_strtolower(urldecode(explode('=', $sPair, 2)[0]));
+                if(!in_array($sKey, $this->_aDropParams, true) && !$this->_isSecretParam($sKey) && ($sIgnored === '' || !preg_match($sIgnored, $sKey)))
+                    $aQuery[] = $sPair;
+            }
+            if(!empty($aQuery))
+                $sPath .= '?' . implode('&', $aQuery);
+        }
+
+        return [
+            'url' => $sUrl,
+            'hostname' => isset($aParts['host']) ? mb_strtolower($aParts['host']) : '',
+            'path' => get_mb_substr(BxAnalyticsErrors::redact(rawurldecode($sPath)), 0, 255),
+        ];
+    }
+
+    /**
+     * Whether a query parameter's name says it carries a secret or a person's identity.
+     */
+    protected function _isSecretParam($sKey)
+    {
+        return in_array($sKey, $this->_aSecretParams, true) || preg_match($this->_sSecretParamsPattern, $sKey);
+    }
+
+    /**
+     * One regular expression for the ignored query parameters setting, or ''.
+     */
+    protected function _getIgnoredParamsPattern()
+    {
+        if($this->_sIgnoredParams !== null)
+            return $this->_sIgnoredParams;
+
+        $aPatterns = [];
+        foreach($this->_oConfig->getIgnoredParams() as $sParam)
+            $aPatterns[] = str_replace('\\*', '.*', preg_quote(mb_strtolower($sParam), '/'));
+
+        return $this->_sIgnoredParams = empty($aPatterns) ? '' : '/^(' . implode('|', $aPatterns) . ')$/u';
+    }
+
+    /**
+     * Scalar properties only, with short keys, within the size limit.
+     */
+    protected function _getProps($aProps)
+    {
+        $aResult = [];
+        foreach($aProps as $sKey => $mixedValue) {
+            if(count($aResult) >= 30)
+                break;
+
+            if(!is_string($sKey) || !preg_match('/^[a-zA-Z0-9_.-]{1,40}$/', $sKey))
+                continue;
+
+            if(is_bool($mixedValue) || is_int($mixedValue) || is_float($mixedValue))
+                $aResult[$sKey] = $mixedValue;
+            elseif(is_string($mixedValue))
+                $aResult[$sKey] = get_mb_substr($mixedValue, 0, 255);
+        }
+
+        while(!empty($aResult) && get_mb_len(json_encode($aResult, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)) > $this->_oConfig->CNF['MAX_PROPS_LENGTH'])
+            array_pop($aResult);
+
+        return $aResult;
+    }
+
+    /**
+     * Count a request's hits against the address's limit for the minute. Past the limit the
+     * hits are dropped, and the request is answered as usual (the classic tracker's 204,
+     * `stored: 0` over the API) rather than with 429: NEO stops sending for the rest of the
+     * visit on any answer but 200, and a short burst (queued hits sent at once, an office or
+     * a school behind one address) shouldn't silence real visitors once it is over. The
+     * next minute counts anew. Hits dropped still count, so a flood stays over the limit for
+     * the whole minute.
+     *
+     * @return bool whether the hits may be stored
+     */
+    protected function _isWithinHitLimit($sIp, $iHits)
+    {
+        $iLimit = $this->_oConfig->getLimitHitsPerMinute();
+        if(!$iLimit || !$iHits)
+            return true;
+
+        // a counter that couldn't be written (0) doesn't stop tracking
+        $iCount = $this->_oDb->addLimitHits($this->_getLimitKey('hits', $sIp), $this->_getLimitBucket(60), $iHits);
+        if($iCount > $iLimit)
+            $this->_bLimited = true;
+
+        return !$this->_bLimited;
+    }
+
+    /**
+     * Count a new visit against the address's limit for the hour.
+     *
+     * @return bool whether the visit may start
+     */
+    protected function _isWithinVisitLimit($sIp)
+    {
+        $iLimit = $this->_oConfig->getLimitVisitsPerHour();
+        if(!$iLimit)
+            return true;
+
+        $iCount = $this->_oDb->addLimitHits($this->_getLimitKey('visits', $sIp), $this->_getLimitBucket(3600), 1);
+        if($iCount > $iLimit)
+            $this->_bLimited = true;
+
+        return !$this->_bLimited;
+    }
+
+    /**
+     * Whether an address is at a limit now: its next hit, or its next new visit, would be
+     * dropped. Reads the counters without adding to them.
+     */
+    protected function _isOverLimit($sIp)
+    {
+        $iHits = $this->_oConfig->getLimitHitsPerMinute();
+        if($iHits && $this->_oDb->getLimitHits($this->_getLimitKey('hits', $sIp), $this->_getLimitBucket(60)) >= $iHits)
+            return true;
+
+        $iVisits = $this->_oConfig->getLimitVisitsPerHour();
+
+        return $iVisits && $this->_oDb->getLimitHits($this->_getLimitKey('visits', $sIp), $this->_getLimitBucket(3600)) >= $iVisits;
+    }
+
+    /**
+     * Start of the running minute or hour.
+     */
+    protected function _getLimitBucket($iSeconds)
+    {
+        return $this->_iNow - $this->_iNow % $iSeconds;
+    }
+
+    /**
+     * 8-byte key of a rate limit counter: the kind and the address hashed with the day's
+     * salt, so the table never holds an address. An IPv6 address counts by its /64 network,
+     * which one device or home usually has to itself and can change addresses within at will.
+     */
+    protected function _getLimitKey($sKind, $sIp)
+    {
+        $sAddress = (string)$sIp;
+        if(filter_var($sAddress, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) {
+            $sBinary = inet_pton($sAddress);
+            // an IPv4 address written as IPv6 (::ffff:1.2.3.4) is that IPv4 address
+            if(substr($sBinary, 0, 12) === str_repeat("\0", 10) . "\xff\xff")
+                $sAddress = inet_ntop(substr($sBinary, 12));
+            else
+                $sAddress = bin2hex(substr($sBinary, 0, 8)) . '/64';
+        }
+
+        return substr($this->_hash('limit:' . $sKind . '|' . $sAddress, $this->_getSalt(gmdate('Y-m-d', $this->_iNow))), 0, 8);
+    }
+
+    protected function _getSalt($sDay)
+    {
+        $this->_loadSalts([$sDay]);
+
+        return $this->_aSalts[$sDay];
+    }
+
+    /**
+     * Read the salts of some days in one query, for this collector's request.
+     */
+    protected function _loadSalts($aDays)
+    {
+        $aMissing = array_values(array_diff($aDays, array_keys($this->_aSalts)));
+        if(!empty($aMissing))
+            $this->_aSalts = array_merge($this->_aSalts, $this->_oDb->getSalts($aMissing));
+    }
+
+    /**
+     * 16-byte visitor id.
+     */
+    protected function _hash($sValue, $sKey)
+    {
+        // a binary digest, not text
+        return substr(hash_hmac('sha256', $sValue, $sKey, true), 0, 16);
+    }
+
+    protected function _string($a, $sKey, $iMaxLength, $sPattern = '')
+    {
+        if(!isset($a[$sKey]) || !is_string($a[$sKey]))
+            return '';
+
+        $s = trim(preg_replace('/[\x00-\x1F\x7F]+/u', ' ', $a[$sKey]));
+        $s = get_mb_substr($s, 0, $iMaxLength);
+        if($s === '' || ($sPattern && !preg_match($sPattern, $s)))
+            return '';
+
+        return $s;
+    }
+}
+
+/** @} */

+ 363 - 7
modules/boonex/analytics/classes/BxAnalyticsConfig.php

@@ -11,18 +11,374 @@
 
 bx_import('BxDolModuleConfig');
 
+define('BX_ANALYTICS_PLATFORM_CORE_WEB', 'core-web');
+define('BX_ANALYTICS_PLATFORM_NEXT_WEB', 'next-web');
+define('BX_ANALYTICS_PLATFORM_RN_IOS', 'rn-ios');
+define('BX_ANALYTICS_PLATFORM_RN_ANDROID', 'rn-android');
+define('BX_ANALYTICS_PLATFORM_API', 'api');
+define('BX_ANALYTICS_PLATFORM_CORE', 'core');
+
+define('BX_ANALYTICS_IDENTITY_COOKIELESS', 'cookieless');
+define('BX_ANALYTICS_IDENTITY_PERSISTENT', 'persistent');
+
+define('BX_ANALYTICS_GEO_NONE', 'none');
+define('BX_ANALYTICS_GEO_HEADERS', 'headers');
+define('BX_ANALYTICS_GEO_CLOUDFLARE', 'cloudflare');
+define('BX_ANALYTICS_GEO_VERCEL', 'vercel');
+define('BX_ANALYTICS_GEO_CLOUDFRONT', 'cloudfront');
+
+// hit and event types: pageviews, custom events, page leaves (engaged time), site actions
+define('BX_ANALYTICS_HIT_PAGEVIEW', 'pageview');
+define('BX_ANALYTICS_HIT_EVENT', 'event');
+define('BX_ANALYTICS_HIT_LEAVE', 'leave');
+define('BX_ANALYTICS_HIT_UNA', 'una');
+
+// health hits: a client error, and a page's web vitals (or an app screen's load times); they
+// are stored apart from events and never start a visit
+define('BX_ANALYTICS_HIT_ERROR', 'error');
+define('BX_ANALYTICS_HIT_VITALS', 'vitals');
+
 class BxAnalyticsConfig extends BxBaseModGeneralConfig
 {
+    /**
+     * Where hits come from, as code => language key. Codes are stored with every session and
+     * event, so they never change; a new client is a new code.
+     * - core-web: the classic UNA web UI (PHP and HTML)
+     * - next-web, rn-ios, rn-android: NEO on the web (Next.js) and its React Native apps
+     * - api: any other client calling api.php directly
+     * - core: the server itself, with no client request behind it (cron, background jobs)
+     */
+    protected $_aPlatforms = [
+        BX_ANALYTICS_PLATFORM_CORE_WEB => '_bx_analytics_platform_core_web',
+        BX_ANALYTICS_PLATFORM_NEXT_WEB => '_bx_analytics_platform_next_web',
+        BX_ANALYTICS_PLATFORM_RN_IOS => '_bx_analytics_platform_rn_ios',
+        BX_ANALYTICS_PLATFORM_RN_ANDROID => '_bx_analytics_platform_rn_android',
+        BX_ANALYTICS_PLATFORM_API => '_bx_analytics_platform_api',
+        BX_ANALYTICS_PLATFORM_CORE => '_bx_analytics_platform_core',
+    ];
+
+    /**
+     * Platforms that run in a browser, where the user agent tells the browser and the device.
+     */
+    protected $_aBrowserPlatforms = [
+        BX_ANALYTICS_PLATFORM_CORE_WEB,
+        BX_ANALYTICS_PLATFORM_NEXT_WEB,
+    ];
+
+    /**
+     * Native apps, which report their system and device themselves.
+     */
+    protected $_aAppPlatforms = [
+        BX_ANALYTICS_PLATFORM_RN_IOS,
+        BX_ANALYTICS_PLATFORM_RN_ANDROID,
+    ];
+
     function __construct($aModule)
     {
         parent::__construct($aModule);
-        $this->CNF = array ();
-        $this->_aJsClasses = array(
-           'analytics' => 'BxAnalytics'
-        );
-        $this->_aJsObjects = array(
-            'analytics' => 'oAnalytics'
-        );
+
+        $this->CNF = [
+            'TABLE_SESSIONS' => $aModule['db_prefix'] . 'sessions',
+            'TABLE_EVENTS' => $aModule['db_prefix'] . 'events',
+            'TABLE_SALTS' => $aModule['db_prefix'] . 'salts',
+            'TABLE_LIMITS' => $aModule['db_prefix'] . 'limits',
+            'TABLE_BOTS' => $aModule['db_prefix'] . 'bots',
+            'TABLE_BOT_PAGES' => $aModule['db_prefix'] . 'bot_pages',
+            'TABLE_GOALS' => $aModule['db_prefix'] . 'goals',
+            'TABLE_FUNNELS' => $aModule['db_prefix'] . 'funnels',
+            'TABLE_REVENUE' => $aModule['db_prefix'] . 'revenue',
+            'TABLE_SUBSCRIPTIONS' => $aModule['db_prefix'] . 'subscriptions',
+            'TABLE_ERRORS' => $aModule['db_prefix'] . 'errors',
+            'TABLE_ERROR_HITS' => $aModule['db_prefix'] . 'error_hits',
+            'TABLE_VITALS' => $aModule['db_prefix'] . 'vitals',
+
+            // import of past payments: the cursors, rows read per service call, and the time
+            // one step may take in the hourly cron and from the Studio button
+            'OPTION_BACKFILL' => 'bx_analytics_revenue_backfill',
+            'OPTION_BACKFILL_SBS' => 'bx_analytics_revenue_backfill_sbs',
+            'BACKFILL_PAGE' => 500,
+            'BACKFILL_SECONDS' => 10,
+
+            // funnel steps at most
+            'MAX_FUNNEL_STEPS' => 6,
+            'MAX_GOALS' => 20,
+
+            // Query: values drawn on the chart, and rows in a CSV download at most
+            'QUERY_SERIES_VALUES' => 5,
+            'MAX_EXPORT_ROWS' => 10000,
+
+            // report data is cached this many seconds per report, filters, parameters,
+            // language and kind of viewer (live reports aren't); goal, funnel and error
+            // status changes drop the copies
+            'REPORT_CACHE_TTL' => 180,
+            'REPORT_CACHE_PREFIX' => 'bx_analytics_report_',
+
+            // hits per request and their properties, to bound the work of one beacon, and the
+            // largest request body read at all (20 hits, 10 of them errors with long stacks)
+            'MAX_HITS_PER_REQUEST' => 20,
+            'MAX_PROPS_LENGTH' => 2048,
+            'MAX_COLLECT_BYTES' => 256 * 1024,
+
+            // health: error hits per request and per visit, new error groups per hour (more go
+            // to one catch-all group), how old a deferred error may be (an app that crashed
+            // sends it on the next launch), and vitals hits per request
+            'MAX_ERRORS_PER_REQUEST' => 10,
+            'MAX_ERRORS_PER_SESSION' => 50,
+            'MAX_NEW_ERROR_GROUPS_PER_HOUR' => 200,
+            'MAX_ERROR_AGE' => 7 * 86400,
+            'MAX_VITALS_PER_REQUEST' => 1,
+
+            // vitals thresholds, metric => [good up to, poor above]: milliseconds, and CLS in
+            // thousandths (the web.dev and Lighthouse values; app screens and starts by RN guides)
+            'VITALS' => [
+                'lcp' => [2500, 4000],
+                'inp' => [200, 500],
+                'cls' => [100, 250],
+                'fcp' => [1800, 3000],
+                'ttfb' => [800, 1800],
+                'screen_load' => [1000, 2500],
+                'app_start' => [2000, 4000],
+            ],
+
+            // engaged time above this on one page is a tab left open, not reading
+            'MAX_ENGAGED_MS' => 30 * 60 * 1000,
+
+            // rows deleted per query when pruning, and the time one pruning run may take
+            'PRUNING_BATCH' => 5000,
+            'PRUNING_SECONDS' => 20,
+        ];
+
+        $this->_aJsClasses = [
+            'analytics' => 'BxAnalytics',
+            'dashboard' => 'BxAnalyticsDashboard',
+            'tools' => 'BxAnalyticsStudioTools',
+        ];
+        $this->_aJsObjects = [
+            'analytics' => 'oAnalytics',
+            'dashboard' => 'oBxAnalyticsDashboard',
+            'tools' => 'oBxAnalyticsStudioTools',
+        ];
+    }
+
+    public function getPlatforms()
+    {
+        return $this->_aPlatforms;
+    }
+
+    public function isPlatform($sPlatform)
+    {
+        return is_string($sPlatform) && isset($this->_aPlatforms[$sPlatform]);
+    }
+
+    public function isBrowserPlatform($sPlatform)
+    {
+        return in_array($sPlatform, $this->_aBrowserPlatforms, true);
+    }
+
+    public function isAppPlatform($sPlatform)
+    {
+        return in_array($sPlatform, $this->_aAppPlatforms, true);
+    }
+
+    public function isTrackingEnabled()
+    {
+        return getParam('bx_analytics_tracking') == 'on';
+    }
+
+    public function isPlatformEnabled($sPlatform)
+    {
+        return $this->isPlatform($sPlatform) && in_array($sPlatform, $this->_getList('bx_analytics_platforms'), true);
+    }
+
+    public function getSessionTimeout()
+    {
+        return 60 * max(1, (int)getParam('bx_analytics_session_timeout'));
+    }
+
+    /**
+     * Hits the public collector takes from one address in a minute, 0 for no limit.
+     */
+    public function getLimitHitsPerMinute()
+    {
+        return max(0, (int)getParam('bx_analytics_limit_hits'));
+    }
+
+    /**
+     * Visits that may start from one address in an hour, 0 for no limit.
+     */
+    public function getLimitVisitsPerHour()
+    {
+        return max(0, (int)getParam('bx_analytics_limit_visits'));
+    }
+
+    public function getOnlineWindow()
+    {
+        return 60 * max(1, (int)getParam('bx_analytics_online_window'));
+    }
+
+    public function getIdentityMode()
+    {
+        return getParam('bx_analytics_identity') == BX_ANALYTICS_IDENTITY_PERSISTENT ? BX_ANALYTICS_IDENTITY_PERSISTENT : BX_ANALYTICS_IDENTITY_COOKIELESS;
+    }
+
+    public function isLinkMembers()
+    {
+        return getParam('bx_analytics_link_members') == 'on';
+    }
+
+    public function isTrackActions()
+    {
+        return getParam('bx_analytics_track_actions') == 'on';
+    }
+
+    public function isTrackBots()
+    {
+        return getParam('bx_analytics_track_bots') == 'on';
+    }
+
+    public function isRespectDnt()
+    {
+        return getParam('bx_analytics_respect_dnt') == 'on';
+    }
+
+    public function getExcludedLevels()
+    {
+        return array_map('intval', $this->_getList('bx_analytics_exclude_levels'));
+    }
+
+    public function getExcludedIps()
+    {
+        return $this->_getList('bx_analytics_exclude_ips');
+    }
+
+    public function getExcludedPaths()
+    {
+        return $this->_getList('bx_analytics_exclude_paths');
+    }
+
+    /**
+     * Query parameters left out of stored paths, as patterns with * for any characters.
+     */
+    public function getIgnoredParams()
+    {
+        return $this->_getList('bx_analytics_ignore_params');
+    }
+
+    public function getTrustedProxies()
+    {
+        return $this->_getList('bx_analytics_trusted_proxies');
+    }
+
+    /**
+     * Where the visitor's location comes from: none, the headers of any CDN (the first one
+     * found), or the headers of one CDN only (the others can be sent by the client, since an
+     * edge sets its own headers and passes the rest through).
+     */
+    public function getGeoSource()
+    {
+        $sSource = getParam('bx_analytics_geo_source');
+
+        return in_array($sSource, [BX_ANALYTICS_GEO_HEADERS, BX_ANALYTICS_GEO_CLOUDFLARE, BX_ANALYTICS_GEO_VERCEL, BX_ANALYTICS_GEO_CLOUDFRONT], true) ? $sSource : BX_ANALYTICS_GEO_NONE;
+    }
+
+    /**
+     * The CDN whose location headers are read ('' for any), or false when the location isn't
+     * read at all.
+     *
+     * @return string|false
+     */
+    public function getGeoEdge()
+    {
+        $sSource = $this->getGeoSource();
+        if($sSource == BX_ANALYTICS_GEO_NONE)
+            return false;
+
+        return $sSource == BX_ANALYTICS_GEO_HEADERS ? '' : $sSource;
+    }
+
+    public function getRetentionDays()
+    {
+        return max(1, (int)getParam('bx_analytics_retention_days'));
+    }
+
+    public function isTrackErrors()
+    {
+        return getParam('bx_analytics_track_errors') == 'on';
+    }
+
+    public function isTrackVitals()
+    {
+        return getParam('bx_analytics_track_vitals') == 'on';
+    }
+
+    /**
+     * Percent of page loads that measure vitals, 0 to 100; the client samples.
+     */
+    public function getVitalsSample()
+    {
+        return min(100, max(0, (int)getParam('bx_analytics_vitals_sample')));
+    }
+
+    /**
+     * Error messages that are not stored, one pattern per line with * for any characters.
+     * Lines, not commas: messages have spaces and commas.
+     */
+    public function getErrorsIgnore()
+    {
+        $sValue = trim((string)getParam('bx_analytics_errors_ignore'));
+        if($sValue === '')
+            return [];
+
+        $aLines = preg_split('/\R/u', $sValue);
+        if($aLines === false)
+            return [];
+
+        return array_values(array_filter(array_map('trim', $aLines), 'get_mb_len'));
+    }
+
+    /**
+     * Vitals are kept for this many days, at most as long as the rest.
+     */
+    public function getVitalsRetentionDays()
+    {
+        return min($this->getRetentionDays(), max(1, (int)getParam('bx_analytics_vitals_retention_days')));
+    }
+
+    /**
+     * Hosts that belong to the site: a referrer from one of them isn't a traffic source.
+     */
+    public function getInternalHosts()
+    {
+        $aHosts = [parse_url(BX_DOL_URL_ROOT, PHP_URL_HOST)];
+
+        /**
+         * @hooks
+         * @hookdef hook-bx_analytics-get_internal_hosts 'bx_analytics', 'get_internal_hosts' - hook to add the hosts of other clients (NEO web, white-label domains) whose referrers are internal
+         * - $unit_name - equals `bx_analytics`
+         * - $action - equals `get_internal_hosts`
+         * - $object_id - not used
+         * - $sender_id - not used
+         * - $extra_params - array of additional params with the following array keys:
+         *      - `hosts` - [array] by ref, list of host names, can be overridden in hook processing
+         * @hook @ref hook-bx_analytics-get_internal_hosts
+         */
+        bx_alert($this->getName(), 'get_internal_hosts', 0, 0, [
+            'hosts' => &$aHosts,
+        ]);
+
+        return array_filter($aHosts);
+    }
+
+    /**
+     * Values of a list setting, which Studio stores comma separated; text settings may use lines too.
+     */
+    protected function _getList($sName)
+    {
+        $sValue = trim((string)getParam($sName));
+        if($sValue === '')
+            return [];
+
+        return array_values(array_filter(array_map('trim', preg_split('/[\s,]+/', $sValue)), 'get_mb_len'));
     }
 }
 

+ 80 - 0
modules/boonex/analytics/classes/BxAnalyticsCsv.php

@@ -0,0 +1,80 @@
+<?php defined('BX_DOL') or die('hack attempt');
+/**
+ * Copyright (c) UNA, Inc - https://una.io
+ * MIT License - https://opensource.org/licenses/MIT
+ *
+ * @defgroup    Analytics Analytics
+ * @ingroup     UnaModules
+ *
+ * @{
+ */
+
+/**
+ * CSV downloads of reports. Paths, titles, referrers and campaign tags come from visitors, so
+ * a text cell that a spreadsheet would read as a formula is turned into plain text.
+ */
+class BxAnalyticsCsv
+{
+    /**
+     * A character that makes a spreadsheet evaluate a cell it starts, where a cell can start:
+     * at the start of the text, and after a ; or a line break. Spreadsheets in many locales
+     * (Russian, German, French) split a CSV line on ;, and a quote in the middle of a field
+     * doesn't enclose anything, so 'x;=1+1' would be two cells, the second a formula.
+     * Every character here is ASCII, which never occurs inside a multibyte UTF-8 character,
+     * so the pattern works on bytes and never fails on broken text.
+     */
+    const FORMULA_PATTERN = '/(?:^|(?<=[;\r\n]))( *)([=+\-@\t\r])/';
+
+    /**
+     * A cell: numbers as they are, text with a ' before anything that would start a formula.
+     */
+    public static function cell($mixedValue)
+    {
+        if(is_int($mixedValue) || is_float($mixedValue))
+            return $mixedValue;
+
+        if(is_bool($mixedValue))
+            return (int)$mixedValue;
+
+        $sValue = (string)$mixedValue;
+        if($sValue === '')
+            return $sValue;
+
+        return preg_replace(self::FORMULA_PATTERN, "$1'$2", $sValue);
+    }
+
+    /**
+     * A download's file name from known parts only: analytics-<report>-<dimension>-<from>-<to>.csv.
+     * Anything but letters, digits, _ and - is dropped from each part.
+     */
+    public static function getFilename($aParts)
+    {
+        $aResult = ['analytics'];
+        foreach($aParts as $sPart) {
+            $sPart = preg_replace('/[^A-Za-z0-9_-]+/', '', (string)$sPart);
+            if($sPart !== '')
+                $aResult[] = $sPart;
+        }
+
+        return implode('-', $aResult) . '.csv';
+    }
+
+    /**
+     * Write a header row and rows to a stream: UTF-8 with a byte order mark (so spreadsheets
+     * read it as UTF-8), commas, quotes doubled inside quoted cells.
+     *
+     * @param resource $rStream
+     * @param array $aHeader column titles
+     * @param array $aRows rows of cells
+     */
+    public static function write($rStream, $aHeader, $aRows)
+    {
+        fwrite($rStream, "\xEF\xBB\xBF");
+
+        fputcsv($rStream, array_map([self::class, 'cell'], array_values($aHeader)), ',', '"', '');
+        foreach($aRows as $aRow)
+            fputcsv($rStream, array_map([self::class, 'cell'], array_values($aRow)), ',', '"', '');
+    }
+}
+
+/** @} */

Fișier diff suprimat deoarece este prea mare
+ 2636 - 0
modules/boonex/analytics/classes/BxAnalyticsDb.php


+ 352 - 0
modules/boonex/analytics/classes/BxAnalyticsErrors.php

@@ -0,0 +1,352 @@
+<?php defined('BX_DOL') or die('hack attempt');
+/**
+ * Copyright (c) UNA, Inc - https://una.io
+ * MIT License - https://opensource.org/licenses/MIT
+ *
+ * @defgroup    Analytics Analytics
+ * @ingroup     UnaModules
+ *
+ * @{
+ */
+
+/**
+ * Client errors before they are stored: what is kept of a message and a stack (no query
+ * strings, no tokens, no addresses), the fingerprint that groups repeats of one error, and
+ * the route a page belongs to.
+ *
+ * Messages and stacks come from visitors' browsers and apps, so everything here treats them
+ * as untrusted text: control characters go, lengths are capped, and reports escape them.
+ */
+class BxAnalyticsErrors
+{
+    /**
+     * Kinds a client may send: uncaught JS errors, unhandled promise rejections, React render
+     * errors (error boundaries), native app errors (the RN global handler), failed requests
+     * (status 500 and up), and scripts or styles that failed to load.
+     */
+    const KINDS = ['js', 'promise', 'react', 'native', 'http', 'resource'];
+
+    /**
+     * The catch-all group for errors past the new groups limit.
+     */
+    const KIND_OVERFLOW = 'overflow';
+
+    const MAX_MESSAGE_INPUT = 500;
+    const MAX_MESSAGE = 255;
+    const MAX_SOURCE = 255;
+    const MAX_STACK_INPUT = 8000;
+    const MAX_STACK = 4000;
+    const MAX_FRAMES = 30;
+
+    const REDACTED = '<redacted>';
+
+    /**
+     * Text without control characters, secrets, query strings or the site's own host in
+     * URLs, cut to a length.
+     *
+     * @param string $s the text
+     * @param int $iMax characters kept
+     * @param array $aOwnHosts the site's hosts, removed from URLs (other hosts stay)
+     * @param bool $bLines keep line breaks (stacks)
+     */
+    public static function sanitize($s, $iMax, $aOwnHosts = [], $bLines = false)
+    {
+        $s = (string)$s;
+        if($s === '')
+            return '';
+
+        if(!mb_check_encoding($s, 'UTF-8'))
+            $s = mb_convert_encoding($s, 'UTF-8', 'UTF-8');
+
+        $s = str_replace("\r\n", "\n", $s);
+        $s = preg_replace($bLines ? '/[\x00-\x09\x0B-\x1F\x7F]+/u' : '/[\x00-\x1F\x7F]+/u', ' ', $s);
+
+        $aHosts = array_map('mb_strtolower', array_filter((array)$aOwnHosts));
+
+        // URLs: no query string or fragment, and no host when it is the site's own; a
+        // stack frame's :line:column after the URL stays
+        $s = preg_replace_callback('~\b(?:https?|wss?)://[^\s()<>"\'`]+~i', function($aMatch) use ($aHosts) {
+            $sUrl = $aMatch[0];
+            $sPosition = '';
+            if(preg_match('/^(.*?)((?::\d+){1,2})$/su', $sUrl, $aPosition)) {
+                $sUrl = $aPosition[1];
+                $sPosition = $aPosition[2];
+            }
+
+            $sUrl = preg_replace('/[?#].*$/s', '', $sUrl);
+
+            if(preg_match('~^[a-z]+://([^/:]+)(:\d+)?(/.*)?$~i', $sUrl, $aParts) && in_array(mb_strtolower($aParts[1]), $aHosts, true))
+                $sUrl = isset($aParts[3]) && $aParts[3] !== '' ? $aParts[3] : '/';
+
+            return $sUrl . $sPosition;
+        }, $s);
+
+        // a site's own relative URLs (an http error's "HTTP 502 /page.php?i=x&id=5"): only
+        // the page parameters stay of their query strings
+        $s = preg_replace_callback('~(?<![\w.:/-])/[^\s()<>"\'`?#]*\?[^\s()<>"\'`#:]*~u', function($aMatch) {
+            return self::getCleanPath($aMatch[0], false);
+        }, $s);
+
+        $s = self::redact($s);
+
+        if($bLines)
+            $s = preg_replace('/[ ]+\n/u', "\n", $s);
+
+        return trim(get_mb_substr(trim($s), 0, $iMax));
+    }
+
+    /**
+     * Tokens, passwords, emails and long random strings replaced.
+     */
+    public static function redact($s)
+    {
+        $aPatterns = [
+            // JSON web tokens
+            '/\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/' => self::REDACTED,
+            '/\b(Bearer|Basic)\s+[^\s"\'`,;]+/i' => '$1 ' . self::REDACTED,
+            // values of names that hold secrets: token=..., api_key=..., "password":"...",
+            // otp: ... (a serialized object or form in a message)
+            '/(["\']?)\b([A-Za-z_]*(?:token|key|secret|password|passwd|pass|pwd|auth|sid|session|otp))\1(\s*[:=]\s*)(?:"[^"]*"|\'[^\']*\'|[^&\s"\'`,;}]+)/i' => '$1$2$1$3' . self::REDACTED,
+            '/[A-Za-z0-9._%+-]+@[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)*\.[A-Za-z]{2,}/' => self::REDACTED,
+            // long hex (hashes, ids) and long base64-like runs with digits and both cases
+            '/(?<![A-Za-z0-9])[0-9a-fA-F]{24,}(?![A-Za-z0-9])/' => self::REDACTED,
+            '/(?<![A-Za-z0-9+_-])(?=[A-Za-z0-9+_-]*[0-9])(?=[A-Za-z0-9+_-]*[A-Z])(?=[A-Za-z0-9+_-]*[a-z])[A-Za-z0-9+_-]{24,}={0,2}/' => self::REDACTED,
+        ];
+
+        foreach($aPatterns as $sPattern => $sReplacement) {
+            $sResult = preg_replace($sPattern, $sReplacement, $s);
+            if($sResult !== null)
+                $s = $sResult;
+        }
+
+        return $s;
+    }
+
+    /**
+     * A stack trace (and a React component stack after it) sanitized, at most 30 frames.
+     */
+    public static function sanitizeStack($sStack, $sComponentStack = '', $aOwnHosts = [])
+    {
+        $sStack = self::_frames(self::sanitize(get_mb_substr((string)$sStack, 0, self::MAX_STACK_INPUT), self::MAX_STACK_INPUT, $aOwnHosts, true));
+
+        $sComponentStack = self::_frames(self::sanitize(get_mb_substr((string)$sComponentStack, 0, self::MAX_STACK_INPUT), self::MAX_STACK_INPUT, $aOwnHosts, true));
+        if($sComponentStack !== '')
+            $sStack .= ($sStack !== '' ? "\n--\n" : '') . $sComponentStack;
+
+        return get_mb_substr($sStack, 0, self::MAX_STACK);
+    }
+
+    /**
+     * Noise that is never stored: "Script error." without a stack (a cross-origin script
+     * the browser hides), and errors of browser extensions.
+     */
+    public static function isNoise($sMessage, $sStack, $sSource)
+    {
+        if($sStack === '' && preg_match('/^Script error\.?$/i', trim($sMessage)))
+            return true;
+
+        $aFrame = self::getTopFrame($sStack);
+        foreach([$sSource, $aFrame ? $aFrame['file'] : ''] as $sFile)
+            if(preg_match('~^[a-z-]*extension://~i', (string)$sFile))
+                return true;
+
+        return false;
+    }
+
+    /**
+     * Whether a message matches one of the ignore patterns (* for any characters, case
+     * doesn't matter).
+     */
+    public static function isIgnored($sMessage, $aPatterns)
+    {
+        foreach($aPatterns as $sPattern) {
+            $sPattern = trim((string)$sPattern);
+            if($sPattern === '')
+                continue;
+
+            if(preg_match('/^' . str_replace('\*', '.*', preg_quote($sPattern, '/')) . '$/isu', trim($sMessage)))
+                return true;
+        }
+
+        return false;
+    }
+
+    /**
+     * The message with what changes between repeats replaced: UUIDs, hex and numbers,
+     * quoted values. Line breaks and runs of spaces become one space.
+     */
+    public static function normalizeMessage($sMessage)
+    {
+        $s = (string)$sMessage;
+        $s = preg_replace('/\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/i', '<uuid>', $s);
+        $s = preg_replace('/\b0x[0-9a-f]+\b/i', '0x0', $s);
+        $s = preg_replace('/\b(?=[0-9a-f]*[0-9])(?=[0-9a-f]*[a-f])[0-9a-f]{8,}\b/i', '<hex>', $s);
+        // quotes that open a value, not an apostrophe inside a word
+        $s = preg_replace('/(?<![\p{L}\p{N}])(["\'`])[^"\'`\n]*\1/u', '""', $s);
+        $s = preg_replace('/\d+(\.\d+)?/', '0', $s);
+
+        return trim(preg_replace('/\s+/u', ' ', $s));
+    }
+
+    /**
+     * The top frame of a stack in the Chrome/V8/Hermes (`at fn (file:1:2)`) or the
+     * Firefox/Safari (`fn@file:1:2`) format.
+     *
+     * @return array|null with `function`, `file`, `line`, `col`
+     */
+    public static function getTopFrame($sStack)
+    {
+        foreach(explode("\n", (string)$sStack) as $sLine) {
+            $sLine = trim($sLine);
+            if($sLine === '' || $sLine === '--')
+                continue;
+
+            if(preg_match('/^at\s+(?:(.+?)\s+\()?(.+?)(?::(\d+))?(?::(\d+))?\)?$/', $sLine, $a) && (isset($a[3]) && $a[3] !== '' || bx_mb_strpos($a[2], '/') !== false))
+                return self::_frame(isset($a[1]) ? $a[1] : '', $a[2], isset($a[3]) ? $a[3] : 0, isset($a[4]) ? $a[4] : 0);
+
+            if(preg_match('/^([^@\s]*)@(.+?)(?::(\d+))?(?::(\d+))?$/', $sLine, $a))
+                return self::_frame($a[1], $a[2], isset($a[3]) ? $a[3] : 0, isset($a[4]) ? $a[4] : 0);
+        }
+
+        return null;
+    }
+
+    /**
+     * A script's path with what changes between deployments replaced by *: the content
+     * hashes of Next.js chunks (app/[...path]/page-*.js, 5123-*.js and *.js keep apart) and
+     * of UNA's combined files and other bundles.
+     */
+    public static function normalizeFile($sFile)
+    {
+        $s = preg_replace('/[?#].*$/s', '', trim((string)$sFile));
+        $s = preg_replace('~(?<=[/._-])(?=[0-9a-z]*[0-9])[0-9a-z]{8,}(?=\.m?js$)~i', '*', $s);
+        $s = preg_replace('/(?<=[._-])(?=[0-9a-z]*[0-9])[0-9a-z]{8,}(?=[._-])/i', '*', $s);
+        $s = str_replace(self::REDACTED, '*', $s);
+
+        return $s;
+    }
+
+    /**
+     * 16 bytes that are the same for every repeat of an error: kind, normalized message,
+     * the file and function of the top frame (the source file without a stack), and the
+     * HTTP status. Line and column are left out: they change with every build of a
+     * minified script.
+     *
+     * @param array $aError with `kind`, `message`, `stack`, `source`, `status`
+     */
+    public static function getFingerprint($aError)
+    {
+        $aFrame = self::getTopFrame(isset($aError['stack']) ? $aError['stack'] : '');
+
+        return substr(hash('sha256', implode('|', [
+            isset($aError['kind']) ? $aError['kind'] : '',
+            self::normalizeMessage(isset($aError['message']) ? $aError['message'] : ''),
+            self::normalizeFile($aFrame ? $aFrame['file'] : (isset($aError['source']) ? $aError['source'] : '')),
+            $aFrame ? $aFrame['function'] : '',
+            isset($aError['status']) ? (int)$aError['status'] : 0,
+        ]), true), 0, 16);
+    }
+
+    /**
+     * The group past the new groups limit takes.
+     */
+    public static function getOverflowError()
+    {
+        return ['kind' => self::KIND_OVERFLOW, 'message' => '', 'source' => '', 'line' => 0, 'col' => 0, 'status' => 0, 'stack' => '', 'fatal' => 0];
+    }
+
+    /**
+     * A page's path as error and vitals hits keep it: of the query string only the page
+     * parameters `i` and `r` stay (the trackers send no more, this strips a client that
+     * does), and tokens and emails are redacted. Report filters on the path of these hits
+     * pass a pageview's path through it too.
+     *
+     * @param string $sPath a path with its query string, decoded
+     * @param bool $bRedact also redact secrets
+     */
+    public static function getCleanPath($sPath, $bRedact = true)
+    {
+        $aParts = explode('?', preg_replace('/#.*$/s', '', (string)$sPath), 2);
+
+        $aKeep = [];
+        if(isset($aParts[1]))
+            foreach(explode('&', $aParts[1]) as $sPair) {
+                $aPair = explode('=', $sPair, 2);
+                if(in_array($aPair[0], ['i', 'r'], true) && isset($aPair[1]) && $aPair[1] !== '' && !isset($aKeep[$aPair[0]]))
+                    $aKeep[$aPair[0]] = $aPair[0] . '=' . $aPair[1];
+            }
+        ksort($aKeep);
+
+        $sPath = $aParts[0] . (!empty($aKeep) ? '?' . implode('&', $aKeep) : '');
+
+        return $bRedact ? self::redact($sPath) : $sPath;
+    }
+
+    /**
+     * The page a path belongs to, so one route collects the errors and timings of all its
+     * items: the query string keeps only `i` (classic pages: page.php?i=view-post), and
+     * ids, UUIDs, hashes and long tokens in the path become *.
+     */
+    public static function getRoute($sPath)
+    {
+        $aParts = explode('?', (string)$sPath, 2);
+
+        $aSegments = [];
+        foreach(explode('/', $aParts[0]) as $sSegment)
+            $aSegments[] = self::_isVariableSegment($sSegment) ? '*' : $sSegment;
+        $sRoute = implode('/', $aSegments);
+        if($sRoute === '')
+            $sRoute = '/';
+
+        if(isset($aParts[1]))
+            foreach(explode('&', $aParts[1]) as $sPair) {
+                $aPair = explode('=', $sPair, 2);
+                if($aPair[0] === 'i' && isset($aPair[1]) && $aPair[1] !== '') {
+                    $sRoute .= '?i=' . $aPair[1];
+                    break;
+                }
+            }
+
+        return get_mb_substr($sRoute, 0, 128);
+    }
+
+    protected static function _isVariableSegment($sSegment)
+    {
+        if($sSegment === '')
+            return false;
+
+        return preg_match('/^\d+$/', $sSegment)
+            || preg_match('/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i', $sSegment)
+            || preg_match('/^(?=[0-9a-f]*[0-9])[0-9a-f]{12,}$/i', $sSegment)
+            || (get_mb_len($sSegment) >= 20 && preg_match('/^(?=.*[0-9])[A-Za-z0-9_-]+$/', $sSegment) && preg_match('/[A-Z]/', $sSegment))
+            || $sSegment === self::REDACTED;
+    }
+
+    protected static function _frame($sFunction, $sFile, $iLine, $iCol)
+    {
+        $sFile = trim(preg_replace('/^address at\s+/i', '', trim($sFile)));
+        $sFunction = trim(preg_replace('/^(async|new)\s+/', '', trim($sFunction)));
+
+        return ['function' => $sFunction, 'file' => $sFile, 'line' => (int)$iLine, 'col' => (int)$iCol];
+    }
+
+    protected static function _frames($sStack)
+    {
+        if($sStack === '')
+            return '';
+
+        $aLines = [];
+        foreach(explode("\n", $sStack) as $sLine) {
+            $sLine = rtrim($sLine);
+            if(trim($sLine) === '')
+                continue;
+            $aLines[] = $sLine;
+            if(count($aLines) >= self::MAX_FRAMES)
+                break;
+        }
+
+        return implode("\n", $aLines);
+    }
+}
+
+/** @} */

+ 297 - 0
modules/boonex/analytics/classes/BxAnalyticsFilter.php

@@ -0,0 +1,297 @@
+<?php defined('BX_DOL') or die('hack attempt');
+/**
+ * Copyright (c) UNA, Inc - https://una.io
+ * MIT License - https://opensource.org/licenses/MIT
+ *
+ * @defgroup    Analytics Analytics
+ * @ingroup     UnaModules
+ *
+ * @{
+ */
+
+/**
+ * The period, timezone, platform and dimension filters of a report, read from request
+ * parameters. Every report block on a page reads the same parameters, so one filter bar
+ * drives them all, in the classic UI, in Studio and in NEO.
+ *
+ * Parameters: range (today, yesterday, 24h, 7d, 30d, 90d, 12m, custom), from and to
+ * (YYYY-MM-DD, for custom), tz (IANA timezone), platform, f (dimension => value).
+ */
+class BxAnalyticsFilter
+{
+    const RANGE_DEFAULT = '7d';
+
+    /**
+     * Longest custom period, in days.
+     */
+    const RANGE_MAX_DAYS = 731;
+
+    /**
+     * The viewer's IANA timezone, kept by the dashboard (js/dashboard.js) for requests
+     * without `tz`.
+     */
+    const TZ_COOKIE = 'bx_analytics_tz';
+
+    public static $aRanges = ['today', 'yesterday', '24h', '7d', '30d', '90d', '12m', 'custom'];
+
+    protected $_oDb;
+    protected $_oConfig;
+    protected $_iNow;
+
+    protected $_aFilter;
+
+    /**
+     * Start of the previous period, a calendar step back in the filter's timezone.
+     */
+    protected $_iPreviousFrom;
+
+    /**
+     * @param object $oModule the Analytics module
+     * @param array $aInput request parameters
+     * @param int $iNow current time, for tests
+     */
+    public function __construct($oModule, $aInput, $iNow = null)
+    {
+        $this->_oDb = $oModule->_oDb;
+        $this->_oConfig = $oModule->_oConfig;
+        $this->_iNow = $iNow ? (int)$iNow : time();
+
+        $this->_aFilter = $this->_parse(is_array($aInput) ? $aInput : []);
+    }
+
+    /**
+     * The filter for Db report methods: from, to (exclusive), platform, dims, and for
+     * series: bucket, offset, starts (exact bucket starts when fixed lengths won't do) and
+     * buckets (every bucket start, to fill gaps with zeros).
+     */
+    public function get()
+    {
+        return $this->_aFilter;
+    }
+
+    /**
+     * The same filter for the period just before, for comparisons: as many local days (or
+     * calendar months, or hours for 24h) as this one, so a daylight saving change or a leap
+     * year doesn't shift it by an hour or a day.
+     */
+    public function getPrevious()
+    {
+        $aFilter = $this->_aFilter;
+        $aFilter['to'] = $aFilter['from'];
+        $aFilter['from'] = $this->_iPreviousFrom;
+
+        return $aFilter;
+    }
+
+    /**
+     * Parameters that reproduce this filter, for links, exports and NEO requests.
+     */
+    public function getParams()
+    {
+        $a = [
+            'range' => $this->_aFilter['range'],
+            'tz' => $this->_aFilter['tz'],
+        ];
+        if($this->_aFilter['range'] == 'custom') {
+            $a['from'] = $this->_aFilter['from_date'];
+            $a['to'] = $this->_aFilter['to_date'];
+        }
+        if($this->_aFilter['platform'] !== '')
+            $a['platform'] = $this->_aFilter['platform'];
+        if(!empty($this->_aFilter['dims']))
+            $a['f'] = $this->_aFilter['dims'];
+
+        return $a;
+    }
+
+    protected function _parse($aInput)
+    {
+        $sRange = isset($aInput['range']) && in_array($aInput['range'], self::$aRanges, true) ? $aInput['range'] : self::RANGE_DEFAULT;
+
+        // with the backward-compatible names (Asia/Calcutta, Europe/Kiev), which Intl still
+        // reports on some runtimes; without one in the request, the viewer's own from the
+        // dashboard's cookie, so a page opened from a menu isn't drawn in UTC and then again
+        $aZones = timezone_identifiers_list(DateTimeZone::ALL_WITH_BC);
+        $sTz = 'UTC';
+        if(isset($aInput['tz']) && is_string($aInput['tz']) && in_array($aInput['tz'], $aZones, true))
+            $sTz = $aInput['tz'];
+        elseif(!isset($aInput['tz']) && isset($_COOKIE[self::TZ_COOKIE]) && is_string($_COOKIE[self::TZ_COOKIE]) && in_array($_COOKIE[self::TZ_COOKIE], $aZones, true))
+            $sTz = $_COOKIE[self::TZ_COOKIE];
+        $oTz = new DateTimeZone($sTz);
+
+        $oNow = (new DateTime('@' . $this->_iNow))->setTimezone($oTz);
+        $oToday = (clone $oNow)->setTime(0, 0);
+        $oTomorrow = (clone $oToday)->modify('+1 day');
+
+        $sBucket = 'day';
+        switch($sRange) {
+            case 'today':
+                $oFrom = $oToday;
+                $oTo = $oTomorrow;
+                $sBucket = 'hour';
+                break;
+
+            case 'yesterday':
+                $oFrom = (clone $oToday)->modify('-1 day');
+                $oTo = $oToday;
+                $sBucket = 'hour';
+                break;
+
+            case '24h':
+                $oTo = (clone $oNow)->setTime((int)$oNow->format('G'), 0)->modify('+1 hour');
+                $oFrom = (clone $oTo)->modify('-24 hours');
+                $sBucket = 'hour';
+                break;
+
+            case '30d':
+                $oFrom = (clone $oToday)->modify('-29 days');
+                $oTo = $oTomorrow;
+                break;
+
+            case '90d':
+                $oFrom = (clone $oToday)->modify('-89 days');
+                $oTo = $oTomorrow;
+                $sBucket = 'week';
+                break;
+
+            case '12m':
+                $oFrom = (clone $oToday)->modify('first day of this month')->modify('-11 months');
+                $oTo = (clone $oToday)->modify('first day of next month');
+                $sBucket = 'month';
+                break;
+
+            case 'custom':
+                $oFrom = $this->_date($aInput, 'from', $oTz);
+                $oTo = $this->_date($aInput, 'to', $oTz);
+                if($oFrom && $oTo && $oFrom <= $oTo) {
+                    $oTo = (clone $oTo)->modify('+1 day');
+                    $iDays = (int)round(($oTo->getTimestamp() - $oFrom->getTimestamp()) / 86400);
+                    if($iDays > self::RANGE_MAX_DAYS)
+                        $oFrom = (clone $oTo)->modify('-' . self::RANGE_MAX_DAYS . ' days');
+
+                    $sBucket = $iDays <= 2 ? 'hour' : ($iDays <= 62 ? 'day' : ($iDays <= 180 ? 'week' : 'month'));
+                    break;
+                }
+                $sRange = self::RANGE_DEFAULT;
+                // invalid dates: the default period
+
+            case '7d':
+            default:
+                $oFrom = (clone $oToday)->modify('-6 days');
+                $oTo = $oTomorrow;
+                break;
+        }
+
+        $aBack = ['today' => '-1 day', 'yesterday' => '-1 day', '24h' => '-24 hours', '7d' => '-7 days', '30d' => '-30 days', '90d' => '-90 days', '12m' => '-12 months'];
+        $sBack = isset($aBack[$sRange]) ? $aBack[$sRange] : '-' . (int)round(($oTo->getTimestamp() - $oFrom->getTimestamp()) / 86400) . ' days';
+        $this->_iPreviousFrom = (clone $oFrom)->modify($sBack)->getTimestamp();
+
+        // the bucket may be overridden to anything that keeps the chart readable
+        if(isset($aInput['bucket']) && in_array($aInput['bucket'], ['hour', 'day', 'week', 'month'], true)) {
+            $iHours = ($oTo->getTimestamp() - $oFrom->getTimestamp()) / 3600;
+            $aMaxHours = ['hour' => 24 * 7, 'day' => 24 * 190, 'week' => 24 * 740, 'month' => PHP_INT_MAX];
+            if($iHours <= $aMaxHours[$aInput['bucket']])
+                $sBucket = $aInput['bucket'];
+        }
+
+        $sPlatform = isset($aInput['platform']) && $this->_oConfig->isPlatform($aInput['platform']) ? $aInput['platform'] : '';
+
+        $aDims = [];
+        if(isset($aInput['f']) && is_array($aInput['f']))
+            foreach($aInput['f'] as $sName => $sValue)
+                if(is_string($sName) && is_string($sValue) && $this->_oDb->isDimension($sName) && $sName != 'platform')
+                    $aDims[$sName] = get_mb_substr($sValue, 0, 255);
+
+        $iFrom = $oFrom->getTimestamp();
+        $iTo = $oTo->getTimestamp();
+        $iOffset = $oFrom->getOffset();
+
+        // fixed-length buckets drift by an hour across a daylight saving change inside the period
+        // (two changes leave the ends at the same offset); then the exact local starts go to the
+        // database as a list, as months always do. getTransitions() gives the state at the
+        // start, then one entry per change.
+        $aStarts = [];
+        if($sBucket == 'month')
+            $aStarts = $this->_months($oFrom, $oTo);
+        elseif(in_array($sBucket, ['day', 'week'], true) && count($oTz->getTransitions($iFrom, $iTo - 1)) > 1)
+            $aStarts = $this->_localStarts($oFrom, $oTo, $sBucket);
+
+        return [
+            'range' => $sRange,
+            'tz' => $sTz,
+            'from' => $iFrom,
+            'to' => $iTo,
+            'from_date' => $oFrom->format('Y-m-d'),
+            'to_date' => (clone $oTo)->modify('-1 second')->format('Y-m-d'),
+            'now' => $this->_iNow,
+            'platform' => $sPlatform,
+            'dims' => $aDims,
+            'bucket' => $sBucket,
+            'offset' => $iOffset,
+            'starts' => $aStarts,
+            'buckets' => !empty($aStarts) ? $aStarts : $this->_buckets($iFrom, $iTo, $sBucket, $iOffset),
+        ];
+    }
+
+    /**
+     * Every bucket start in the period, computed the way BxAnalyticsDb buckets rows.
+     */
+    protected function _buckets($iFrom, $iTo, $sBucket, $iOffset)
+    {
+        $aSizes = ['hour' => 3600, 'day' => 86400, 'week' => 604800];
+        $iSize = $aSizes[$sBucket];
+        $iShift = $sBucket == 'week' ? 345600 : 0;
+
+        $iStart = (int)(floor(($iFrom + $iOffset - $iShift) / $iSize) * $iSize + $iShift - $iOffset);
+
+        $aResult = [];
+        for($i = $iStart; $i < $iTo && count($aResult) < 2000; $i += $iSize)
+            $aResult[] = $i;
+
+        return $aResult;
+    }
+
+    protected function _months($oFrom, $oTo)
+    {
+        $aResult = [];
+        $oMonth = (clone $oFrom)->modify('first day of this month')->setTime(0, 0);
+        while($oMonth < $oTo && count($aResult) < 100) {
+            $aResult[] = $oMonth->getTimestamp();
+            $oMonth->modify('first day of next month');
+        }
+
+        return $aResult;
+    }
+
+    /**
+     * Local midnights of every day, or of every Monday, in the period.
+     */
+    protected function _localStarts($oFrom, $oTo, $sBucket)
+    {
+        $oStart = (clone $oFrom)->setTime(0, 0);
+        if($sBucket == 'week')
+            $oStart->modify('-' . ((int)$oStart->format('N') - 1) . ' days');
+
+        $sStep = $sBucket == 'week' ? '+1 week' : '+1 day';
+
+        $aResult = [];
+        while($oStart < $oTo && count($aResult) < 800) {
+            $aResult[] = $oStart->getTimestamp();
+            $oStart->modify($sStep)->setTime(0, 0);
+        }
+
+        return $aResult;
+    }
+
+    protected function _date($aInput, $sKey, $oTz)
+    {
+        if(!isset($aInput[$sKey]) || !is_string($aInput[$sKey]) || !preg_match('/^\d{4}-\d{2}-\d{2}$/', $aInput[$sKey]))
+            return null;
+
+        $oDate = DateTime::createFromFormat('!Y-m-d', $aInput[$sKey], $oTz);
+
+        return $oDate ? $oDate : null;
+    }
+}
+
+/** @} */

+ 82 - 0
modules/boonex/analytics/classes/BxAnalyticsGeo.php

@@ -0,0 +1,82 @@
+<?php defined('BX_DOL') or die('hack attempt');
+/**
+ * Copyright (c) UNA, Inc - https://una.io
+ * MIT License - https://opensource.org/licenses/MIT
+ *
+ * @defgroup    Analytics Analytics
+ * @ingroup     UnaModules
+ *
+ * @{
+ */
+
+/**
+ * Approximate centre of each country (ISO 3166-1 alpha-2), where the map puts a country's
+ * visitors when a hit has no coordinates of its own.
+ */
+class BxAnalyticsGeo
+{
+    protected static $_aCenters = [
+        'AD' => [42.5, 1.6], 'AE' => [23.4, 53.8], 'AF' => [33.9, 67.7], 'AG' => [17.1, -61.8], 'AI' => [18.2, -63.1],
+        'AL' => [41.2, 20.2], 'AM' => [40.1, 45.0], 'AO' => [-11.2, 17.9], 'AQ' => [-75.3, -0.1], 'AR' => [-38.4, -63.6],
+        'AS' => [-14.3, -170.1], 'AT' => [47.5, 14.6], 'AU' => [-25.3, 133.8], 'AW' => [12.5, -70.0], 'AX' => [60.2, 20.0],
+        'AZ' => [40.1, 47.6], 'BA' => [43.9, 17.7], 'BB' => [13.2, -59.5], 'BD' => [23.7, 90.4], 'BE' => [50.5, 4.5],
+        'BF' => [12.2, -1.6], 'BG' => [42.7, 25.5], 'BH' => [25.9, 50.6], 'BI' => [-3.4, 29.9], 'BJ' => [9.3, 2.3],
+        'BL' => [17.9, -62.8], 'BM' => [32.3, -64.8], 'BN' => [4.5, 114.7], 'BO' => [-16.3, -63.6], 'BQ' => [12.2, -68.3],
+        'BR' => [-14.2, -51.9], 'BS' => [25.0, -77.4], 'BT' => [27.5, 90.4], 'BW' => [-22.3, 24.7], 'BY' => [53.7, 28.0],
+        'BZ' => [17.2, -88.5], 'CA' => [56.1, -106.3], 'CC' => [-12.2, 96.9], 'CD' => [-4.0, 21.8], 'CF' => [6.6, 20.9],
+        'CG' => [-0.2, 15.8], 'CH' => [46.8, 8.2], 'CI' => [7.5, -5.5], 'CK' => [-21.2, -159.8], 'CL' => [-35.7, -71.5],
+        'CM' => [7.4, 12.4], 'CN' => [35.9, 104.2], 'CO' => [4.6, -74.3], 'CR' => [9.7, -83.8], 'CU' => [21.5, -77.8],
+        'CV' => [16.0, -24.0], 'CW' => [12.2, -69.0], 'CX' => [-10.4, 105.7], 'CY' => [35.1, 33.4], 'CZ' => [49.8, 15.5],
+        'DE' => [51.2, 10.5], 'DJ' => [11.8, 42.6], 'DK' => [56.3, 9.5], 'DM' => [15.4, -61.4], 'DO' => [18.7, -70.2],
+        'DZ' => [28.0, 1.7], 'EC' => [-1.8, -78.2], 'EE' => [58.6, 25.0], 'EG' => [26.8, 30.8], 'EH' => [24.2, -12.9],
+        'ER' => [15.2, 39.8], 'ES' => [40.5, -3.7], 'ET' => [9.1, 40.5], 'FI' => [61.9, 25.7], 'FJ' => [-16.6, 179.4],
+        'FK' => [-51.8, -59.5], 'FM' => [7.4, 150.6], 'FO' => [61.9, -6.9], 'FR' => [46.2, 2.2], 'GA' => [-0.8, 11.6],
+        'GB' => [55.4, -3.4], 'GD' => [12.3, -61.6], 'GE' => [42.3, 43.4], 'GF' => [3.9, -53.1], 'GG' => [49.5, -2.6],
+        'GH' => [7.9, -1.0], 'GI' => [36.1, -5.3], 'GL' => [71.7, -42.6], 'GM' => [13.4, -15.3], 'GN' => [9.9, -9.7],
+        'GP' => [16.3, -61.6], 'GQ' => [1.7, 10.3], 'GR' => [39.1, 21.8], 'GS' => [-54.4, -36.6], 'GT' => [15.8, -90.2],
+        'GU' => [13.4, 144.8], 'GW' => [11.8, -15.2], 'GY' => [4.9, -58.9], 'HK' => [22.4, 114.1], 'HN' => [15.2, -86.2],
+        'HR' => [45.1, 15.2], 'HT' => [19.0, -72.3], 'HU' => [47.2, 19.5], 'ID' => [-0.8, 113.9], 'IE' => [53.4, -8.2],
+        'IL' => [31.0, 34.9], 'IM' => [54.2, -4.5], 'IN' => [20.6, 79.0], 'IO' => [-6.3, 71.9], 'IQ' => [33.2, 43.7],
+        'IR' => [32.4, 53.7], 'IS' => [65.0, -19.0], 'IT' => [41.9, 12.6], 'JE' => [49.2, -2.1], 'JM' => [18.1, -77.3],
+        'JO' => [30.6, 36.2], 'JP' => [36.2, 138.3], 'KE' => [-0.0, 37.9], 'KG' => [41.2, 74.8], 'KH' => [12.6, 105.0],
+        'KI' => [-3.4, -168.7], 'KM' => [-11.9, 43.9], 'KN' => [17.4, -62.8], 'KP' => [40.3, 127.5], 'KR' => [35.9, 127.8],
+        'KW' => [29.3, 47.5], 'KY' => [19.5, -80.6], 'KZ' => [48.0, 66.9], 'LA' => [19.9, 102.5], 'LB' => [33.9, 35.9],
+        'LC' => [13.9, -61.0], 'LI' => [47.2, 9.6], 'LK' => [7.9, 80.8], 'LR' => [6.4, -9.4], 'LS' => [-29.6, 28.2],
+        'LT' => [55.2, 23.9], 'LU' => [49.8, 6.1], 'LV' => [56.9, 24.6], 'LY' => [26.3, 17.2], 'MA' => [31.8, -7.1],
+        'MC' => [43.7, 7.4], 'MD' => [47.4, 28.4], 'ME' => [42.7, 19.4], 'MF' => [18.1, -63.1], 'MG' => [-18.8, 46.9],
+        'MH' => [7.1, 171.2], 'MK' => [41.6, 21.7], 'ML' => [17.6, -4.0], 'MM' => [21.9, 96.0], 'MN' => [46.9, 103.8],
+        'MO' => [22.2, 113.5], 'MP' => [17.3, 145.4], 'MQ' => [14.6, -61.0], 'MR' => [21.0, -10.9], 'MS' => [16.7, -62.2],
+        'MT' => [35.9, 14.4], 'MU' => [-20.3, 57.6], 'MV' => [3.2, 73.2], 'MW' => [-13.3, 34.3], 'MX' => [23.6, -102.6],
+        'MY' => [4.2, 102.0], 'MZ' => [-18.7, 35.5], 'NA' => [-22.96, 18.5], 'NC' => [-20.9, 165.6], 'NE' => [17.6, 8.1],
+        'NF' => [-29.0, 167.9], 'NG' => [9.1, 8.7], 'NI' => [12.9, -85.2], 'NL' => [52.1, 5.3], 'NO' => [60.5, 8.5],
+        'NP' => [28.4, 84.1], 'NR' => [-0.5, 166.9], 'NU' => [-19.1, -169.9], 'NZ' => [-40.9, 174.9], 'OM' => [21.5, 55.9],
+        'PA' => [8.5, -80.8], 'PE' => [-9.2, -75.0], 'PF' => [-17.7, -149.4], 'PG' => [-6.3, 143.96], 'PH' => [12.9, 121.8],
+        'PK' => [30.4, 69.3], 'PL' => [51.9, 19.1], 'PM' => [46.9, -56.3], 'PN' => [-24.7, -127.4], 'PR' => [18.2, -66.6],
+        'PS' => [31.95, 35.2], 'PT' => [39.4, -8.2], 'PW' => [7.5, 134.6], 'PY' => [-23.4, -58.4], 'QA' => [25.4, 51.2],
+        'RE' => [-21.1, 55.5], 'RO' => [45.9, 25.0], 'RS' => [44.0, 21.0], 'RU' => [61.5, 105.3], 'RW' => [-1.9, 29.9],
+        'SA' => [23.9, 45.1], 'SB' => [-9.6, 160.2], 'SC' => [-4.7, 55.5], 'SD' => [12.9, 30.2], 'SE' => [60.1, 18.6],
+        'SG' => [1.35, 103.8], 'SH' => [-24.1, -10.0], 'SI' => [46.2, 15.0], 'SJ' => [77.6, 23.7], 'SK' => [48.7, 19.7],
+        'SL' => [8.5, -11.8], 'SM' => [43.9, 12.5], 'SN' => [14.5, -14.5], 'SO' => [5.2, 46.2], 'SR' => [3.9, -56.0],
+        'SS' => [6.9, 31.3], 'ST' => [0.2, 6.6], 'SV' => [13.8, -88.9], 'SX' => [18.0, -63.1], 'SY' => [34.8, 39.0],
+        'SZ' => [-26.5, 31.5], 'TC' => [21.7, -71.8], 'TD' => [15.5, 18.7], 'TF' => [-49.3, 69.3], 'TG' => [8.6, 0.8],
+        'TH' => [15.9, 101.0], 'TJ' => [38.9, 71.3], 'TK' => [-8.97, -171.9], 'TL' => [-8.9, 125.7], 'TM' => [38.97, 59.6],
+        'TN' => [33.9, 9.5], 'TO' => [-21.2, -175.2], 'TR' => [39.0, 35.2], 'TT' => [10.7, -61.2], 'TV' => [-7.1, 177.6],
+        'TW' => [23.7, 121.0], 'TZ' => [-6.4, 34.9], 'UA' => [48.4, 31.2], 'UG' => [1.4, 32.3], 'UM' => [19.3, 166.6],
+        'US' => [37.1, -95.7], 'UY' => [-32.5, -55.8], 'UZ' => [41.4, 64.6], 'VA' => [41.9, 12.5], 'VC' => [12.98, -61.3],
+        'VE' => [6.4, -66.6], 'VG' => [18.4, -64.6], 'VI' => [18.3, -64.9], 'VN' => [14.1, 108.3], 'VU' => [-15.4, 166.96],
+        'WF' => [-13.8, -177.2], 'WS' => [-13.8, -172.1], 'XK' => [42.6, 20.9], 'YE' => [15.6, 48.5], 'YT' => [-12.8, 45.2],
+        'ZA' => [-30.6, 22.9], 'ZM' => [-13.1, 27.8], 'ZW' => [-19.0, 29.2],
+    ];
+
+    /**
+     * [lat, lng] of a country, or null.
+     */
+    public static function getCenter($sCountry)
+    {
+        $sCountry = mb_strtoupper((string)$sCountry);
+
+        return isset(self::$_aCenters[$sCountry]) ? self::$_aCenters[$sCountry] : null;
+    }
+}
+
+/** @} */

Fișier diff suprimat deoarece este prea mare
+ 1730 - 96
modules/boonex/analytics/classes/BxAnalyticsModule.php


+ 3 - 1
modules/boonex/analytics/classes/BxAnalyticsPage.php

@@ -21,7 +21,9 @@ class BxAnalyticsPage extends BxTemplPage
         if($oMenuSubmenu) {
             $sMenuSubmenu = 'sys_account_dashboard';
             $oMenuSubmenu->setObjectSubmenu($sMenuSubmenu, array('title' => _t('_sys_menu_item_title_account_dashboard'), 'link' => '', 'icon' => ''));
-            BxDolMenu::getObjectInstance($sMenuSubmenu)->setSelected($this->MODULE, 'analytics');
+            // the item's name in sys_account_dashboard: the Revenue page has its own, named like its URI
+            $sItem = isset($aObject['uri']) && $aObject['uri'] == 'analytics-revenue' ? 'analytics-revenue' : 'dashboard-analytics';
+            BxDolMenu::getObjectInstance($sMenuSubmenu)->setSelected($this->MODULE, $sItem);
         }
     }
 }

+ 3006 - 0
modules/boonex/analytics/classes/BxAnalyticsReports.php

@@ -0,0 +1,3006 @@
+<?php defined('BX_DOL') or die('hack attempt');
+/**
+ * Copyright (c) UNA, Inc - https://una.io
+ * MIT License - https://opensource.org/licenses/MIT
+ *
+ * @defgroup    Analytics Analytics
+ * @ingroup     UnaModules
+ *
+ * @{
+ */
+
+/**
+ * Report data as plain arrays. The classic templates, Studio and the NEO elements all render
+ * from these, so a number means the same thing everywhere.
+ *
+ * Every report is a method get<Report>(BxAnalyticsFilter $oFilter, array $aParams) that returns
+ * an array, or false when the parameters name nothing it knows.
+ */
+class BxAnalyticsReports
+{
+    /**
+     * Tabbed top lists: group => [tab => dimension]. A block shows one group.
+     */
+    protected $_aGroups = [
+        'pages' => ['pages' => 'path', 'titles' => 'title', 'entry' => 'entry_path', 'exit' => 'exit_path', 'hostnames' => 'hostname'],
+        'sources' => ['referrers' => 'referrer_host', 'channels' => 'channel', 'utm_source' => 'utm_source', 'utm_medium' => 'utm_medium', 'utm_campaign' => 'utm_campaign'],
+        'locations' => ['countries' => 'country', 'regions' => 'region', 'cities' => 'city', 'languages' => 'language'],
+        'devices' => ['browsers' => 'browser', 'devices' => 'device', 'os' => 'os', 'screens' => 'screen'],
+        'platforms' => ['platforms' => 'platform', 'apps' => 'app', 'app_versions' => 'app_version'],
+    ];
+
+    /**
+     * Full tables with every metric: table => [tab => dimension].
+     */
+    protected $_aTables = [
+        'pages' => ['pages' => 'path', 'titles' => 'title', 'entry' => 'entry_path', 'exit' => 'exit_path', 'hostnames' => 'hostname'],
+        'sources' => ['referrers' => 'referrer_host', 'referrer_urls' => 'referrer', 'channels' => 'channel', 'utm_source' => 'utm_source', 'utm_medium' => 'utm_medium', 'utm_campaign' => 'utm_campaign', 'utm_term' => 'utm_term', 'utm_content' => 'utm_content'],
+    ];
+
+    protected $_aMetrics = ['visitors', 'sessions', 'pageviews', 'pages_per_session', 'bounce_rate', 'duration'];
+
+    /**
+     * Tabs of the other reports: report => [tab => what the tab shows]. The first is the default.
+     * - map: the period's visitors or those online now
+     * - events: event types
+     * - retention: whether only members count (cohorts of member visitors)
+     * - errors: statuses (`new` is open or resolved, first seen in the period)
+     * - performance: pages grouped by route or by path
+     * - revenue: splits as columns of the ledger
+     */
+    protected $_aReportTabs = [
+        'map' => ['period' => 'period', 'live' => 'live'],
+        // set in the constructor: the hit types are constants of the config, which the module loads on first use
+        'events' => [],
+        'retention' => ['members' => true, 'visitors' => false],
+        'errors' => [
+            'open' => ['open'],
+            'new' => ['open', 'resolved'],
+            'resolved' => ['resolved'],
+            'ignored' => ['ignored'],
+            'all' => ['open', 'resolved', 'ignored'],
+        ],
+        'performance' => ['route' => 'route', 'path' => 'path'],
+        'revenue' => ['sources' => 'module', 'providers' => 'provider', 'platforms' => 'platform', 'first_touch' => 'first_channel', 'last_touch' => 'last_channel', 'campaigns' => 'last_utm_campaign'],
+    ];
+
+    protected $_iPerPage = 25;
+
+    protected $_oModule;
+    protected $_oDb;
+    protected $_oConfig;
+
+    protected $_aCountries;
+    protected $_aModuleTitles;
+
+    public function __construct($oModule)
+    {
+        $this->_oModule = $oModule;
+        $this->_oDb = $oModule->_oDb;
+        $this->_oConfig = $oModule->_oConfig;
+
+        $this->_aReportTabs['events'] = ['all' => [BX_ANALYTICS_HIT_EVENT, BX_ANALYTICS_HIT_UNA], 'custom' => [BX_ANALYTICS_HIT_EVENT], 'site' => [BX_ANALYTICS_HIT_UNA]];
+
+        $this->_oDb->setReportSession();
+    }
+
+    /**
+     * Whether a report shows live figures (visitors online, the last minutes), which are
+     * never cached.
+     */
+    public function isLive($sReport, $aParams = [])
+    {
+        if(in_array($sReport, ['realtime', 'filters'], true))
+            return true;
+
+        return $sReport == 'map' && $this->_getTab($aParams, 'map', $this->_aReportTabs['map']) == 'live';
+    }
+
+    /**
+     * The parameters a report reads, past the filter: its cache name is made of them. Blocks
+     * send the whole page URL, which also holds other blocks' tabs and pages, the chart's
+     * metric and opened rows; those would make a copy per combination of things the report
+     * never shows. A report missing here keeps every parameter.
+     */
+    public function getCacheParams($sReport, $aParams)
+    {
+        $aKeys = [
+            'overview' => [],
+            'top' => ['group', 'tab'],
+            'table' => ['table', 'tab', 'page'],
+            'query' => ['q_dim', 'q_metric', 'q_dir', 'page', 'page_query'],
+            'map' => ['tab', 'tab_map'],
+            'bots' => ['tab', 'tab_bots', 'page_bot_list'],
+            'events' => ['event', 'tab', 'tab_events', 'page_events'],
+            'sessions' => ['session', 'page_sessions'],
+            'users' => ['user', 'session', 'page_users', 'page_sessions'],
+            'journeys' => ['jfrom', 'via'],
+            'retention' => ['tab', 'tab_retention'],
+            'goals' => ['edit_goal'],
+            'funnels' => ['funnel', 'edit_funnel'],
+            'revenue' => ['tab', 'tab_revenue'],
+            'subscriptions' => [],
+            'sales' => [],
+            'credits' => [],
+            'errors' => ['error', 'tab', 'tab_errors', 'page_errors'],
+            'performance' => ['vital', 'vital_device', 'tab', 'tab_performance', 'page_performance'],
+        ];
+        if(!isset($aKeys[$sReport]))
+            return $aParams;
+
+        // a top list's tab, and a table's tab and page, are named after the group or the table
+        $aOwn = array_merge($aKeys[$sReport], ['limit', 'meta']);
+        if($sReport == 'top' && ($sGroup = (string)$this->_param($aParams, 'group')) !== '')
+            $aOwn[] = 'tab_' . $sGroup;
+        if($sReport == 'table' && ($sTable = (string)$this->_param($aParams, 'table')) !== '')
+            array_push($aOwn, 'tab_' . $sTable, 'page_' . $sTable);
+
+        return array_intersect_key($aParams, array_flip($aOwn));
+    }
+
+    public function getGroups()
+    {
+        return $this->_aGroups;
+    }
+
+    public function isGroup($sGroup)
+    {
+        return isset($this->_aGroups[$sGroup]);
+    }
+
+    public function getMetrics()
+    {
+        return $this->_aMetrics;
+    }
+
+    /**
+     * What get_report accepts, for the API playground: the filter parameters every report
+     * reads, and each report's own. A parameter is [type, values, min, max, default] with
+     * type enum (one of values), enum_by (one of values[the value of the parameter `by`]),
+     * int (min to max), date (YYYY-MM-DD), string, or dims (an object of dimension => value,
+     * the dimensions in values).
+     *
+     * @return array common => parameters, reports => [report => parameters]
+     */
+    public function getParamsSchema()
+    {
+        $fEnum = function($aValues, $sDefault = null) {
+            return ['type' => 'enum', 'values' => array_values($aValues), 'default' => $sDefault !== null ? $sDefault : reset($aValues)];
+        };
+        $fInt = function($iMin, $iMax = null, $iDefault = null) {
+            return array_filter(['type' => 'int', 'min' => $iMin, 'max' => $iMax, 'default' => $iDefault], function($m) { return $m !== null; });
+        };
+        $fString = function() {
+            return ['type' => 'string'];
+        };
+
+        // a tab is one of its group's or table's: group or table => tabs
+        $fTabs = function($sBy, $aLists) {
+            return ['type' => 'enum_by', 'by' => $sBy, 'values' => array_map('array_keys', $aLists)];
+        };
+
+        $aDimensions = array_keys($this->_oDb->getDimensions());
+        $aQueryMetrics = [];
+        foreach($aDimensions as $sDimension)
+            $aQueryMetrics = array_merge($aQueryMetrics, $this->_oDb->getBreakdownMetrics($sDimension));
+
+        bx_import('Filter', $this->_oModule->_aModule);
+
+        return [
+            'common' => [
+                'range' => $fEnum(BxAnalyticsFilter::$aRanges, BxAnalyticsFilter::RANGE_DEFAULT),
+                'from' => ['type' => 'date'],
+                'to' => ['type' => 'date'],
+                'tz' => ['type' => 'string', 'default' => 'UTC'],
+                'platform' => ['type' => 'enum', 'values' => array_keys($this->_oConfig->getPlatforms())],
+                'bucket' => ['type' => 'enum', 'values' => ['hour', 'day', 'week', 'month']],
+                'f' => ['type' => 'dims', 'values' => array_values(array_diff($aDimensions, ['platform']))],
+            ],
+            'reports' => [
+                'filters' => [],
+                'overview' => [],
+                'top' => ['group' => $fEnum(array_keys($this->_aGroups)), 'tab' => $fTabs('group', $this->_aGroups), 'limit' => $fInt(1, 100, 10)],
+                'table' => ['table' => $fEnum(array_keys($this->_aTables)), 'tab' => $fTabs('table', $this->_aTables), 'page' => $fInt(1)],
+                'realtime' => ['limit' => $fInt(0, 50, 20)],
+                'map' => ['tab' => $fEnum(array_keys($this->_aReportTabs['map']))],
+                'bots' => ['tab' => $fEnum(array_keys($this->_getBotCategories())), 'page_bot_list' => $fInt(1)],
+                'events' => ['event' => $fString(), 'tab' => $fEnum(array_keys($this->_aReportTabs['events'])), 'page_events' => $fInt(1)],
+                'sessions' => ['session' => $fInt(1), 'page_sessions' => $fInt(1)],
+                'users' => ['user' => $fInt(1), 'session' => $fInt(1), 'page_users' => $fInt(1)],
+                'journeys' => ['jfrom' => $fString(), 'via' => $fString()],
+                'retention' => ['tab' => $fEnum(array_keys($this->_aReportTabs['retention']))],
+                'goals' => [],
+                'funnels' => ['funnel' => $fInt(1)],
+                'revenue' => ['tab' => $fEnum(array_keys($this->_aReportTabs['revenue']))],
+                'subscriptions' => [],
+                'sales' => [],
+                'credits' => [],
+                'errors' => ['error' => $fInt(1), 'tab' => $fEnum(array_keys($this->_aReportTabs['errors'])), 'page_errors' => $fInt(1)],
+                'performance' => ['vital' => $fEnum(BxAnalyticsVitals::METRICS), 'vital_device' => $fEnum(['all', 'mobile', 'desktop']), 'tab' => $fEnum(array_keys($this->_aReportTabs['performance'])), 'page_performance' => $fInt(1)],
+                'query' => ['q_dim' => $fEnum($aDimensions), 'q_metric' => $fEnum(array_unique($aQueryMetrics)), 'q_dir' => $fEnum(['desc', 'asc']), 'page_query' => $fInt(1)],
+            ],
+        ];
+    }
+
+    /**
+     * Bot tabs: tab => bot categories (none for all).
+     */
+    protected function _getBotCategories()
+    {
+        bx_import('UserAgent', $this->_oModule->_aModule);
+
+        return [
+            'all' => [],
+            'ai' => [BxAnalyticsUserAgent::BOT_AI_CRAWLER, BxAnalyticsUserAgent::BOT_AI_AGENT],
+            'search' => [BxAnalyticsUserAgent::BOT_SEARCH],
+            'social' => [BxAnalyticsUserAgent::BOT_SOCIAL],
+            'seo' => [BxAnalyticsUserAgent::BOT_SEO],
+            'monitor' => [BxAnalyticsUserAgent::BOT_MONITOR],
+            'script' => [BxAnalyticsUserAgent::BOT_SCRIPT, BxAnalyticsUserAgent::BOT_FEED, BxAnalyticsUserAgent::BOT_OTHER],
+        ];
+    }
+
+    /**
+     * The filter bar: active dimension filters with readable values, and visitors online.
+     */
+    public function getFilters(BxAnalyticsFilter $oFilter, $aParams = [])
+    {
+        $aFilter = $oFilter->get();
+
+        $aChips = [];
+        foreach($aFilter['dims'] as $sName => $sValue)
+            $aChips[] = [
+                'name' => $sName,
+                'value' => $sValue,
+                'title' => _t('_bx_analytics_dim_' . $sName),
+                'label' => $this->getLabel($sName, $sValue),
+            ];
+
+        $aOnline = $this->_oDb->getOnline(time() - $this->_oConfig->getOnlineWindow());
+
+        return [
+            'chips' => $aChips,
+            'online' => $aFilter['platform'] !== '' ? (isset($aOnline[$aFilter['platform']]) ? (int)$aOnline[$aFilter['platform']] : 0) : (int)array_sum($aOnline),
+            'window' => (int)($this->_oConfig->getOnlineWindow() / 60),
+        ];
+    }
+
+    /**
+     * KPIs with the change against the previous period, and the series for the chart.
+     */
+    public function getOverview(BxAnalyticsFilter $oFilter, $aParams = [])
+    {
+        $aFilter = $oFilter->get();
+
+        $aCurrent = $this->_getKpis($this->_oDb->getTotals($aFilter));
+        $aPrevious = $this->_getKpis($this->_oDb->getTotals($oFilter->getPrevious()));
+
+        $aKpis = [];
+        foreach($this->_aMetrics as $sMetric)
+            $aKpis[$sMetric] = [
+                'value' => $aCurrent[$sMetric],
+                'previous' => $aPrevious[$sMetric],
+                'change' => $this->_change($aCurrent[$sMetric], $aPrevious[$sMetric]),
+            ];
+
+        $aRows = $this->_oDb->getSeries($aFilter, $aFilter['bucket'], $aFilter['offset'], $aFilter['starts']);
+
+        // the bucket that holds "now" is still filling up, and the chart draws it dashed
+        $aSeries = [];
+        $aBuckets = $aFilter['buckets'];
+        foreach($aBuckets as $i => $iBucket) {
+            $iNext = isset($aBuckets[$i + 1]) ? $aBuckets[$i + 1] : $aFilter['to'];
+            $aKpi = $this->_getKpis(isset($aRows[$iBucket]) ? $aRows[$iBucket] : []);
+            $aSeries[] = array_merge([
+                't' => $iBucket,
+                'future' => $iBucket > $aFilter['now'],
+                'partial' => $iBucket <= $aFilter['now'] && $aFilter['now'] < $iNext,
+            ], $aKpi);
+        }
+
+        return [
+            'kpis' => $aKpis,
+            'series' => $aSeries,
+            'bucket' => $aFilter['bucket'],
+            'from' => $aFilter['from'],
+            'to' => $aFilter['to'],
+            'tz' => $aFilter['tz'],
+        ];
+    }
+
+    /**
+     * One tab of a top list, with labels, shares and the filter each row applies when clicked.
+     * Params: group, tab (or tab_<group>), limit.
+     */
+    public function getTop(BxAnalyticsFilter $oFilter, $aParams = [])
+    {
+        $sGroup = $this->_param($aParams, 'group');
+        if(!isset($this->_aGroups[$sGroup]))
+            return false;
+
+        $sTab = $this->_getTab($aParams, $sGroup, $this->_aGroups[$sGroup]);
+        $sDimension = $this->_aGroups[$sGroup][$sTab];
+        $iLimit = isset($aParams['limit']) ? max(1, min(100, (int)$aParams['limit'])) : 10;
+
+        $aRows = $this->_oDb->getTop($oFilter->get(), $sDimension, $iLimit);
+
+        $iMax = 0;
+        $iTotal = 0;
+        foreach($aRows as $aRow) {
+            $iMax = max($iMax, (int)$aRow['visitors']);
+            $iTotal += (int)$aRow['visitors'];
+        }
+
+        $aItems = [];
+        foreach($aRows as $aRow)
+            $aItems[] = [
+                'value' => (string)$aRow['value'],
+                'label' => $this->getLabel($sDimension, (string)$aRow['value']),
+                'visitors' => (int)$aRow['visitors'],
+                'pageviews' => (int)$aRow['pageviews'],
+                'share' => $iTotal ? round(100 * $aRow['visitors'] / $iTotal, 1) : 0,
+                'bar' => $iMax ? round(100 * $aRow['visitors'] / $iMax, 1) : 0,
+            ];
+
+        return [
+            'group' => $sGroup,
+            'tab' => $sTab,
+            'tabs' => $this->_getTabs($this->_aGroups[$sGroup], $sTab),
+            'dimension' => $sDimension,
+            'column' => _t('_bx_analytics_tab_' . $sTab),
+            'items' => $aItems,
+        ];
+    }
+
+    /**
+     * A full table of a dimension with every metric, a page at a time.
+     * Params: table (pages, sources), tab (or tab_<table>), page (or page_<table>).
+     */
+    public function getTable(BxAnalyticsFilter $oFilter, $aParams = [])
+    {
+        $sTable = $this->_param($aParams, 'table');
+        if(!isset($this->_aTables[$sTable]))
+            return false;
+
+        $sTab = $this->_getTab($aParams, $sTable, $this->_aTables[$sTable]);
+        $sDimension = $this->_aTables[$sTable][$sTab];
+        $aFilter = $oFilter->get();
+
+        $iTotal = $this->_oDb->getTopCount($aFilter, $sDimension);
+        $iPages = max(1, (int)ceil($iTotal / $this->_iPerPage));
+        // page_<table> first: in Studio `page` names the section
+        $iPage = min($iPages, max(1, (int)$this->_param($aParams, 'page_' . $sTable, $this->_param($aParams, 'page', 1))));
+        $iStart = ($iPage - 1) * $this->_iPerPage;
+
+        if($this->_oDb->isEventDimension($sDimension)) {
+            $aRows = $this->_oDb->getPageStats($aFilter, $sDimension, $this->_iPerPage, $iStart);
+            $aColumns = [
+                ['key' => 'label', 'type' => 'label', 'title' => _t('_bx_analytics_tab_' . $sTab)],
+                ['key' => 'visitors', 'type' => 'number', 'title' => _t('_bx_analytics_metric_visitors')],
+                ['key' => 'pageviews', 'type' => 'number', 'title' => _t('_bx_analytics_metric_pageviews')],
+                ['key' => 'engaged', 'type' => 'engaged', 'title' => _t('_bx_analytics_metric_engaged')],
+            ];
+            if($sDimension == 'path')
+                $aColumns = array_merge($aColumns, [
+                    ['key' => 'entries', 'type' => 'number', 'title' => _t('_bx_analytics_metric_entries')],
+                    ['key' => 'bounce_rate', 'type' => 'percent', 'title' => _t('_bx_analytics_metric_bounce_rate')],
+                    ['key' => 'exits', 'type' => 'number', 'title' => _t('_bx_analytics_metric_exits')],
+                ]);
+        }
+        else {
+            $aRows = [];
+            foreach($this->_oDb->getTop($aFilter, $sDimension, $this->_iPerPage, $iStart) as $aRow)
+                $aRows[] = array_merge($aRow, $this->_getKpis($aRow));
+
+            $aColumns = [
+                ['key' => 'label', 'type' => 'label', 'title' => _t('_bx_analytics_tab_' . $sTab)],
+                ['key' => 'visitors', 'type' => 'number', 'title' => _t('_bx_analytics_metric_visitors')],
+                ['key' => 'sessions', 'type' => 'number', 'title' => _t('_bx_analytics_metric_sessions')],
+                ['key' => 'bounce_rate', 'type' => 'percent', 'title' => _t('_bx_analytics_metric_bounce_rate')],
+                ['key' => 'pages_per_session', 'type' => 'decimal', 'title' => _t('_bx_analytics_metric_pages_per_session')],
+                ['key' => 'duration', 'type' => 'duration', 'title' => _t('_bx_analytics_metric_duration')],
+            ];
+        }
+
+        $iMax = 0;
+        foreach($aRows as $aRow)
+            $iMax = max($iMax, (int)$aRow['visitors']);
+
+        $aResult = [];
+        foreach($aRows as $aRow)
+            $aResult[] = [
+                'label' => $this->getLabel($sDimension, (string)$aRow['value']),
+                'filter' => ['dim' => $sDimension, 'value' => (string)$aRow['value']],
+                'bar' => $iMax ? round(100 * $aRow['visitors'] / $iMax, 1) : 0,
+                'values' => $aRow,
+            ];
+
+        return [
+            'name' => $sTable,
+            'caption' => _t('_bx_analytics_group_' . $sTable),
+            'tab' => $sTab,
+            'tabs' => $this->_getTabs($this->_aTables[$sTable], $sTab),
+            'dimension' => $sDimension,
+            'columns' => $aColumns,
+            'rows' => $aResult,
+            'page' => $iPage,
+            'pages' => $iPages,
+            'total' => $iTotal,
+        ];
+    }
+
+    /**
+     * Explore any dimension: its values sorted by a metric, a page at a time, and the first
+     * few of them over time. Same shape as a table, so it renders and pages like one.
+     * Params: q_dim, q_metric, q_dir (see getQueryParams), page (or page_query).
+     */
+    public function getQuery(BxAnalyticsFilter $oFilter, $aParams = [])
+    {
+        $aQuery = $this->getQueryParams($aParams);
+        $sDimension = $aQuery['dimension'];
+        $sMetric = $aQuery['metric'];
+        $aFilter = $oFilter->get();
+
+        $iTotal = $this->_oDb->getTopCount($aFilter, $sDimension);
+        $iPages = max(1, (int)ceil($iTotal / $this->_iPerPage));
+        // page_query first: in Studio `page` names the section
+        $iPage = min($iPages, max(1, (int)$this->_param($aParams, 'page_query', $this->_param($aParams, 'page', 1))));
+        $iStart = ($iPage - 1) * $this->_iPerPage;
+
+        $aRows = $this->_getQueryRows($aFilter, $aQuery, $this->_iPerPage, $iStart);
+
+        // the chart follows the first values in this order, whatever page the table shows
+        $iSeries = (int)$this->_oConfig->CNF['QUERY_SERIES_VALUES'];
+        $aFirst = $iStart == 0 ? array_slice($aRows, 0, $iSeries) : $this->_getQueryRows($aFilter, $aQuery, $iSeries, 0, $iSeries, false);
+
+        $fMax = 0;
+        foreach($aRows as $aRow)
+            $fMax = max($fMax, (float)$aRow[$sMetric]);
+
+        $aResult = [];
+        foreach($aRows as $aRow)
+            $aResult[] = [
+                'label' => $this->getLabel($sDimension, $aRow['value']),
+                'filter' => ['dim' => $sDimension, 'value' => $aRow['value']],
+                'bar' => $fMax > 0 ? round(100 * $aRow[$sMetric] / $fMax, 1) : 0,
+                'values' => $aRow,
+            ];
+
+        $aDimensions = [];
+        foreach($this->_oDb->getDimensions() as $sName => $sKind)
+            $aDimensions[] = ['name' => $sName, 'title' => _t('_bx_analytics_dim_' . $sName), 'kind' => $sKind];
+
+        $aMetrics = [];
+        foreach($aQuery['metrics'] as $sName)
+            $aMetrics[] = ['name' => $sName, 'title' => _t('_bx_analytics_metric_' . $sName)];
+
+        return [
+            'name' => 'query',
+            'caption' => _t('_bx_analytics_dim_' . $sDimension),
+            'dimension' => $sDimension,
+            'kind' => $aQuery['kind'],
+            'metric' => $sMetric,
+            'dir' => $aQuery['dir'],
+            'dimensions' => $aDimensions,
+            'metrics' => $aMetrics,
+            'tabs' => [],
+            'columns' => $this->_getQueryColumns($aQuery),
+            'rows' => $aResult,
+            'page' => $iPage,
+            'pages' => $iPages,
+            'total' => $iTotal,
+            'export_max' => (int)$this->_oConfig->CNF['MAX_EXPORT_ROWS'],
+            'chart' => $this->_getQuerySeries($aFilter, $aQuery, $aFirst),
+        ];
+    }
+
+    /**
+     * What a query asks for, from q_dim (a dimension, see BxAnalyticsDb::getDimensions),
+     * q_metric (one the dimension can be sorted by) and q_dir (asc or desc). Anything unknown
+     * falls back to the first valid choice: pages, by visitors, highest first.
+     *
+     * @return array dimension, kind (event or session), metric, metrics (the dimension's
+     *      sortable metrics), dir
+     */
+    public function getQueryParams($aParams)
+    {
+        $aDimensions = $this->_oDb->getDimensions();
+        $sDimension = (string)$this->_param($aParams, 'q_dim');
+        if(!isset($aDimensions[$sDimension]))
+            $sDimension = key($aDimensions);
+
+        $aMetrics = $this->_oDb->getBreakdownMetrics($sDimension);
+        $sMetric = (string)$this->_param($aParams, 'q_metric');
+        if(!in_array($sMetric, $aMetrics, true))
+            $sMetric = $aMetrics[0];
+
+        return [
+            'dimension' => $sDimension,
+            'kind' => $aDimensions[$sDimension],
+            'metric' => $sMetric,
+            'metrics' => $aMetrics,
+            'dir' => $this->_param($aParams, 'q_dir') === 'asc' ? 'asc' : 'desc',
+        ];
+    }
+
+    /**
+     * Every value of a report's dimension with its metrics, for a CSV download: the query,
+     * or a table or top list tab (by visitors). Rows are the label, the stored value and the
+     * metrics as plain numbers.
+     *
+     * @param string $sReport query, table or top
+     * @param int $iMax rows at most
+     * @return array dimension, header, rows, total (values in the period), truncated; or false
+     */
+    public function getExport($sReport, BxAnalyticsFilter $oFilter, $aParams, $iMax)
+    {
+        switch($sReport) {
+            case 'query':
+                $aQuery = $this->getQueryParams($aParams);
+                break;
+
+            case 'table':
+            case 'top':
+                $aLists = $sReport == 'table' ? $this->_aTables : $this->_aGroups;
+                $sList = (string)$this->_param($aParams, $sReport == 'table' ? 'table' : 'group');
+                if(!isset($aLists[$sList]))
+                    return false;
+                $aQuery = $this->getQueryParams(['q_dim' => $aLists[$sList][$this->_getTab($aParams, $sList, $aLists[$sList])]]);
+                break;
+
+            default:
+                return false;
+        }
+
+        $aFilter = $oFilter->get();
+        $aColumns = $this->_getQueryColumns($aQuery);
+
+        $aHeader = [$aColumns[0]['title'], _t('_bx_analytics_txt_raw_value')];
+        foreach(array_slice($aColumns, 1) as $aColumn)
+            $aHeader[] = $aColumn['title'];
+
+        // one query: pages of a re-run aggregate would cost a full scan each and could
+        // repeat or skip values that change places while hits come in
+        $iMax = max(1, (int)$iMax);
+        $aRows = [];
+        foreach($this->_getQueryRows($aFilter, $aQuery, $iMax, 0, $iMax) as $aRow) {
+            $aLine = [$this->getLabel($aQuery['dimension'], $aRow['value']), $aRow['value']];
+            foreach(array_slice($aColumns, 1) as $aColumn)
+                $aLine[] = isset($aRow[$aColumn['key']]) ? $aRow[$aColumn['key']] : 0;
+            $aRows[] = $aLine;
+        }
+
+        $iTotal = $this->_oDb->getTopCount($aFilter, $aQuery['dimension']);
+
+        return [
+            'dimension' => $aQuery['dimension'],
+            'header' => $aHeader,
+            'rows' => $aRows,
+            'total' => $iTotal,
+            'truncated' => $iTotal > count($aRows),
+        ];
+    }
+
+    /**
+     * Query rows: the stored value and the metrics as numbers (KPIs for a session dimension,
+     * pageview figures for a page or title).
+     *
+     * @param bool $bPathSessions pages get entries, bounce rate and exits
+     */
+    protected function _getQueryRows($aFilter, $aQuery, $iLimit, $iStart, $iMax = 1000, $bPathSessions = true)
+    {
+        $aRows = $this->_oDb->getBreakdown($aFilter, $aQuery['dimension'], $aQuery['metric'], $aQuery['dir'] == 'asc' ? 'ASC' : 'DESC', $iLimit, $iStart, $iMax, $bPathSessions);
+
+        $aResult = [];
+        foreach($aRows as $aRow)
+            $aResult[] = ['value' => (string)$aRow['value']] + ($aQuery['kind'] == 'session' ? $this->_getKpis($aRow) : $this->_getPageKpis($aRow));
+
+        return $aResult;
+    }
+
+    /**
+     * Metrics of a page or title row from BxAnalyticsDb::getPageStats.
+     */
+    protected function _getPageKpis($aRow)
+    {
+        $aResult = [
+            'visitors' => isset($aRow['visitors']) ? (int)$aRow['visitors'] : 0,
+            'pageviews' => isset($aRow['pageviews']) ? (int)$aRow['pageviews'] : 0,
+            'engaged' => isset($aRow['engaged']) ? round((float)$aRow['engaged'], 1) : 0,
+        ];
+
+        if(isset($aRow['entries']))
+            $aResult += [
+                'entries' => (int)$aRow['entries'],
+                'bounce_rate' => (float)$aRow['bounce_rate'],
+                'exits' => (int)$aRow['exits'],
+            ];
+
+        return $aResult;
+    }
+
+    /**
+     * Table columns of a query, the sorted one marked.
+     */
+    protected function _getQueryColumns($aQuery)
+    {
+        if($aQuery['kind'] == 'session')
+            $aTypes = ['visitors' => 'number', 'sessions' => 'number', 'pageviews' => 'number', 'bounce_rate' => 'percent', 'pages_per_session' => 'decimal', 'duration' => 'duration'];
+        else {
+            $aTypes = ['visitors' => 'number', 'pageviews' => 'number', 'engaged' => 'engaged'];
+            if($aQuery['dimension'] == 'path')
+                $aTypes += ['entries' => 'number', 'bounce_rate' => 'percent', 'exits' => 'number'];
+        }
+
+        $aColumns = [['key' => 'label', 'type' => 'label', 'title' => _t('_bx_analytics_dim_' . $aQuery['dimension'])]];
+        foreach($aTypes as $sKey => $sType) {
+            $aColumn = ['key' => $sKey, 'type' => $sType, 'title' => _t('_bx_analytics_metric_' . $sKey)];
+            if($sKey == $aQuery['metric'])
+                $aColumn['sort'] = $aQuery['dir'] == 'asc' ? 'ascending' : 'descending';
+            $aColumns[] = $aColumn;
+        }
+
+        return $aColumns;
+    }
+
+    /**
+     * The chosen metric of some values per bucket. A bucket without data is 0 for a count
+     * and a gap for an average or a rate; buckets after now are gaps.
+     */
+    protected function _getQuerySeries($aFilter, $aQuery, $aValues)
+    {
+        $sMetric = $aQuery['metric'];
+        $bRatio = in_array($sMetric, ['pages_per_session', 'bounce_rate', 'duration', 'engaged'], true);
+
+        $aData = [];
+        foreach($this->_oDb->getBreakdownSeries($aFilter, $aQuery['dimension'], array_column($aValues, 'value'), $aFilter['bucket'], $aFilter['offset'], $aFilter['starts']) as $aRow) {
+            $aKpis = $aQuery['kind'] == 'session' ? $this->_getKpis($aRow) : $this->_getPageKpis($aRow);
+            $aData[(string)$aRow['value']][(int)$aRow['bucket']] = $aKpis[$sMetric];
+        }
+
+        $aBuckets = $aFilter['buckets'];
+        $aSeries = [];
+        foreach($aValues as $aValue) {
+            $aPoints = [];
+            foreach($aBuckets as $i => $iBucket) {
+                $iNext = isset($aBuckets[$i + 1]) ? $aBuckets[$i + 1] : $aFilter['to'];
+                if($iBucket > $aFilter['now'])
+                    $mixedValue = null;
+                elseif(isset($aData[$aValue['value']][$iBucket]))
+                    $mixedValue = $aData[$aValue['value']][$iBucket];
+                else
+                    $mixedValue = $bRatio ? null : 0;
+
+                $aPoints[] = [
+                    't' => $iBucket,
+                    'v' => $mixedValue,
+                    'partial' => $iBucket <= $aFilter['now'] && $aFilter['now'] < $iNext,
+                ];
+            }
+
+            $aSeries[] = [
+                'value' => $aValue['value'],
+                'label' => $this->getLabel($aQuery['dimension'], $aValue['value']),
+                'points' => $aPoints,
+            ];
+        }
+
+        return [
+            'kind' => 'series',
+            'metric' => $sMetric,
+            'title' => _t('_bx_analytics_metric_' . $sMetric),
+            'bucket' => $aFilter['bucket'],
+            'tz' => $aFilter['tz'],
+            'series' => $aSeries,
+        ];
+    }
+
+    /**
+     * Visitors online now, per platform, pageviews per minute over the last 30 minutes, and
+     * the latest pageviews.
+     */
+    public function getRealtime(BxAnalyticsFilter $oFilter, $aParams = [])
+    {
+        $iNow = time();
+        $sPlatform = $oFilter->get()['platform'];
+
+        $aOnline = $this->_oDb->getOnline($iNow - $this->_oConfig->getOnlineWindow());
+        $aPlatforms = [];
+        $iTotal = 0;
+        foreach($this->_oConfig->getPlatforms() as $sCode => $sTitle) {
+            $iCount = isset($aOnline[$sCode]) ? (int)$aOnline[$sCode] : 0;
+            $iTotal += $iCount;
+            if($iCount)
+                $aPlatforms[] = ['value' => $sCode, 'label' => _t($sTitle), 'visitors' => $iCount];
+        }
+        foreach($aPlatforms as $i => $a)
+            $aPlatforms[$i]['share'] = $iTotal ? round(100 * $a['visitors'] / $iTotal, 1) : 0;
+
+        $iMinutes = 30;
+        $iStart = (int)(floor($iNow / 60) * 60) - ($iMinutes - 1) * 60;
+        $aPerMinute = $this->_oDb->getPageviewsPerMinute($iStart, $sPlatform);
+        $aMinutes = [];
+        for($i = 0; $i < $iMinutes; $i++) {
+            $iMinute = $iStart + $i * 60;
+            $aMinutes[] = ['t' => $iMinute, 'pageviews' => isset($aPerMinute[$iMinute]) ? (int)$aPerMinute[$iMinute] : 0];
+        }
+
+        $iLimit = isset($aParams['limit']) ? max(0, min(50, (int)$aParams['limit'])) : 20;
+        $aRecent = [];
+        $aRows = $iLimit > 0 ? $this->_oDb->getRecentPageviews($iNow - $iMinutes * 60, $iLimit, $sPlatform) : [];
+        foreach($aRows as $aRow)
+            $aRecent[] = [
+                'ts' => (int)$aRow['ts'],
+                'platform' => $aRow['platform'],
+                'platform_label' => $this->getLabel('platform', $aRow['platform']),
+                'path' => $aRow['path'],
+                'title' => $aRow['title'],
+                'country' => $aRow['country'],
+                'country_label' => $this->getLabel('country', $aRow['country']),
+                'city' => $aRow['city'],
+                'device' => $this->getLabel('device', $aRow['device']),
+                'browser' => $aRow['browser'],
+                'os' => $aRow['os'],
+                'member' => (int)$aRow['profile_id'] > 0,
+            ];
+
+        return [
+            'online' => $sPlatform !== '' ? (isset($aOnline[$sPlatform]) ? (int)$aOnline[$sPlatform] : 0) : $iTotal,
+            'platforms' => $aPlatforms,
+            'minutes' => $aMinutes,
+            'recent' => $aRecent,
+            'window' => (int)($this->_oConfig->getOnlineWindow() / 60),
+        ];
+    }
+
+    /**
+     * Visitors on a map: per country at the country's centre, and per place where the edge
+     * gave coordinates. Tabs: the period, or live (the last 30 minutes).
+     */
+    public function getMap(BxAnalyticsFilter $oFilter, $aParams = [])
+    {
+        bx_import('Geo', $this->_oModule->_aModule);
+
+        $aTabs = $this->_aReportTabs['map'];
+        $sTab = $this->_getTab($aParams, 'map', $aTabs);
+
+        $aFilter = $oFilter->get();
+        if($sTab == 'live') {
+            $aFilter['from'] = time() - 30 * 60;
+            $aFilter['to'] = time() + 60;
+        }
+
+        $aCountries = [];
+        $iMax = 0;
+        $iUnknown = 0;
+        foreach($this->_oDb->getTop($aFilter, 'country', 250) as $aRow) {
+            $aCenter = BxAnalyticsGeo::getCenter($aRow['value']);
+            if(!$aCenter) {
+                $iUnknown += (int)$aRow['visitors'];
+                continue;
+            }
+
+            $iMax = max($iMax, (int)$aRow['visitors']);
+            $aCountries[] = [
+                'country' => $aRow['value'],
+                'label' => $this->getLabel('country', $aRow['value']),
+                'visitors' => (int)$aRow['visitors'],
+                'lat' => $aCenter[0],
+                'lng' => $aCenter[1],
+            ];
+        }
+
+        $aPoints = [];
+        foreach($this->_oDb->getMapPoints($aFilter) as $aRow)
+            $aPoints[] = [
+                'label' => trim($aRow['city'] . ($aRow['country'] !== '' ? ', ' . $this->getLabel('country', $aRow['country']) : ''), ', '),
+                'visitors' => (int)$aRow['visitors'],
+                'lat' => (float)$aRow['lat'],
+                'lng' => (float)$aRow['lng'],
+            ];
+
+        $iTotal = array_sum(array_column($aCountries, 'visitors'));
+        foreach($aCountries as $i => $a) {
+            $aCountries[$i]['share'] = $iTotal ? round(100 * $a['visitors'] / $iTotal, 1) : 0;
+            $aCountries[$i]['bar'] = $iMax ? round(100 * $a['visitors'] / $iMax, 1) : 0;
+        }
+
+        return [
+            'tab' => $sTab,
+            'tabs' => array_map(function($sName) use ($sTab) {
+                return ['name' => $sName, 'title' => _t('_bx_analytics_tab_map_' . $sName), 'active' => $sName == $sTab];
+            }, array_keys($aTabs)),
+            'countries' => $aCountries,
+            'points' => $aPoints,
+            'unknown' => $iUnknown,
+            'provider' => getParam('sys_location_leaflet_provider'),
+        ];
+    }
+
+    /**
+     * Bots and agents: totals, hits per day by category, the bots and the pages they ask for.
+     * Tabs narrow it to a category; AI covers crawlers and assistants fetching for a person.
+     */
+    public function getBots(BxAnalyticsFilter $oFilter, $aParams = [])
+    {
+        bx_import('UserAgent', $this->_oModule->_aModule);
+
+        $aCategories = $this->_getBotCategories();
+        $sTab = $this->_getTab($aParams, 'bots', $aCategories);
+        $aIn = $aCategories[$sTab];
+        $aFilter = $oFilter->get();
+
+        $aTotals = $this->_oDb->getBotTotals($aFilter, $aIn);
+        $aAi = $this->_oDb->getBotTotals($aFilter, $aCategories['ai']);
+        $aHumans = $this->_oDb->getTotals(array_merge($aFilter, ['dims' => []]));
+        $iHits = (int)$aTotals['hits'];
+        $iAll = $sTab == 'all' ? $iHits : (int)$this->_oDb->getBotTotals($aFilter)['hits'];
+
+        $aTiles = [
+            ['title' => _t('_bx_analytics_metric_bot_hits'), 'value' => $iHits, 'type' => 'number'],
+            ['title' => _t('_bx_analytics_metric_bots'), 'value' => (int)$aTotals['bots'], 'type' => 'number'],
+            ['title' => _t('_bx_analytics_metric_ai_hits'), 'value' => (int)$aAi['hits'], 'type' => 'number'],
+            ['title' => _t('_bx_analytics_metric_bot_share'), 'value' => $iAll + (int)$aHumans['pageviews'] ? round(100 * $iAll / ($iAll + (int)$aHumans['pageviews']), 1) : 0, 'type' => 'percent'],
+        ];
+
+        // hits per day and category, every day of the period
+        $aDays = [];
+        for($i = (int)$aFilter['from']; $i < (int)$aFilter['to'] && count($aDays) < 800; $i += 86400)
+            $aDays[gmdate('Y-m-d', $i)] = true;
+        $aDays = array_keys($aDays);
+
+        $aSeries = [];
+        foreach($this->_oDb->getBotSeries($aFilter) as $aRow)
+            if(empty($aIn) || in_array($aRow['category'], $aIn, true))
+                $aSeries[$aRow['category']][$aRow['day']] = (int)$aRow['hits'];
+
+        $aDatasets = [];
+        foreach($aSeries as $sCategory => $aValues)
+            $aDatasets[] = [
+                'key' => $sCategory,
+                'label' => _t('_bx_analytics_bot_' . $sCategory),
+                'values' => array_map(function($sDay) use ($aValues) { return isset($aValues[$sDay]) ? $aValues[$sDay] : 0; }, $aDays),
+            ];
+
+        $iTotal = $this->_oDb->getBotListCount($aFilter, $aIn);
+        $iPages = max(1, (int)ceil($iTotal / $this->_iPerPage));
+        $iPage = min($iPages, max(1, (int)$this->_param($aParams, 'page_bot_list', 1)));
+
+        $aBots = $this->_oDb->getBotList($aFilter, $aIn, $this->_iPerPage, ($iPage - 1) * $this->_iPerPage);
+        $iMax = 1;
+        foreach($aBots as $aBot)
+            $iMax = max($iMax, (int)$aBot['hits']);
+
+        $aRows = [];
+        foreach($aBots as $aBot)
+            $aRows[] = [
+                'label' => $aBot['bot'],
+                'sub' => _t('_bx_analytics_bot_' . $aBot['category']),
+                'bar' => round(100 * $aBot['hits'] / $iMax, 1),
+                'values' => [
+                    'hits' => (int)$aBot['hits'],
+                    'last_seen' => (int)$aBot['last_seen'],
+                    'platforms' => implode(', ', array_map(function($s) { return $this->getLabel('platform', $s); }, explode(',', (string)$aBot['platforms']))),
+                ],
+            ];
+
+        $aPagesRows = [];
+        $aPages = $this->_oDb->getBotPages($aFilter, $aIn, 10);
+        $iMaxPage = 1;
+        foreach($aPages as $aPage)
+            $iMaxPage = max($iMaxPage, (int)$aPage['hits']);
+        foreach($aPages as $aPage)
+            $aPagesRows[] = [
+                'label' => $aPage['path'],
+                'bar' => round(100 * $aPage['hits'] / $iMaxPage, 1),
+                'values' => ['hits' => (int)$aPage['hits']],
+            ];
+
+        return [
+            'tab' => $sTab,
+            'tabs' => $this->_getTabs(array_flip(array_keys($aCategories)), $sTab, '_bx_analytics_tab_bots_'),
+            'tiles' => $aTiles,
+            'chart' => [
+                'kind' => 'stacked',
+                'labels' => array_map(function($sDay) { return strtotime($sDay . ' 00:00:00 UTC'); }, $aDays),
+                'bucket' => 'day',
+                'tz' => 'UTC',
+                'datasets' => $aDatasets,
+            ],
+            'bots' => [
+                'name' => 'bot_list',
+                'caption' => _t('_bx_analytics_lmi_cpt_bots'),
+                'columns' => [
+                    ['key' => 'label', 'type' => 'label', 'title' => _t('_bx_analytics_txt_bot')],
+                    ['key' => 'hits', 'type' => 'number', 'title' => _t('_bx_analytics_metric_bot_hits')],
+                    ['key' => 'platforms', 'type' => 'text', 'title' => _t('_bx_analytics_dim_platform')],
+                    ['key' => 'last_seen', 'type' => 'datetime', 'title' => _t('_bx_analytics_txt_last_seen')],
+                ],
+                'rows' => $aRows,
+                'page' => $iPage,
+                'pages' => $iPages,
+                'empty' => _t('_bx_analytics_txt_no_bots'),
+            ],
+            'pages' => [
+                'name' => 'bot_pages',
+                'caption' => _t('_bx_analytics_txt_bot_pages'),
+                'columns' => [
+                    ['key' => 'label', 'type' => 'label', 'title' => _t('_bx_analytics_tab_pages')],
+                    ['key' => 'hits', 'type' => 'number', 'title' => _t('_bx_analytics_metric_bot_hits')],
+                ],
+                'rows' => $aPagesRows,
+                'empty' => _t('_bx_analytics_txt_no_bots'),
+            ],
+        ];
+    }
+
+    /**
+     * Custom events and site actions with their counts and visitors. With `event`
+     * (type|name|module) one event over time, by platform and by its properties.
+     * Tabs: all, custom (from trackers), site (UNA actions).
+     */
+    public function getEvents(BxAnalyticsFilter $oFilter, $aParams = [])
+    {
+        $aEvent = $this->_parseEvent($this->_param($aParams, 'event'));
+        if($aEvent)
+            return $this->_getEventDetail($oFilter, $aEvent);
+
+        $aTabs = $this->_aReportTabs['events'];
+        $sTab = $this->_getTab($aParams, 'events', $aTabs);
+        $aFilter = $oFilter->get();
+
+        $iTotal = $this->_oDb->getEventListCount($aFilter, $aTabs[$sTab]);
+        $iPages = max(1, (int)ceil($iTotal / $this->_iPerPage));
+        $iPage = min($iPages, max(1, (int)$this->_param($aParams, 'page_events', 1)));
+
+        $aEvents = $this->_oDb->getEventList($aFilter, $aTabs[$sTab], $this->_iPerPage, ($iPage - 1) * $this->_iPerPage);
+        $iMax = 1;
+        foreach($aEvents as $aRow)
+            $iMax = max($iMax, (int)$aRow['count']);
+
+        $aRows = [];
+        foreach($aEvents as $aRow)
+            $aRows[] = [
+                'label' => $this->getEventLabel($aRow['type'], $aRow['name'], $aRow['module']),
+                'sub' => _t('_bx_analytics_event_type_' . $aRow['type']),
+                'open' => ['param' => 'event', 'value' => $aRow['type'] . '|' . $aRow['name'] . '|' . $aRow['module']],
+                'bar' => round(100 * $aRow['count'] / $iMax, 1),
+                'values' => [
+                    'count' => (int)$aRow['count'],
+                    'visitors' => (int)$aRow['visitors'],
+                    'per_visitor' => $aRow['visitors'] ? round($aRow['count'] / $aRow['visitors'], 1) : 0,
+                    'last' => (int)$aRow['last'],
+                ],
+            ];
+
+        return [
+            'view' => 'list',
+            'name' => 'events',
+            'caption' => _t('_bx_analytics_lmi_cpt_events'),
+            'tab' => $sTab,
+            'tabs' => $this->_getTabs($aTabs, $sTab, '_bx_analytics_tab_events_'),
+            'columns' => [
+                ['key' => 'label', 'type' => 'label', 'title' => _t('_bx_analytics_txt_event')],
+                ['key' => 'count', 'type' => 'number', 'title' => _t('_bx_analytics_metric_count')],
+                ['key' => 'visitors', 'type' => 'number', 'title' => _t('_bx_analytics_metric_visitors')],
+                ['key' => 'per_visitor', 'type' => 'decimal', 'title' => _t('_bx_analytics_metric_per_visitor')],
+                ['key' => 'last', 'type' => 'datetime', 'title' => _t('_bx_analytics_txt_last_seen')],
+            ],
+            'rows' => $aRows,
+            'page' => $iPage,
+            'pages' => $iPages,
+            'empty' => _t('_bx_analytics_txt_no_events'),
+        ];
+    }
+
+    protected function _getEventDetail(BxAnalyticsFilter $oFilter, $aEvent)
+    {
+        $aFilter = $oFilter->get();
+
+        $aRows = $this->_oDb->getEventSeries($aFilter, $aEvent, $aFilter['bucket'], $aFilter['offset'], $aFilter['starts']);
+        $aCounts = [];
+        $aVisitors = [];
+        foreach($aFilter['buckets'] as $iBucket) {
+            $aCounts[] = isset($aRows[$iBucket]) ? (int)$aRows[$iBucket]['count'] : 0;
+            $aVisitors[] = isset($aRows[$iBucket]) ? (int)$aRows[$iBucket]['visitors'] : 0;
+        }
+
+        $aPlatforms = [];
+        $iTotal = 0;
+        $iTotalVisitors = 0;
+        foreach($this->_oDb->getEventPlatforms($aFilter, $aEvent) as $aRow) {
+            $iTotal += (int)$aRow['count'];
+            $iTotalVisitors += (int)$aRow['visitors'];
+            $aPlatforms[] = ['label' => $this->getLabel('platform', $aRow['platform']), 'count' => (int)$aRow['count']];
+        }
+        foreach($aPlatforms as $i => $a)
+            $aPlatforms[$i]['share'] = $iTotal ? round(100 * $a['count'] / $iTotal, 1) : 0;
+
+        // the most common values of each property among the latest occurrences; a sale's
+        // properties only for those who may see revenue (events recorded earlier carry amounts)
+        $aProps = [];
+        $bMoney = $aEvent['type'] == BX_ANALYTICS_HIT_UNA && in_array($aEvent['name'], ['purchase', 'renewal', 'trial_start'], true);
+        foreach($bMoney && $this->_oModule->checkAllowedRevenue() !== CHECK_ACTION_RESULT_ALLOWED ? [] : $this->_oDb->getEventProps($aFilter, $aEvent) as $sJson) {
+            $a = json_decode($sJson, true);
+            if(!is_array($a))
+                continue;
+            foreach($a as $sKey => $mixedValue)
+                if(is_scalar($mixedValue)) {
+                    $sValue = is_bool($mixedValue) ? ($mixedValue ? 'true' : 'false') : (string)$mixedValue;
+                    $aProps[$sKey][$sValue] = (isset($aProps[$sKey][$sValue]) ? $aProps[$sKey][$sValue] : 0) + 1;
+                }
+        }
+
+        $aPropTables = [];
+        foreach(array_slice($aProps, 0, 6, true) as $sKey => $aValues) {
+            arsort($aValues);
+            $iMax = max($aValues);
+            $aPropRows = [];
+            foreach(array_slice($aValues, 0, 10, true) as $sValue => $iCount)
+                $aPropRows[] = ['label' => (string)$sValue, 'bar' => round(100 * $iCount / $iMax, 1), 'values' => ['count' => $iCount]];
+
+            $aPropTables[] = [
+                'name' => 'prop_' . preg_replace('/[^a-z0-9_]/i', '_', $sKey),
+                'caption' => (string)$sKey,
+                'columns' => [
+                    ['key' => 'label', 'type' => 'label', 'title' => (string)$sKey],
+                    ['key' => 'count', 'type' => 'number', 'title' => _t('_bx_analytics_metric_count')],
+                ],
+                'rows' => $aPropRows,
+            ];
+        }
+
+        return [
+            'view' => 'detail',
+            'event' => $aEvent,
+            'title' => $this->getEventLabel($aEvent['type'], $aEvent['name'], $aEvent['module']),
+            'tiles' => [
+                ['title' => _t('_bx_analytics_metric_count'), 'value' => $iTotal, 'type' => 'number'],
+                ['title' => _t('_bx_analytics_metric_visitors'), 'value' => $iTotalVisitors, 'type' => 'number'],
+                ['title' => _t('_bx_analytics_metric_per_visitor'), 'value' => $iTotalVisitors ? round($iTotal / $iTotalVisitors, 1) : 0, 'type' => 'decimal'],
+            ],
+            'chart' => [
+                'kind' => 'lines',
+                'labels' => $aFilter['buckets'],
+                'bucket' => $aFilter['bucket'],
+                'tz' => $aFilter['tz'],
+                'datasets' => [
+                    ['key' => 'count', 'label' => _t('_bx_analytics_metric_count'), 'values' => $aCounts],
+                    ['key' => 'visitors', 'label' => _t('_bx_analytics_metric_visitors'), 'values' => $aVisitors],
+                ],
+            ],
+            'platforms' => $aPlatforms,
+            'props' => $aPropTables,
+        ];
+    }
+
+    /**
+     * Visits, latest first, with who, where from and on what. With `session` one visit's
+     * timeline. Shows members by name, so it needs the people permission.
+     */
+    public function getSessions(BxAnalyticsFilter $oFilter, $aParams = [], $iProfileId = 0)
+    {
+        $iSession = (int)$this->_param($aParams, 'session', 0);
+        if($iSession)
+            return $this->_getSessionDetail($iSession);
+
+        $aFilter = $oFilter->get();
+        $iTotal = $this->_oDb->getSessionListCount($aFilter, $iProfileId);
+        $iPages = max(1, (int)ceil($iTotal / $this->_iPerPage));
+        $iPage = min($iPages, max(1, (int)$this->_param($aParams, 'page_sessions', 1)));
+
+        $aRows = [];
+        foreach($this->_oDb->getSessionList($aFilter, $this->_iPerPage, ($iPage - 1) * $this->_iPerPage, $iProfileId) as $aSession) {
+            $aMember = $this->getMember((int)$aSession['profile_id']);
+            $aRows[] = [
+                'label' => $aMember ? $aMember['name'] : _t('_bx_analytics_txt_guest'),
+                'open' => ['param' => 'session', 'value' => (int)$aSession['id']],
+                'values' => [
+                    'started' => (int)$aSession['started'],
+                    'platform' => $this->getLabel('platform', $aSession['platform']),
+                    'location' => $this->_getLocation($aSession),
+                    'device' => $this->_getDevice($aSession),
+                    'source' => $aSession['referrer_host'] !== '' ? $aSession['referrer_host'] : $this->getLabel('channel', $aSession['channel'] !== '' ? $aSession['channel'] : 'direct'),
+                    'pageviews' => (int)$aSession['pageviews'],
+                    'duration' => (int)$aSession['duration'],
+                ],
+            ];
+        }
+
+        return [
+            'view' => 'list',
+            'name' => 'sessions',
+            'caption' => _t('_bx_analytics_lmi_cpt_sessions'),
+            'columns' => [
+                ['key' => 'label', 'type' => 'label', 'title' => _t('_bx_analytics_txt_visitor')],
+                ['key' => 'started', 'type' => 'datetime', 'title' => _t('_bx_analytics_txt_started')],
+                ['key' => 'platform', 'type' => 'text', 'title' => _t('_bx_analytics_dim_platform')],
+                ['key' => 'location', 'type' => 'text', 'title' => _t('_bx_analytics_txt_location')],
+                ['key' => 'device', 'type' => 'text', 'title' => _t('_bx_analytics_dim_device')],
+                ['key' => 'source', 'type' => 'text', 'title' => _t('_bx_analytics_txt_source')],
+                ['key' => 'pageviews', 'type' => 'number', 'title' => _t('_bx_analytics_metric_pageviews')],
+                ['key' => 'duration', 'type' => 'duration', 'title' => _t('_bx_analytics_txt_duration')],
+            ],
+            'rows' => $aRows,
+            'page' => $iPage,
+            'pages' => $iPages,
+            'empty' => _t('_bx_analytics_txt_no_data'),
+        ];
+    }
+
+    protected function _getSessionDetail($iSession)
+    {
+        $aSession = $this->_oDb->getSession($iSession);
+        if(empty($aSession))
+            return false;
+
+        $iStart = (int)$aSession['started'];
+        $aTimeline = [];
+        foreach($this->_oDb->getSessionEvents($iSession) as $aEvent)
+            $aTimeline[] = [
+                'offset' => max(0, (int)$aEvent['ts'] - $iStart),
+                'type' => $aEvent['type'],
+                'label' => $aEvent['type'] == BX_ANALYTICS_HIT_PAGEVIEW ? ($aEvent['title'] !== '' ? $aEvent['title'] : $aEvent['path']) : $this->getEventLabel($aEvent['type'], $aEvent['name'], $aEvent['module']),
+                'path' => $aEvent['type'] == BX_ANALYTICS_HIT_PAGEVIEW ? $aEvent['path'] : '',
+                'engaged' => (int)round($aEvent['engaged'] / 1000),
+            ];
+
+        $aMember = $this->getMember((int)$aSession['profile_id']);
+
+        return [
+            'view' => 'detail',
+            'id' => (int)$aSession['id'],
+            'member' => $aMember,
+            'facts' => [
+                ['title' => _t('_bx_analytics_txt_started'), 'value' => (int)$aSession['started'], 'type' => 'datetime'],
+                ['title' => _t('_bx_analytics_txt_duration'), 'value' => (int)$aSession['duration'], 'type' => 'duration'],
+                ['title' => _t('_bx_analytics_metric_pageviews'), 'value' => (int)$aSession['pageviews'], 'type' => 'number'],
+                ['title' => _t('_bx_analytics_dim_platform'), 'value' => $this->getLabel('platform', $aSession['platform']) . ($aSession['app_version'] !== '' ? ' ' . $aSession['app_version'] : ''), 'type' => 'text'],
+                ['title' => _t('_bx_analytics_txt_location'), 'value' => $this->_getLocation($aSession), 'type' => 'text'],
+                ['title' => _t('_bx_analytics_dim_device'), 'value' => $this->_getDevice($aSession), 'type' => 'text'],
+                ['title' => _t('_bx_analytics_txt_source'), 'value' => $aSession['referrer'] !== '' ? $aSession['referrer'] : $this->getLabel('channel', $aSession['channel'] !== '' ? $aSession['channel'] : 'direct'), 'type' => 'text'],
+                ['title' => _t('_bx_analytics_dim_language'), 'value' => $aSession['language'], 'type' => 'text'],
+            ],
+            'timeline' => $aTimeline,
+        ];
+    }
+
+    /**
+     * Members who visited in the period. With `user` one member's visits and actions; with
+     * `session` too, one of their visits. Needs the people permission.
+     */
+    public function getUsers(BxAnalyticsFilter $oFilter, $aParams = [])
+    {
+        $iUser = (int)$this->_param($aParams, 'user', 0);
+        if($iUser) {
+            if((int)$this->_param($aParams, 'session', 0))
+                return $this->_getSessionDetail((int)$aParams['session']);
+
+            return $this->_getUserDetail($oFilter, $iUser, $aParams);
+        }
+
+        $aFilter = $oFilter->get();
+        $iTotal = $this->_oDb->getMemberListCount($aFilter);
+        $iPages = max(1, (int)ceil($iTotal / $this->_iPerPage));
+        $iPage = min($iPages, max(1, (int)$this->_param($aParams, 'page_users', 1)));
+
+        $aMembers = $this->_oDb->getMemberList($aFilter, $this->_iPerPage, ($iPage - 1) * $this->_iPerPage);
+
+        // lifetime value: what each member paid, for viewers who may see revenue
+        $bRevenue = $this->_oModule->checkAllowedRevenue() === CHECK_ACTION_RESULT_ALLOWED;
+        $aLtv = $bRevenue ? $this->getLifetimeValues(array_column($aMembers, 'profile_id')) : [];
+
+        $aRows = [];
+        foreach($aMembers as $aRow) {
+            $aMember = $this->getMember((int)$aRow['profile_id']);
+            $aValues = [
+                'sessions' => (int)$aRow['sessions'],
+                'pageviews' => (int)$aRow['pageviews'],
+                'duration' => (int)$aRow['duration'],
+                'platforms' => $this->_getPlatformsText($aRow['platforms']),
+                'last' => (int)$aRow['last'],
+            ];
+            if($bRevenue)
+                $aValues['ltv'] = isset($aLtv[$aRow['profile_id']]) ? round((float)$aLtv[$aRow['profile_id']], 2) : 0;
+
+            $aRows[] = [
+                'label' => $aMember ? $aMember['name'] : _t('_bx_analytics_txt_deleted_member'),
+                'sub' => $aRow['country'] !== '' ? $this->getLabel('country', $aRow['country']) : '',
+                'open' => ['param' => 'user', 'value' => (int)$aRow['profile_id']],
+                'values' => $aValues,
+            ];
+        }
+
+        $aColumns = [
+            ['key' => 'label', 'type' => 'label', 'title' => _t('_bx_analytics_txt_member_name')],
+            ['key' => 'sessions', 'type' => 'number', 'title' => _t('_bx_analytics_metric_sessions')],
+            ['key' => 'pageviews', 'type' => 'number', 'title' => _t('_bx_analytics_metric_pageviews')],
+            ['key' => 'duration', 'type' => 'duration', 'title' => _t('_bx_analytics_txt_time_spent')],
+            ['key' => 'platforms', 'type' => 'text', 'title' => _t('_bx_analytics_tab_platforms')],
+        ];
+        if($bRevenue)
+            $aColumns[] = ['key' => 'ltv', 'type' => 'money', 'title' => _t('_bx_analytics_txt_lifetime_value')];
+        $aColumns[] = ['key' => 'last', 'type' => 'datetime', 'title' => _t('_bx_analytics_txt_last_seen')];
+
+        return [
+            'view' => 'list',
+            'name' => 'users',
+            'caption' => _t('_bx_analytics_lmi_cpt_users'),
+            'columns' => $aColumns,
+            'rows' => $aRows,
+            'page' => $iPage,
+            'pages' => $iPages,
+            'empty' => _t('_bx_analytics_txt_no_members'),
+        ];
+    }
+
+    protected function _getUserDetail(BxAnalyticsFilter $oFilter, $iProfileId, $aParams)
+    {
+        $aTotals = $this->_oDb->getMemberTotals($iProfileId);
+        if(empty($aTotals['totals']['sessions']))
+            return false;
+
+        $aPlatforms = [];
+        $iSessions = (int)$aTotals['totals']['sessions'];
+        foreach($aTotals['platforms'] as $sPlatform => $iCount)
+            $aPlatforms[] = [
+                'label' => $this->getLabel('platform', $sPlatform),
+                'count' => (int)$iCount,
+                'share' => $iSessions ? round(100 * $iCount / $iSessions, 1) : 0,
+            ];
+
+        $aActions = [];
+        $aRows = $this->_oDb->getMemberActions($iProfileId, $oFilter->get());
+        $iMax = 1;
+        foreach($aRows as $aRow)
+            $iMax = max($iMax, (int)$aRow['count']);
+        foreach($aRows as $aRow)
+            $aActions[] = [
+                'label' => $this->getEventLabel($aRow['type'], $aRow['name'], $aRow['module']),
+                'bar' => round(100 * $aRow['count'] / $iMax, 1),
+                'values' => ['count' => (int)$aRow['count'], 'last' => (int)$aRow['last']],
+            ];
+
+        $aSessions = $this->getSessions($oFilter, $aParams, $iProfileId);
+
+        $aTiles = [
+            ['title' => _t('_bx_analytics_metric_sessions'), 'value' => $iSessions, 'type' => 'number'],
+            ['title' => _t('_bx_analytics_metric_pageviews'), 'value' => (int)$aTotals['totals']['pageviews'], 'type' => 'number'],
+            ['title' => _t('_bx_analytics_txt_time_spent'), 'value' => (int)$aTotals['totals']['duration'], 'type' => 'duration'],
+            ['title' => _t('_bx_analytics_txt_first_seen'), 'value' => (int)$aTotals['totals']['first'], 'type' => 'date'],
+            ['title' => _t('_bx_analytics_txt_last_seen'), 'value' => (int)$aTotals['totals']['last'], 'type' => 'datetime'],
+        ];
+        if($this->_oModule->checkAllowedRevenue() === CHECK_ACTION_RESULT_ALLOWED) {
+            $aLtv = $this->getLifetimeValues([$iProfileId]);
+            $aTiles[] = ['title' => _t('_bx_analytics_txt_lifetime_value'), 'value' => isset($aLtv[$iProfileId]) ? round((float)$aLtv[$iProfileId], 2) : 0, 'type' => 'money'];
+        }
+
+        return [
+            'view' => 'user',
+            'member' => $this->getMember($iProfileId),
+            'tiles' => $aTiles,
+            'platforms' => $aPlatforms,
+            'actions' => [
+                'name' => 'user_actions',
+                'caption' => _t('_bx_analytics_txt_actions'),
+                'columns' => [
+                    ['key' => 'label', 'type' => 'label', 'title' => _t('_bx_analytics_txt_event')],
+                    ['key' => 'count', 'type' => 'number', 'title' => _t('_bx_analytics_metric_count')],
+                    ['key' => 'last', 'type' => 'datetime', 'title' => _t('_bx_analytics_txt_last_seen')],
+                ],
+                'rows' => $aActions,
+                'empty' => _t('_bx_analytics_txt_no_events'),
+            ],
+            'sessions' => $aSessions,
+        ];
+    }
+
+    /**
+     * Where visitors go from a page: the pages they open next and, for one of those, the
+     * pages after it; and the most common three-page paths from the entry page. Params:
+     * `from` (default: the top entry page), `via` (a next page to follow further).
+     */
+    public function getJourneys(BxAnalyticsFilter $oFilter, $aParams = [])
+    {
+        $iLimit = 200000;
+
+        // each visit's pages in order, a page reloaded in place counted once
+        $aRead = $this->_oDb->getPageSequences($oFilter->get(), $iLimit);
+        $aSequences = $aRead['sequences'];
+
+        $aEntries = [];
+        $aPaths = [];
+        foreach($aSequences as $aSequence) {
+            $aEntries[$aSequence[0]] = (isset($aEntries[$aSequence[0]]) ? $aEntries[$aSequence[0]] : 0) + 1;
+            $sPath = implode("\n", array_slice($aSequence, 0, 3));
+            $aPaths[$sPath] = (isset($aPaths[$sPath]) ? $aPaths[$sPath] : 0) + 1;
+        }
+        arsort($aEntries);
+        arsort($aPaths);
+
+        $sFrom = (string)$this->_param($aParams, 'jfrom', '');
+        if($sFrom === '' || !isset($aEntries[$sFrom]) && !$this->_inSequences($aSequences, $sFrom))
+            $sFrom = !empty($aEntries) ? (string)key($aEntries) : '';
+        $sVia = (string)$this->_param($aParams, 'via', '');
+
+        // the page after the first visit of `jfrom`, and after `via` when it came right after
+        $iFromSessions = 0;
+        $aNext = [];
+        $iNextExits = 0;
+        $iViaSessions = 0;
+        $aAfter = [];
+        $iAfterExits = 0;
+        foreach($aSequences as $aSequence) {
+            $i = array_search($sFrom, $aSequence, true);
+            if($i === false)
+                continue;
+
+            $iFromSessions++;
+            if(!isset($aSequence[$i + 1])) {
+                $iNextExits++;
+                continue;
+            }
+
+            $sNext = $aSequence[$i + 1];
+            $aNext[$sNext] = (isset($aNext[$sNext]) ? $aNext[$sNext] : 0) + 1;
+
+            if($sVia !== '' && $sNext === $sVia) {
+                $iViaSessions++;
+                if(isset($aSequence[$i + 2]))
+                    $aAfter[$aSequence[$i + 2]] = (isset($aAfter[$aSequence[$i + 2]]) ? $aAfter[$aSequence[$i + 2]] : 0) + 1;
+                else
+                    $iAfterExits++;
+            }
+        }
+        arsort($aNext);
+        arsort($aAfter);
+
+        $fStep = function($aCounts, $iExits, $iTotal, $iLimit) {
+            $aResult = [];
+            foreach(array_slice($aCounts, 0, $iLimit, true) as $sPage => $iCount)
+                $aResult[] = ['page' => (string)$sPage, 'sessions' => $iCount, 'share' => $iTotal ? round(100 * $iCount / $iTotal, 1) : 0];
+            if($iExits)
+                $aResult[] = ['page' => '', 'exit' => true, 'sessions' => $iExits, 'share' => $iTotal ? round(100 * $iExits / $iTotal, 1) : 0];
+            return $aResult;
+        };
+
+        $iTotal = count($aSequences);
+        $aTopPaths = [];
+        foreach(array_slice($aPaths, 0, 10, true) as $sPath => $iCount)
+            $aTopPaths[] = ['pages' => explode("\n", (string)$sPath), 'sessions' => $iCount, 'share' => $iTotal ? round(100 * $iCount / $iTotal, 1) : 0];
+
+        $aEntryList = [];
+        foreach(array_slice($aEntries, 0, 10, true) as $sPage => $iCount)
+            $aEntryList[] = ['page' => (string)$sPage, 'sessions' => $iCount, 'active' => (string)$sPage === $sFrom];
+
+        return [
+            'jfrom' => $sFrom,
+            'via' => $sVia !== '' && $iViaSessions ? $sVia : '',
+            'sessions' => $iTotal,
+            'truncated' => $aRead['rows'] >= $iLimit,
+            'entries' => $aEntryList,
+            'from_sessions' => $iFromSessions,
+            'next' => $fStep($aNext, $iNextExits, $iFromSessions, 8),
+            'after' => $iViaSessions ? $fStep($aAfter, $iAfterExits, $iViaSessions, 8) : [],
+            'via_sessions' => $iViaSessions,
+            'paths' => $aTopPaths,
+        ];
+    }
+
+    /**
+     * Weekly cohorts by first visit and the share of each that came back in the weeks after.
+     * Tabs: members (stable ids) or all visitor ids (with cookieless ids a guest is new every
+     * day, so only persistent ids make guest retention meaningful).
+     */
+    public function getRetention(BxAnalyticsFilter $oFilter, $aParams = [])
+    {
+        $aTabs = $this->_aReportTabs['retention'];
+        $sTab = $this->_getTab($aParams, 'retention', $aTabs);
+        $aFilter = $oFilter->get();
+
+        $iWeeks = 12;
+        $iOffset = (int)$aFilter['offset'];
+        $fWeek = function($iTime) use ($iOffset) {
+            return (int)(floor(($iTime + $iOffset - 345600) / 604800) * 604800 + 345600 - $iOffset);
+        };
+
+        $iNow = (int)$aFilter['now'];
+        $iThisWeek = $fWeek($iNow);
+        $iFrom = $iThisWeek - ($iWeeks - 1) * 604800;
+
+        $aCohorts = [];
+        for($i = 0; $i < $iWeeks; $i++)
+            $aCohorts[$iFrom + $i * 604800] = ['size' => 0, 'back' => array_fill(0, $iWeeks - $i, 0)];
+
+        foreach($this->_oDb->getRetentionCohorts($iFrom, $iNow + 1, $aFilter['platform'], $aTabs[$sTab]) as $aRow) {
+            $iCohort = $iFrom + (int)$aRow['cohort'] * 604800;
+            if(!isset($aCohorts[$iCohort]))
+                continue;
+
+            $iVisitors = (int)$aRow['visitors'];
+            $iMask = (int)$aRow['weeks'];
+            $aCohorts[$iCohort]['size'] += $iVisitors;
+            foreach(array_keys($aCohorts[$iCohort]['back']) as $iIndex)
+                if($iMask & (1 << ((int)$aRow['cohort'] + $iIndex)))
+                    $aCohorts[$iCohort]['back'][$iIndex] += $iVisitors;
+        }
+
+        $aRows = [];
+        foreach($aCohorts as $iStart => $aCohort)
+            $aRows[] = [
+                'start' => $iStart,
+                'size' => $aCohort['size'],
+                'weeks' => array_map(function($iCount) use ($aCohort) {
+                    return $aCohort['size'] ? round(100 * $iCount / $aCohort['size'], 1) : null;
+                }, $aCohort['back']),
+            ];
+
+        return [
+            'tab' => $sTab,
+            'tabs' => $this->_getTabs($aTabs, $sTab, '_bx_analytics_tab_retention_'),
+            'weeks' => $iWeeks,
+            'tz' => $aFilter['tz'],
+            'rows' => $aRows,
+            'cookieless' => $sTab == 'visitors' && $this->_oConfig->getIdentityMode() == BX_ANALYTICS_IDENTITY_COOKIELESS,
+        ];
+    }
+
+    /**
+     * Goals with their conversions: visits and visitors that reached them, the conversion
+     * rate of all visits, and the change against the previous period.
+     */
+    public function getGoals(BxAnalyticsFilter $oFilter, $aParams = [])
+    {
+        $aFilter = $oFilter->get();
+        $aPrevious = $oFilter->getPrevious();
+
+        $aTotals = $this->_oDb->getTotals($aFilter);
+        $aTotalsPrevious = $this->_oDb->getTotals($aPrevious);
+        $iSessions = (int)$aTotals['sessions'];
+        $iSessionsPrevious = (int)$aTotalsPrevious['sessions'];
+
+        $aGoals = [];
+        foreach($this->_oDb->getGoals($this->_oConfig->CNF['MAX_GOALS']) as $aGoal) {
+            $aCurrent = $this->_oDb->getGoalTotals($aFilter, $aGoal);
+            $aBefore = $this->_oDb->getGoalTotals($aPrevious, $aGoal);
+            $fRate = $iSessions ? round(100 * $aCurrent['sessions'] / $iSessions, 2) : 0;
+            $fRateBefore = $iSessionsPrevious ? round(100 * $aBefore['sessions'] / $iSessionsPrevious, 2) : 0;
+
+            $aGoals[] = [
+                'id' => (int)$aGoal['id'],
+                'name' => $aGoal['name'],
+                'type' => $aGoal['type'],
+                'pattern' => $aGoal['pattern'],
+                'conversions' => (int)$aCurrent['sessions'],
+                'visitors' => (int)$aCurrent['visitors'],
+                'rate' => $fRate,
+                'change' => $this->_change($fRate, $fRateBefore),
+            ];
+        }
+
+        return [
+            'goals' => $aGoals,
+            'sessions' => $iSessions,
+            'manage' => $this->_oModule->checkAllowedManage() === CHECK_ACTION_RESULT_ALLOWED,
+            'edit' => (string)$this->_param($aParams, 'edit_goal', ''),
+        ];
+    }
+
+    /**
+     * A funnel's steps with the visits that reached each one in order within the visit, and
+     * the drop between steps. Param `funnel` (default: the first).
+     */
+    public function getFunnels(BxAnalyticsFilter $oFilter, $aParams = [])
+    {
+        $aFunnels = $this->_oDb->getFunnels();
+        $iFunnel = (int)$this->_param($aParams, 'funnel', 0);
+
+        $aFunnel = !empty($aFunnels) ? $aFunnels[0] : null;
+        foreach($aFunnels as $a)
+            if((int)$a['id'] === $iFunnel)
+                $aFunnel = $a;
+
+        $aSteps = [];
+        if($aFunnel) {
+            $aDefinition = json_decode($aFunnel['steps'], true);
+            $aDefinition = is_array($aDefinition) ? $aDefinition : [];
+
+            // each visit walks the steps in order: a step counts when it happens after the previous one
+            $aDefinition = array_values(array_filter($aDefinition, function($aStep) {
+                return is_array($aStep) && isset($aStep['type'], $aStep['pattern']);
+            }));
+            foreach($this->_oDb->getFunnelCounts($oFilter->get(), $aDefinition) as $i => $iSessions)
+                $aSteps[] = [
+                    'type' => $aDefinition[$i]['type'],
+                    'pattern' => $aDefinition[$i]['pattern'],
+                    'sessions' => $iSessions,
+                ];
+
+            $iFirst = !empty($aSteps) ? $aSteps[0]['sessions'] : 0;
+            foreach($aSteps as $i => $aStep) {
+                $aSteps[$i]['rate'] = $iFirst ? round(100 * $aStep['sessions'] / $iFirst, 1) : 0;
+                $aSteps[$i]['drop'] = $i > 0 && $aSteps[$i - 1]['sessions'] ? round(100 * ($aSteps[$i - 1]['sessions'] - $aStep['sessions']) / $aSteps[$i - 1]['sessions'], 1) : null;
+            }
+        }
+
+        $aList = [];
+        foreach($aFunnels as $a)
+            $aList[] = ['id' => (int)$a['id'], 'name' => $a['name'], 'active' => $aFunnel && (int)$a['id'] === (int)$aFunnel['id']];
+
+        return [
+            'funnels' => $aList,
+            'funnel' => $aFunnel ? ['id' => (int)$aFunnel['id'], 'name' => $aFunnel['name']] : null,
+            'steps' => $aSteps,
+            'manage' => $this->_oModule->checkAllowedManage() === CHECK_ACTION_RESULT_ALLOWED,
+            'edit' => (string)$this->_param($aParams, 'edit_funnel', ''),
+            'definition' => $aFunnel ? json_decode($aFunnel['steps'], true) : [],
+            'max_steps' => $this->_oConfig->CNF['MAX_FUNNEL_STEPS'],
+        ];
+    }
+
+    protected function _inSequences($aSequences, $sPage)
+    {
+        foreach($aSequences as $aSequence)
+            if(in_array($sPage, $aSequence, true))
+                return true;
+
+        return false;
+    }
+
+    /*
+     * Health: client errors and performance
+     */
+
+    /**
+     * Client errors grouped by fingerprint, with their hits, visitors and platforms in the
+     * period and a trend per group. With `error` (a group id) one group in detail. Tabs: open,
+     * new (first seen in the period), resolved, ignored, all.
+     */
+    public function getErrors(BxAnalyticsFilter $oFilter, $aParams = [])
+    {
+        $iError = (int)$this->_param($aParams, 'error', 0);
+        if($iError)
+            return $this->_getErrorDetail($oFilter, $iError);
+
+        // ignored errors keep counting but stay out of everything but their own tab
+        $aCounted = ['open', 'resolved'];
+        $aTabs = $this->_aReportTabs['errors'];
+        $sTab = $this->_getTab($aParams, 'errors', $aTabs);
+        $aFilter = $oFilter->get();
+        $iNewFrom = $sTab == 'new' ? (int)$aFilter['from'] : 0;
+
+        // crashes are fatal errors in the apps, against the apps' visits
+        $aApps = array_values(array_filter(array_keys($this->_oConfig->getPlatforms()), [$this->_oConfig, 'isAppPlatform']));
+        $aTotals = $this->_oDb->getErrorTotals($aFilter, $aCounted, $aApps);
+        $aBefore = $this->_oDb->getErrorTotals($oFilter->getPrevious(), $aCounted);
+        $aSessions = $this->_oDb->getSessionsPerPlatform($aFilter);
+        $iSessions = (int)array_sum($aSessions);
+        $iAppSessions = 0;
+        foreach($aApps as $sApp)
+            $iAppSessions += isset($aSessions[$sApp]) ? (int)$aSessions[$sApp] : 0;
+
+        $aTiles = [
+            ['title' => _t('_bx_analytics_metric_errors'), 'value' => (int)$aTotals['hits'], 'type' => 'number', 'metric' => 'errors', 'change' => $this->_change((int)$aTotals['hits'], (int)$aBefore['hits'])],
+            ['title' => _t('_bx_analytics_metric_error_sessions'), 'value' => $iSessions ? round(100 * $aTotals['sessions'] / $iSessions, 1) : null, 'type' => 'percent', 'sub' => _t('_bx_analytics_txt_n_sessions', (int)$aTotals['sessions'])],
+        ];
+        if($aFilter['platform'] === '' || $this->_oConfig->isAppPlatform($aFilter['platform']))
+            $aTiles[] = ['title' => _t('_bx_analytics_metric_crash_free'), 'value' => $iAppSessions ? round(100 - 100 * min($iAppSessions, (int)$aTotals['fatal_sessions']) / $iAppSessions, 2) : null, 'type' => 'percent_precise', 'sub' => _t('_bx_analytics_txt_crash_free_apps')];
+        $aTiles[] = ['title' => _t('_bx_analytics_metric_new_errors'), 'value' => $this->_oDb->getErrorListCount($aFilter, $aCounted, (int)$aFilter['from']), 'type' => 'number'];
+
+        $iTotal = $this->_oDb->getErrorListCount($aFilter, $aTabs[$sTab], $iNewFrom);
+        $iPages = max(1, (int)ceil($iTotal / $this->_iPerPage));
+        $iPage = min($iPages, max(1, (int)$this->_param($aParams, 'page_errors', 1)));
+
+        $aList = $this->_oDb->getErrorList($aFilter, $aTabs[$sTab], $iNewFrom, $this->_iPerPage, ($iPage - 1) * $this->_iPerPage);
+        $aIds = array_map('intval', array_column($aList, 'error_id'));
+        $aGroups = $this->_oDb->getErrorGroups($aIds);
+        $aTrends = $this->_oDb->getErrorTrends($aFilter, $aIds, $aFilter['bucket'], $aFilter['offset'], $aFilter['starts']);
+
+        $iMax = 1;
+        foreach($aList as $aRow)
+            $iMax = max($iMax, (int)$aRow['count']);
+
+        $aRows = [];
+        foreach($aList as $aRow) {
+            $iId = (int)$aRow['error_id'];
+            if(!isset($aGroups[$iId]))
+                continue;
+
+            $aGroup = $aGroups[$iId];
+            $aTrend = [];
+            foreach($aFilter['buckets'] as $iBucket)
+                $aTrend[] = isset($aTrends[$iId][$iBucket]) ? $aTrends[$iId][$iBucket] : 0;
+
+            $aRows[] = [
+                'label' => $this->getErrorLabel($aGroup),
+                'sub' => implode(' · ', array_filter([
+                    _t('_bx_analytics_error_kind_' . $aGroup['kind']),
+                    $this->_getErrorLocation($aGroup),
+                    (int)$aRow['fatal'] ? _t('_bx_analytics_txt_fatal') : '',
+                    $sTab == 'all' ? _t('_bx_analytics_error_status_' . $aGroup['status']) : '',
+                ])),
+                'open' => ['param' => 'error', 'value' => $iId],
+                'bar' => round(100 * $aRow['count'] / $iMax, 1),
+                'values' => [
+                    'trend' => $aTrend,
+                    'count' => (int)$aRow['count'],
+                    'users' => (int)$aRow['users'],
+                    'platforms' => $this->_getPlatformsText($aRow['platforms']),
+                    'first_seen' => (int)$aGroup['first_seen'],
+                    'last_seen' => (int)$aRow['last_in'],
+                ],
+            ];
+        }
+
+        return [
+            'view' => 'list',
+            'tiles' => $aTiles,
+            'name' => 'errors',
+            'caption' => _t('_bx_analytics_lmi_cpt_errors'),
+            'tab' => $sTab,
+            'tabs' => $this->_getTabs($aTabs, $sTab, '_bx_analytics_tab_errors_'),
+            'columns' => [
+                ['key' => 'label', 'type' => 'label', 'title' => _t('_bx_analytics_txt_error')],
+                ['key' => 'trend', 'type' => 'spark', 'title' => _t('_bx_analytics_txt_trend')],
+                ['key' => 'count', 'type' => 'number', 'title' => _t('_bx_analytics_metric_occurrences')],
+                ['key' => 'users', 'type' => 'number', 'title' => _t('_bx_analytics_metric_visitors')],
+                ['key' => 'platforms', 'type' => 'text', 'title' => _t('_bx_analytics_dim_platform')],
+                ['key' => 'last_seen', 'type' => 'datetime', 'title' => _t('_bx_analytics_txt_last_seen')],
+            ],
+            'rows' => $aRows,
+            'page' => $iPage,
+            'pages' => $iPages,
+            'empty' => _t($sTab == 'open' || $sTab == 'new' ? '_bx_analytics_txt_no_errors' : '_bx_analytics_txt_no_data'),
+        ];
+    }
+
+    /**
+     * One error group: what and where, its hits over time and by client, and the latest
+     * occurrences. The stack and the visits behind the occurrences can carry what a visitor
+     * typed or opened, so they need the people permission.
+     */
+    protected function _getErrorDetail(BxAnalyticsFilter $oFilter, $iId)
+    {
+        $aGroup = $this->_oDb->getErrorGroup($iId);
+        if(empty($aGroup))
+            return ['view' => 'detail', 'missing' => true, 'title' => _t('_bx_analytics_txt_error_missing')];
+
+        $aFilter = $oFilter->get();
+        $bPeople = $this->_oModule->checkAllowedPeople() === CHECK_ACTION_RESULT_ALLOWED;
+        $bManage = $this->_oModule->checkAllowedManage() === CHECK_ACTION_RESULT_ALLOWED;
+
+        $aStats = $this->_oDb->getErrorStats($aFilter, $iId);
+        $aRows = $this->_oDb->getErrorSeries($aFilter, $iId, $aFilter['bucket'], $aFilter['offset'], $aFilter['starts']);
+        $aCounts = [];
+        $aUsers = [];
+        foreach($aFilter['buckets'] as $iBucket) {
+            $aCounts[] = isset($aRows[$iBucket]) ? (int)$aRows[$iBucket]['count'] : 0;
+            $aUsers[] = isset($aRows[$iBucket]) ? (int)$aRows[$iBucket]['users'] : 0;
+        }
+
+        // where it happens: a table per client column that has values
+        $aBreakdowns = [];
+        foreach(['platform', 'route', 'browser', 'os', 'device', 'app_version', 'country'] as $sColumn) {
+            $aValues = $this->_oDb->getErrorBreakdown($aFilter, $iId, $sColumn);
+            if(empty($aValues) || (count($aValues) == 1 && $aValues[0]['value'] === ''))
+                continue;
+
+            $iTotal = 0;
+            foreach($aValues as $aValue)
+                $iTotal += (int)$aValue['count'];
+
+            $aBreakdownRows = [];
+            foreach($aValues as $aValue)
+                $aBreakdownRows[] = [
+                    'label' => $this->getLabel($sColumn, (string)$aValue['value']),
+                    'bar' => $iTotal ? round(100 * $aValue['count'] / $iTotal, 1) : 0,
+                    'values' => [
+                        'count' => (int)$aValue['count'],
+                        'share' => $iTotal ? round(100 * $aValue['count'] / $iTotal, 1) : 0,
+                    ],
+                ];
+
+            $sTitle = _t($sColumn == 'route' ? '_bx_analytics_txt_route' : '_bx_analytics_dim_' . $sColumn);
+            $aBreakdowns[] = [
+                'name' => 'error_' . $sColumn,
+                'caption' => $sTitle,
+                'columns' => [
+                    ['key' => 'label', 'type' => 'label', 'title' => $sTitle],
+                    ['key' => 'count', 'type' => 'number', 'title' => _t('_bx_analytics_metric_occurrences')],
+                    ['key' => 'share', 'type' => 'percent', 'title' => _t('_bx_analytics_txt_share')],
+                ],
+                'rows' => $aBreakdownRows,
+            ];
+        }
+
+        $aOccurrences = [];
+        foreach($this->_oDb->getErrorOccurrences($aFilter, $iId) as $aHit) {
+            $aRow = [
+                'label' => $aHit['path'] !== '' ? $aHit['path'] : '/',
+                'sub' => implode(' · ', array_filter([
+                    $this->getLabel('platform', $aHit['platform']),
+                    $aHit['app_version'],
+                    $aHit['device'] !== '' ? $this->getLabel('device', $aHit['device']) : '',
+                    $aHit['browser'],
+                    $aHit['os'],
+                    $aHit['country'] !== '' ? $this->getLabel('country', $aHit['country']) : '',
+                ])),
+                'values' => [
+                    'ts' => (int)$aHit['ts'],
+                    'repeats' => (int)$aHit['repeats'],
+                ],
+            ];
+            // the visit behind it, for those who may see visits (Studio is for administrators)
+            if($bPeople && (int)$aHit['session_id'] && isAdmin())
+                $aRow['link'] = BX_DOL_URL_STUDIO . 'module.php?name=' . $this->_oModule->getName() . '&page=sessions&session=' . (int)$aHit['session_id'];
+            $aOccurrences[] = $aRow;
+        }
+
+        $aActions = [];
+        if($bManage)
+            foreach(['resolved', 'ignored', 'open'] as $sStatus)
+                if($sStatus != $aGroup['status'])
+                    $aActions[] = ['op' => $sStatus, 'title' => _t('_bx_analytics_txt_error_mark_' . $sStatus)];
+
+        $sLocation = $this->_getErrorLocation($aGroup, true);
+
+        return [
+            'view' => 'detail',
+            'id' => (int)$aGroup['id'],
+            'title' => $this->getErrorLabel($aGroup),
+            'status' => $aGroup['status'],
+            'facts' => array_values(array_filter([
+                ['title' => _t('_bx_analytics_txt_error_status'), 'value' => _t('_bx_analytics_error_status_' . $aGroup['status']), 'type' => 'text'],
+                ['title' => _t('_bx_analytics_txt_error_kind'), 'value' => _t('_bx_analytics_error_kind_' . $aGroup['kind']) . ((int)$aGroup['fatal'] ? ' · ' . _t('_bx_analytics_txt_fatal') : ''), 'type' => 'text'],
+                $sLocation !== '' ? ['title' => _t('_bx_analytics_txt_error_source'), 'value' => $sLocation, 'type' => 'text'] : null,
+                (int)$aGroup['status_code'] ? ['title' => _t('_bx_analytics_txt_error_http_status'), 'value' => (string)(int)$aGroup['status_code'], 'type' => 'text'] : null,
+                ['title' => _t('_bx_analytics_txt_first_seen'), 'value' => (int)$aGroup['first_seen'], 'type' => 'datetime'],
+                ['title' => _t('_bx_analytics_txt_last_seen'), 'value' => (int)$aGroup['last_seen'], 'type' => 'datetime'],
+                ['title' => _t('_bx_analytics_txt_error_all_time'), 'value' => (int)$aGroup['occurrences'], 'type' => 'number'],
+            ])),
+            'stack' => $bPeople ? (string)$aGroup['stack'] : '',
+            'stack_hidden' => !$bPeople && (string)$aGroup['stack'] !== '',
+            'tiles' => [
+                ['title' => _t('_bx_analytics_metric_occurrences'), 'value' => (int)$aStats['count'], 'type' => 'number'],
+                ['title' => _t('_bx_analytics_metric_visitors'), 'value' => (int)$aStats['users'], 'type' => 'number'],
+                ['title' => _t('_bx_analytics_metric_sessions'), 'value' => (int)$aStats['sessions'], 'type' => 'number'],
+            ],
+            'chart' => [
+                'kind' => 'lines',
+                'labels' => $aFilter['buckets'],
+                'bucket' => $aFilter['bucket'],
+                'tz' => $aFilter['tz'],
+                'datasets' => [
+                    ['key' => 'count', 'label' => _t('_bx_analytics_metric_occurrences'), 'values' => $aCounts],
+                    ['key' => 'users', 'label' => _t('_bx_analytics_metric_visitors'), 'values' => $aUsers],
+                ],
+            ],
+            'breakdowns' => $aBreakdowns,
+            'occurrences' => [
+                'name' => 'error_hits',
+                'caption' => _t('_bx_analytics_txt_error_latest'),
+                'columns' => [
+                    ['key' => 'label', 'type' => 'label', 'title' => _t('_bx_analytics_tab_pages')],
+                    ['key' => 'repeats', 'type' => 'number', 'title' => _t('_bx_analytics_metric_occurrences')],
+                    ['key' => 'ts', 'type' => 'datetime', 'title' => _t('_bx_analytics_txt_time')],
+                ],
+                'rows' => $aOccurrences,
+            ],
+            'manage' => $bManage,
+            'actions' => $aActions,
+        ];
+    }
+
+    /**
+     * What an error group is called in lists: its message, or a name for the catch-all group.
+     */
+    public function getErrorLabel($aGroup)
+    {
+        if($aGroup['kind'] == BxAnalyticsErrors::KIND_OVERFLOW)
+            return _t('_bx_analytics_txt_error_overflow');
+
+        return $aGroup['message'] !== '' ? $aGroup['message'] : _t('_bx_analytics_txt_unknown');
+    }
+
+    /**
+     * Script and position of an error, file:line or file:line:column.
+     */
+    protected function _getErrorLocation($aGroup, $bColumn = false)
+    {
+        if($aGroup['source'] === '')
+            return '';
+
+        $sLocation = $aGroup['source'];
+        if((int)$aGroup['line'])
+            $sLocation .= ':' . (int)$aGroup['line'] . ($bColumn && (int)$aGroup['col'] ? ':' . (int)$aGroup['col'] : '');
+
+        return $sLocation;
+    }
+
+    /**
+     * Web vitals and app timings: the 75th percentile of each metric with the share of good,
+     * needing improvement and poor values; the chosen metric over time, by platform and by
+     * page. Params: `vital` (metric), `vital_device` (all, mobile, desktop), tab
+     * (`tab_performance`): route or path.
+     */
+    public function getPerformance(BxAnalyticsFilter $oFilter, $aParams = [])
+    {
+        $aFilter = $oFilter->get();
+        $aThresholds = $this->_oConfig->CNF['VITALS'];
+        $iMinSamples = 20;
+
+        // the metrics the platform has: web vitals in browsers, screen loads and starts in apps
+        $sPlatform = $aFilter['platform'];
+        $aMetrics = BxAnalyticsVitals::METRICS;
+        if($sPlatform !== '' && $this->_oConfig->isBrowserPlatform($sPlatform))
+            $aMetrics = BxAnalyticsVitals::WEB_METRICS;
+        elseif($sPlatform !== '' && $this->_oConfig->isAppPlatform($sPlatform))
+            $aMetrics = BxAnalyticsVitals::APP_METRICS;
+
+        $sMetric = (string)$this->_param($aParams, 'vital', '');
+        if(!in_array($sMetric, $aMetrics, true))
+            $sMetric = $aMetrics[0];
+
+        $sDevice = (string)$this->_param($aParams, 'vital_device', 'all');
+        if(!in_array($sDevice, ['all', 'mobile', 'desktop'], true))
+            $sDevice = 'all';
+
+        $aGroups = $this->_aReportTabs['performance'];
+        $sGroup = $this->_getTab($aParams, 'performance', $aGroups);
+
+        // the cards: exact counts on each side of the thresholds, and the histograms of the
+        // metrics with enough samples, each in one pass
+        $aDistribution = $this->_oDb->getVitalsDistribution($aFilter, $sDevice, $aThresholds);
+        $aCardMetrics = [];
+        foreach($aMetrics as $sCardMetric)
+            if($aDistribution[$sCardMetric]['samples'] >= $iMinSamples)
+                $aCardMetrics[] = $sCardMetric;
+        list($aCardHistograms) = $this->_getHistograms($this->_oDb->getVitalsHistograms($aFilter, $aCardMetrics, $sDevice));
+
+        $aCards = [];
+        foreach($aMetrics as $sCardMetric) {
+            $aCount = $aDistribution[$sCardMetric];
+            $iSamples = $aCount['samples'];
+
+            $mixedP75 = null;
+            if(isset($aCardHistograms[$sCardMetric]))
+                $mixedP75 = BxAnalyticsVitals::getPercentile($aCardHistograms[$sCardMetric], 0.75, $iMinSamples, [$aCount['good'], $iSamples - $aCount['good'] - $aCount['poor'], $aCount['poor']], $aThresholds[$sCardMetric]);
+
+            $aCards[] = [
+                'metric' => $sCardMetric,
+                'title' => _t('_bx_analytics_vital_' . $sCardMetric),
+                'unit' => BxAnalyticsVitals::getUnit($sCardMetric),
+                'value' => $mixedP75 !== null ? BxAnalyticsVitals::toDisplay($sCardMetric, round($mixedP75)) : null,
+                'rating' => $mixedP75 !== null ? BxAnalyticsVitals::getRating(round($mixedP75), $aThresholds[$sCardMetric]) : '',
+                'samples' => $iSamples,
+                'good' => $iSamples ? round(100 * $aCount['good'] / $iSamples, 1) : 0,
+                'needs_improvement' => $iSamples ? round(100 * ($iSamples - $aCount['good'] - $aCount['poor']) / $iSamples, 1) : 0,
+                'poor' => $iSamples ? round(100 * $aCount['poor'] / $iSamples, 1) : 0,
+                'good_max' => BxAnalyticsVitals::toDisplay($sCardMetric, $aThresholds[$sCardMetric][0]),
+                'poor_min' => BxAnalyticsVitals::toDisplay($sCardMetric, $aThresholds[$sCardMetric][1]),
+                'active' => $sCardMetric == $sMetric,
+            ];
+        }
+
+        $sUnit = BxAnalyticsVitals::getUnit($sMetric);
+        $fP75 = function($aHistogram, $aSides, $iMin) use ($sMetric, $aThresholds) {
+            $mixedValue = BxAnalyticsVitals::getPercentile($aHistogram, 0.75, $iMin, $aSides, $aThresholds[$sMetric]);
+            return $mixedValue !== null ? BxAnalyticsVitals::toDisplay($sMetric, round($mixedValue)) : null;
+        };
+        $fRating = function($mixedValue) use ($sMetric, $aThresholds) {
+            return $mixedValue !== null ? BxAnalyticsVitals::getRating($sMetric == 'cls' ? round($mixedValue * 1000) : $mixedValue, $aThresholds[$sMetric]) : '';
+        };
+
+        // the 75th percentile per time bucket, with the thresholds as lines; a bucket with few
+        // samples is left as a gap
+        list($aByBucket, $aBucketSides) = $this->_getHistograms($this->_oDb->getVitalsHistogram($aFilter, $sMetric, $sDevice, 'bucket', [], [$aFilter['bucket'], $aFilter['offset'], $aFilter['starts']]));
+        $aValues = [];
+        foreach($aFilter['buckets'] as $iBucket)
+            $aValues[] = isset($aByBucket[$iBucket]) ? $fP75($aByBucket[$iBucket], $aBucketSides[$iBucket], 5) : null;
+        $iCount = count($aFilter['buckets']);
+
+        $aPlatformRows = [];
+        list($aByPlatform, $aPlatformSides) = $this->_getHistograms($this->_oDb->getVitalsHistogram($aFilter, $sMetric, $sDevice, 'platform'));
+        foreach($aByPlatform as $sValue => $aHistogram) {
+            $mixedValue = $fP75($aHistogram, $aPlatformSides[$sValue], $iMinSamples);
+            $aPlatformRows[] = [
+                'label' => $this->getLabel('platform', (string)$sValue),
+                'values' => ['p75' => $mixedValue, 'rating' => $fRating($mixedValue), 'samples' => (int)array_sum($aHistogram)],
+            ];
+        }
+        usort($aPlatformRows, function($a, $b) { return $b['values']['samples'] - $a['values']['samples']; });
+
+        $iTotal = $this->_oDb->getVitalsPagesCount($aFilter, $sMetric, $sDevice, $sGroup);
+        $iPages = max(1, (int)ceil($iTotal / $this->_iPerPage));
+        $iPage = min($iPages, max(1, (int)$this->_param($aParams, 'page_performance', 1)));
+        $aPages = $this->_oDb->getVitalsPages($aFilter, $sMetric, $sDevice, $sGroup, $this->_iPerPage, ($iPage - 1) * $this->_iPerPage);
+        list($aPageHistograms, $aPageSides) = $this->_getHistograms($this->_oDb->getVitalsHistogram($aFilter, $sMetric, $sDevice, $sGroup, array_map('strval', array_keys($aPages))));
+
+        $iMax = 1;
+        foreach($aPages as $iSamples)
+            $iMax = max($iMax, (int)$iSamples);
+
+        $aPageRows = [];
+        foreach($aPages as $sValue => $iSamples) {
+            $mixedValue = isset($aPageHistograms[$sValue]) ? $fP75($aPageHistograms[$sValue], $aPageSides[$sValue], $iMinSamples) : null;
+            $aPageRows[] = [
+                'label' => (string)$sValue,
+                'bar' => round(100 * $iSamples / $iMax, 1),
+                'values' => ['p75' => $mixedValue, 'rating' => $fRating($mixedValue), 'samples' => (int)$iSamples],
+            ];
+        }
+
+        $aColumns = function($sTitle) use ($sUnit) {
+            return [
+                ['key' => 'label', 'type' => 'label', 'title' => $sTitle],
+                ['key' => 'p75', 'type' => $sUnit, 'title' => _t('_bx_analytics_txt_p75')],
+                ['key' => 'rating', 'type' => 'rating', 'title' => _t('_bx_analytics_txt_rating')],
+                ['key' => 'samples', 'type' => 'number', 'title' => _t('_bx_analytics_txt_samples')],
+            ];
+        };
+
+        $aDevices = [];
+        foreach(['all', 'mobile', 'desktop'] as $sValue)
+            $aDevices[] = ['value' => $sValue, 'title' => _t('_bx_analytics_txt_device_' . $sValue), 'selected' => $sValue == $sDevice];
+
+        return [
+            'metric' => $sMetric,
+            'metric_title' => _t('_bx_analytics_vital_' . $sMetric),
+            'unit' => $sUnit,
+            'device' => $sDevice,
+            'devices' => $aDevices,
+            'min_samples' => $iMinSamples,
+            'cards' => $aCards,
+            'chart' => [
+                'kind' => 'lines',
+                'metric' => $sUnit,
+                'labels' => $aFilter['buckets'],
+                'bucket' => $aFilter['bucket'],
+                'tz' => $aFilter['tz'],
+                'datasets' => [
+                    ['key' => 'p75', 'label' => _t('_bx_analytics_txt_p75'), 'values' => $aValues],
+                    ['key' => 'good', 'label' => _t('_bx_analytics_txt_threshold_good'), 'values' => array_fill(0, $iCount, BxAnalyticsVitals::toDisplay($sMetric, $aThresholds[$sMetric][0])), 'dashed' => true, 'tone' => 'good'],
+                    ['key' => 'poor', 'label' => _t('_bx_analytics_txt_threshold_poor'), 'values' => array_fill(0, $iCount, BxAnalyticsVitals::toDisplay($sMetric, $aThresholds[$sMetric][1])), 'dashed' => true, 'tone' => 'bad'],
+                ],
+            ],
+            'platforms' => [
+                'name' => 'vital_platforms',
+                'caption' => _t('_bx_analytics_tab_platforms'),
+                'columns' => $aColumns(_t('_bx_analytics_dim_platform')),
+                'rows' => $aPlatformRows,
+                'empty' => _t('_bx_analytics_txt_no_vitals'),
+            ],
+            // named after the block, so its pager and the tabs (which reset the page) share page_performance
+            'pages' => [
+                'name' => 'performance',
+                'caption' => _t('_bx_analytics_txt_vitals_pages'),
+                'tab' => $sGroup,
+                'tabs' => $this->_getTabs($aGroups, $sGroup, '_bx_analytics_tab_vitals_'),
+                'columns' => $aColumns(_t($sGroup == 'route' ? '_bx_analytics_txt_route' : '_bx_analytics_tab_pages')),
+                'rows' => $aPageRows,
+                'page' => $iPage,
+                'pages' => $iPages,
+                'empty' => _t('_bx_analytics_txt_no_vitals'),
+            ],
+        ];
+    }
+
+    /**
+     * Histogram rows (grp, hb, n, and side when the query has it) as [[group => [bucket =>
+     * count]], [group => [counts up to the good threshold, between, above the poor one]]].
+     */
+    protected function _getHistograms($aRows)
+    {
+        $aHistograms = [];
+        $aSides = [];
+        foreach($aRows as $aRow) {
+            $sGroup = (string)$aRow['grp'];
+            $iBucket = (int)$aRow['hb'];
+            $aHistograms[$sGroup][$iBucket] = (isset($aHistograms[$sGroup][$iBucket]) ? $aHistograms[$sGroup][$iBucket] : 0) + (int)$aRow['n'];
+
+            if(!isset($aSides[$sGroup]))
+                $aSides[$sGroup] = isset($aRow['side']) ? [0, 0, 0] : [];
+            if(isset($aRow['side']))
+                $aSides[$sGroup][min(2, max(0, (int)$aRow['side']))] += (int)$aRow['n'];
+        }
+
+        return [$aHistograms, $aSides];
+    }
+
+    /*
+     * Revenue. Amounts come from the ledger in the site's default currency, as the payment
+     * module recorded them; orders paid with credits are reported apart from money.
+     */
+
+    /**
+     * Gross and net revenue, refunds, orders, paying customers and revenue per customer, over
+     * time and split by source, provider, platform and the buyers' traffic sources. Param
+     * `tab` (or tab_revenue): sources, providers, platforms, first_touch, last_touch, campaigns.
+     */
+    public function getRevenue(BxAnalyticsFilter $oFilter, $aParams = [])
+    {
+        $aMoney = $this->getMoney();
+        $aFilter = $this->_getRevenueFilter($oFilter->get(), $aMoney);
+        $aPrevious = $this->_getRevenueFilter($oFilter->getPrevious(), $aMoney);
+
+        $aCurrent = $this->_oDb->getRevenueTotals($aFilter);
+        $aBefore = $this->_oDb->getRevenueTotals($aPrevious);
+
+        $aTiles = [];
+        foreach(['gross' => 'money', 'refunds' => 'money', 'net' => 'money', 'orders' => 'number', 'customers' => 'number', 'arppu' => 'money'] as $sKey => $sType) {
+            $fValue = $sKey == 'arppu' ? ((int)$aCurrent['customers'] ? (float)$aCurrent['net'] / (int)$aCurrent['customers'] : 0) : (float)$aCurrent[$sKey];
+            $fPrevious = $sKey == 'arppu' ? ((int)$aBefore['customers'] ? (float)$aBefore['net'] / (int)$aBefore['customers'] : 0) : (float)$aBefore[$sKey];
+
+            $aTiles[] = [
+                'key' => $sKey,
+                'title' => _t('_bx_analytics_metric_revenue_' . $sKey),
+                'value' => $sType == 'money' ? round($fValue, 2) : (int)$fValue,
+                'previous' => $sType == 'money' ? round($fPrevious, 2) : (int)$fPrevious,
+                'change' => $this->_change($fValue, $fPrevious),
+                'type' => $sType,
+                'metric' => $sKey,
+            ];
+        }
+
+        $aRows = $this->_oDb->getRevenueSeries($aFilter);
+        $aSeries = ['gross' => [], 'net' => [], 'refunds' => []];
+        foreach($aFilter['buckets'] as $iBucket)
+            foreach(array_keys($aSeries) as $sKey)
+                $aSeries[$sKey][] = $iBucket > $aFilter['now'] ? null : (isset($aRows[$iBucket]) ? round((float)$aRows[$iBucket][$sKey], 2) : 0);
+
+        $aDatasets = [];
+        foreach($aSeries as $sKey => $aValues)
+            $aDatasets[] = ['key' => $sKey, 'label' => _t('_bx_analytics_metric_revenue_' . $sKey), 'values' => $aValues];
+
+        $aTabs = $this->_aReportTabs['revenue'];
+        $sTab = $this->_getTab($aParams, 'revenue', $aTabs);
+
+        $fNet = (float)$aCurrent['net'];
+        $aSplit = [];
+        foreach($this->_oDb->getRevenueSplit($aFilter, $aTabs[$sTab], 25) as $aRow) {
+            $fRowNet = (float)$aRow['net'];
+            $aSplit[] = [
+                'label' => $this->getRevenueLabel($aTabs[$sTab], (string)$aRow['value'], $aMoney['payment']),
+                'bar' => $fNet > 0 ? max(0, round(100 * $fRowNet / $fNet, 1)) : 0,
+                'values' => [
+                    'net' => round($fRowNet, 2),
+                    'share' => $fNet > 0 ? round(100 * $fRowNet / $fNet, 1) : 0,
+                    'customers' => (int)$aRow['customers'],
+                    'orders' => (int)$aRow['orders'],
+                ],
+            ];
+        }
+
+        return [
+            'money' => $aMoney,
+            'tiles' => $aTiles,
+            'chart' => [
+                'kind' => 'lines',
+                'metric' => 'money',
+                'currency' => $aMoney['code'],
+                'labels' => $aFilter['buckets'],
+                'bucket' => $aFilter['bucket'],
+                'tz' => $aFilter['tz'],
+                'datasets' => $aDatasets,
+            ],
+            'split' => [
+                'name' => 'revenue',
+                'caption' => _t('_bx_analytics_txt_revenue_split'),
+                'tab' => $sTab,
+                'tabs' => $this->_getTabs($aTabs, $sTab, '_bx_analytics_tab_revenue_'),
+                'columns' => [
+                    ['key' => 'label', 'type' => 'label', 'title' => _t('_bx_analytics_tab_revenue_' . $sTab)],
+                    ['key' => 'net', 'type' => 'money', 'title' => _t('_bx_analytics_metric_revenue_net')],
+                    ['key' => 'share', 'type' => 'percent', 'title' => _t('_bx_analytics_txt_share')],
+                    ['key' => 'customers', 'type' => 'number', 'title' => _t('_bx_analytics_metric_revenue_customers')],
+                    ['key' => 'orders', 'type' => 'number', 'title' => _t('_bx_analytics_metric_revenue_orders')],
+                ],
+                'rows' => $aSplit,
+                'empty' => _t('_bx_analytics_txt_no_revenue'),
+            ],
+            'notes' => $this->_getRevenueNotes($aMoney, (int)$aCurrent['unconverted']),
+            'import' => $this->_getImportStatus(),
+        ];
+    }
+
+    /**
+     * Monthly recurring revenue, new, renewed and cancelled subscriptions and churn per
+     * bucket, the plans that bring the MRR, and why members cancel.
+     */
+    public function getSubscriptions(BxAnalyticsFilter $oFilter, $aParams = [])
+    {
+        $aMoney = $this->getMoney();
+        $aFilter = $this->_getRevenueFilter($oFilter->get(), $aMoney);
+        $aPrevious = $this->_getRevenueFilter($oFilter->getPrevious(), $aMoney);
+        $iNow = (int)$aFilter['now'];
+
+        bx_import('Revenue', $this->_oModule->_aModule);
+
+        // subscriptions alive in either period, read once
+        $aRows = $this->_oDb->getSubscriptionRows(array_merge($aFilter, ['from' => $aPrevious['from']]), [
+            'unit_seconds' => BxAnalyticsRevenue::getUnitSeconds(),
+            'unpaid' => BxAnalyticsRevenue::STATUS_UNPAID,
+            'grace' => BxAnalyticsRevenue::UNPAID_GRACE,
+        ]);
+
+        list($aCurrent) = $this->_sweepSubscriptions($aRows, [[$aFilter['from'], min($aFilter['to'], $iNow)]]);
+        list($aBefore) = $this->_sweepSubscriptions($aRows, [[$aPrevious['from'], min($aPrevious['to'], $iNow)]]);
+
+        $aTiles = [];
+        foreach(['mrr' => 'money', 'active' => 'number', 'trials' => 'number', 'new' => 'number', 'cancelled' => 'number', 'churn' => 'percent'] as $sKey => $sType) {
+            $sSub = '';
+            if($sKey == 'trials' && $aCurrent['trial_mrr'] > 0)
+                $sSub = _t('_bx_analytics_txt_sbs_trial_mrr', _t_format_currency($aCurrent['trial_mrr']));
+            else if($sKey == 'churn' && $aCurrent['churn'] === null)
+                $sSub = _t('_bx_analytics_txt_sbs_churn_no_base');
+
+            $aTiles[] = [
+                'key' => $sKey,
+                'title' => _t('_bx_analytics_metric_sbs_' . $sKey),
+                'value' => $aCurrent[$sKey],
+                'previous' => $aBefore[$sKey],
+                'change' => $aCurrent[$sKey] !== null && $aBefore[$sKey] !== null ? $this->_change($aCurrent[$sKey], $aBefore[$sKey]) : null,
+                'type' => $sType,
+                'metric' => $sKey,
+                'sub' => $sSub,
+            ];
+        }
+
+        // per bucket, within the period (the first bucket may start before it); renewals paid
+        // with credits too, like the subscriptions
+        $aRenewals = $this->_oDb->getRevenueSeries($aFilter, null);
+        $aBuckets = $aFilter['buckets'];
+        $aSpans = [];
+        foreach($aBuckets as $i => $iBucket)
+            if($iBucket <= $iNow)
+                $aSpans[$i] = [max($iBucket, $aFilter['from']), min(isset($aBuckets[$i + 1]) ? $aBuckets[$i + 1] : $aFilter['to'], $iNow)];
+        $aSwept = array_combine(array_keys($aSpans), $this->_sweepSubscriptions($aRows, $aSpans));
+
+        $aMrr = $aNew = $aRenewed = $aCancelled = [];
+        foreach($aBuckets as $i => $iBucket) {
+            if(!isset($aSwept[$i])) {
+                $aMrr[] = $aNew[] = $aRenewed[] = $aCancelled[] = null;
+                continue;
+            }
+
+            $aMrr[] = $aSwept[$i]['mrr'];
+            $aNew[] = $aSwept[$i]['new'];
+            $aCancelled[] = $aSwept[$i]['cancelled'];
+            $aRenewed[] = isset($aRenewals[$iBucket]) ? (int)$aRenewals[$iBucket]['renewals'] : 0;
+        }
+
+        // plans paying at the end of the period, by MRR
+        $iEnd = min($aFilter['to'], $iNow);
+        $aPlans = [];
+        foreach($aRows as $aRow) {
+            if(!$this->_isPaying($aRow, $iEnd))
+                continue;
+
+            $sKey = $aRow['module'] . '|' . (int)$aRow['item_id'];
+            if(!isset($aPlans[$sKey]))
+                $aPlans[$sKey] = ['module' => $aRow['module'], 'item_id' => (int)$aRow['item_id'], 'active' => 0, 'mrr' => 0];
+            $aPlans[$sKey]['active']++;
+            $aPlans[$sKey]['mrr'] += (float)$aRow['mrr_default'];
+        }
+        uasort($aPlans, function($a, $b) { return $b['mrr'] <=> $a['mrr']; });
+
+        $aPlanRows = [];
+        foreach(array_slice($aPlans, 0, 10) as $aPlan) {
+            $aItem = $this->_getCartItem($aPlan['module'], $aPlan['item_id']);
+            $aPlanRows[] = [
+                'label' => $aItem['title'],
+                'sub' => $aItem['module_title'],
+                'link' => $aItem['url'],
+                'bar' => $aCurrent['mrr'] > 0 ? round(100 * $aPlan['mrr'] / $aCurrent['mrr'], 1) : 0,
+                'values' => ['active' => $aPlan['active'], 'mrr' => round($aPlan['mrr'], 2)],
+            ];
+        }
+
+        $aReasons = $this->_oDb->getSubscriptionReasons($aFilter, 10);
+        $iReasons = array_sum($aReasons);
+        $aReasonRows = [];
+        foreach($aReasons as $sReason => $iCount)
+            $aReasonRows[] = [
+                'label' => $sReason !== '' && $sReason !== 'cancel' ? $sReason : _t('_bx_analytics_txt_sbs_no_reason'),
+                'bar' => $iReasons ? round(100 * $iCount / $iReasons, 1) : 0,
+                'values' => ['count' => (int)$iCount, 'share' => $iReasons ? round(100 * $iCount / $iReasons, 1) : 0],
+            ];
+
+        $aLabels = $aFilter['buckets'];
+
+        return [
+            'money' => $aMoney,
+            'tiles' => $aTiles,
+            'chart' => [
+                'kind' => 'lines',
+                'metric' => 'money',
+                'currency' => $aMoney['code'],
+                'labels' => $aLabels,
+                'bucket' => $aFilter['bucket'],
+                'tz' => $aFilter['tz'],
+                'datasets' => [['key' => 'mrr', 'label' => _t('_bx_analytics_metric_sbs_mrr'), 'values' => $aMrr]],
+            ],
+            'movement' => [
+                'kind' => 'stacked',
+                'metric' => 'number',
+                'labels' => $aLabels,
+                'bucket' => $aFilter['bucket'],
+                'tz' => $aFilter['tz'],
+                'datasets' => [
+                    ['key' => 'new', 'label' => _t('_bx_analytics_metric_sbs_new'), 'values' => $aNew],
+                    ['key' => 'renewed', 'label' => _t('_bx_analytics_metric_sbs_renewed'), 'values' => $aRenewed],
+                    ['key' => 'cancelled', 'label' => _t('_bx_analytics_metric_sbs_cancelled'), 'values' => $aCancelled],
+                ],
+            ],
+            'plans' => [
+                'name' => 'sbs_plans',
+                'caption' => _t('_bx_analytics_txt_sbs_plans'),
+                'columns' => [
+                    ['key' => 'label', 'type' => 'label', 'title' => _t('_bx_analytics_txt_sbs_plan')],
+                    ['key' => 'active', 'type' => 'number', 'title' => _t('_bx_analytics_metric_sbs_active')],
+                    ['key' => 'mrr', 'type' => 'money', 'title' => _t('_bx_analytics_metric_sbs_mrr')],
+                ],
+                'rows' => $aPlanRows,
+                'empty' => _t('_bx_analytics_txt_no_subscriptions'),
+            ],
+            'reasons' => [
+                'name' => 'sbs_reasons',
+                'caption' => _t('_bx_analytics_txt_sbs_reasons'),
+                'columns' => [
+                    ['key' => 'label', 'type' => 'label', 'title' => _t('_bx_analytics_txt_sbs_reason')],
+                    ['key' => 'count', 'type' => 'number', 'title' => _t('_bx_analytics_metric_sbs_cancelled')],
+                    ['key' => 'share', 'type' => 'percent', 'title' => _t('_bx_analytics_txt_share')],
+                ],
+                'rows' => $aReasonRows,
+                'empty' => _t('_bx_analytics_txt_no_cancellations'),
+            ],
+            'notes' => $this->_getRevenueNotes($aMoney, 0),
+        ];
+    }
+
+    /**
+     * The items that sell best, the sellers of a multi-vendor site, the site's own sales
+     * against its vendors' and the commissions they paid, and commission invoices.
+     */
+    public function getSales(BxAnalyticsFilter $oFilter, $aParams = [])
+    {
+        $aMoney = $this->getMoney();
+        $aFilter = $this->_getRevenueFilter($oFilter->get(), $aMoney);
+
+        $aTotals = $this->_oDb->getRevenueTotals($aFilter);
+        $fNet = (float)$aTotals['net'];
+
+        $aItems = [];
+        foreach($this->_oDb->getRevenueTopItems($aFilter, 10) as $aRow) {
+            $aItem = $this->_getCartItem($aRow['module'], (int)$aRow['item_id']);
+            $aItems[] = [
+                'label' => $aItem['title'],
+                'sub' => $aItem['module_title'],
+                'link' => $aItem['url'],
+                'bar' => $fNet > 0 ? max(0, round(100 * $aRow['net'] / $fNet, 1)) : 0,
+                'values' => [
+                    'net' => round((float)$aRow['net'], 2),
+                    'units' => (int)$aRow['units'],
+                    'orders' => (int)$aRow['orders'],
+                    'renewals' => (int)$aRow['renewals'],
+                ],
+            ];
+        }
+
+        $aSellers = [];
+        foreach($this->_oDb->getRevenueSplit($aFilter, 'seller_id', 10) as $aRow) {
+            $aMember = $this->getMember((int)$aRow['value']);
+            $aSellers[] = [
+                'label' => $aMember ? $aMember['name'] : _t('_bx_analytics_txt_deleted_member'),
+                'link' => $aMember ? $aMember['url'] : '',
+                'bar' => $fNet > 0 ? max(0, round(100 * $aRow['net'] / $fNet, 1)) : 0,
+                'values' => [
+                    'net' => round((float)$aRow['net'], 2),
+                    'orders' => (int)$aRow['orders'],
+                    'customers' => (int)$aRow['customers'],
+                ],
+            ];
+        }
+
+        $iSiteAdmin = (int)BxDolPayments::getInstance()->getOption('site_admin');
+        $aSplit = $this->_oDb->getRevenueSellers($aFilter, $iSiteAdmin);
+
+        $aTiles = [
+            ['title' => _t('_bx_analytics_metric_revenue_net'), 'value' => round($fNet, 2), 'type' => 'money'],
+        ];
+        if($iSiteAdmin)
+            $aTiles[] = ['title' => _t('_bx_analytics_metric_sales_site'), 'value' => round((float)$aSplit['site'], 2), 'type' => 'money'];
+        $aTiles[] = ['title' => _t('_bx_analytics_metric_sales_vendors'), 'value' => round((float)$aSplit['vendors'], 2), 'type' => 'money', 'sub' => _t('_bx_analytics_txt_sales_vendor_count', (int)$aSplit['vendor_count'])];
+        $aTiles[] = ['title' => _t('_bx_analytics_metric_sales_commissions'), 'value' => round((float)$aSplit['commissions'], 2), 'type' => 'money'];
+
+        $aInvoices = [];
+        foreach($this->getRevenueSources(['from' => $aFilter['from'], 'to' => $aFilter['to']], 'money') as $aSource)
+            foreach($aSource['metrics'] as $aMetric)
+                $aInvoices[] = [
+                    'title' => _t($aMetric['title']),
+                    'value' => isset($aMetric['value']) ? $aMetric['value'] : 0,
+                    'type' => 'number',
+                    'sub' => isset($aMetric['money']) && $aMetric['money'] !== null ? _t_format_currency($aMetric['money']) : '',
+                ];
+
+        return [
+            'money' => $aMoney,
+            'tiles' => $aTiles,
+            'items' => [
+                'name' => 'sales_items',
+                'caption' => _t('_bx_analytics_txt_sales_items'),
+                'columns' => [
+                    ['key' => 'label', 'type' => 'label', 'title' => _t('_bx_analytics_txt_item')],
+                    ['key' => 'net', 'type' => 'money', 'title' => _t('_bx_analytics_metric_revenue_net')],
+                    ['key' => 'units', 'type' => 'number', 'title' => _t('_bx_analytics_metric_sales_units')],
+                    ['key' => 'renewals', 'type' => 'number', 'title' => _t('_bx_analytics_metric_sbs_renewed')],
+                ],
+                'rows' => $aItems,
+                'empty' => _t('_bx_analytics_txt_no_revenue'),
+            ],
+            'sellers' => [
+                'name' => 'sales_sellers',
+                'caption' => _t('_bx_analytics_txt_sales_sellers'),
+                'columns' => [
+                    ['key' => 'label', 'type' => 'label', 'title' => _t('_bx_analytics_txt_sales_seller')],
+                    ['key' => 'net', 'type' => 'money', 'title' => _t('_bx_analytics_metric_revenue_net')],
+                    ['key' => 'orders', 'type' => 'number', 'title' => _t('_bx_analytics_metric_revenue_orders')],
+                    ['key' => 'customers', 'type' => 'number', 'title' => _t('_bx_analytics_metric_revenue_customers')],
+                ],
+                'rows' => $aSellers,
+                'empty' => _t('_bx_analytics_txt_no_revenue'),
+            ],
+            'invoices' => $aInvoices,
+            'notes' => $this->_getRevenueNotes($aMoney, (int)$aTotals['unconverted']),
+        ];
+    }
+
+    /**
+     * The credits economy as the modules that keep credits report it: bought, spent, granted,
+     * sent and withdrawn, the top spenders and the credits members hold; and from the ledger,
+     * what credits paid for, by module, valued in money. Those modules don't know the
+     * platform, so the whole block is for every platform, the ledger's figures too.
+     */
+    public function getCredits(BxAnalyticsFilter $oFilter, $aParams = [])
+    {
+        $aMoney = $this->getMoney();
+        $aFilter = $this->_getRevenueFilter($oFilter->get(), $aMoney);
+        $bPlatform = $aFilter['platform'] !== '';
+        $aFilter['platform'] = '';
+
+        $aSources = $this->getRevenueSources([
+            'from' => $aFilter['from'],
+            'to' => $aFilter['to'],
+            'starts' => $aFilter['buckets'],
+            'limit' => 10,
+        ], 'credits');
+
+        $aSource = reset($aSources);
+        $sSource = (string)key($aSources);
+
+        $aTiles = $aDatasets = $aSpenders = [];
+        if($aSource) {
+            foreach($aSource['metrics'] as $sKey => $aMetric) {
+                $aTiles[] = [
+                    'key' => $sKey,
+                    'title' => _t($aMetric['title']),
+                    'value' => (float)$aMetric['value'],
+                    'type' => 'credits',
+                    'sub' => isset($aMetric['money']) && $aMetric['money'] !== null && (float)$aMetric['value'] > 0 ? _t_format_currency($aMetric['money']) : '',
+                ];
+
+                if(in_array($sKey, ['bought', 'spent', 'granted', 'withdrawn'], true))
+                    $aDatasets[] = [
+                        'key' => $sKey,
+                        'label' => _t($aMetric['title']),
+                        'values' => array_map(function($iBucket) use ($aMetric, $aFilter) {
+                            return $iBucket > $aFilter['now'] ? null : (isset($aMetric['series'][$iBucket]) ? (float)$aMetric['series'][$iBucket] : 0);
+                        }, $aFilter['buckets']),
+                    ];
+            }
+
+            if(!empty($aSource['snapshot']['balance'])) {
+                $aBalance = $aSource['snapshot']['balance'];
+                $aTiles[] = [
+                    'key' => 'balance',
+                    'title' => _t($aBalance['title']),
+                    'value' => (float)$aBalance['value'],
+                    'type' => 'credits',
+                    'sub' => _t('_bx_analytics_txt_credits_liability', _t_format_currency(isset($aBalance['money_default']) ? $aBalance['money_default'] : 0)),
+                ];
+            }
+
+            if(!empty($aSource['lists']['top_spenders']['rows'])) {
+                $aList = $aSource['lists']['top_spenders']['rows'];
+                $fMax = 0;
+                foreach($aList as $aRow)
+                    $fMax = max($fMax, (float)$aRow['value']);
+
+                foreach($aList as $aRow) {
+                    $aMember = $this->getMember(isset($aRow['profile_id']) ? (int)$aRow['profile_id'] : 0);
+                    $aSpenders[] = [
+                        'label' => $aMember ? $aMember['name'] : (isset($aRow['label']) ? (string)$aRow['label'] : _t('_bx_analytics_txt_deleted_member')),
+                        'link' => $aMember ? $aMember['url'] : '',
+                        'bar' => $fMax > 0 ? round(100 * $aRow['value'] / $fMax, 1) : 0,
+                        'values' => ['value' => (float)$aRow['value']],
+                    ];
+                }
+            }
+        }
+
+        // what orders paid with credits bought, valued in money
+        $aSpentRows = [];
+        $aSpent = $this->_oDb->getRevenueSplit($aFilter, 'module', 10, 0);
+        $fSpent = 0;
+        foreach($aSpent as $aRow)
+            $fSpent += max(0, (float)$aRow['net']);
+        foreach($aSpent as $aRow)
+            $aSpentRows[] = [
+                'label' => $this->getRevenueLabel('module', (string)$aRow['value'], $aMoney['payment']),
+                'bar' => $fSpent > 0 ? max(0, round(100 * $aRow['net'] / $fSpent, 1)) : 0,
+                'values' => ['net' => round((float)$aRow['net'], 2), 'orders' => (int)$aRow['orders']],
+            ];
+
+        // credit bundles sold for money
+        $fBundles = 0;
+        if($sSource !== '')
+            foreach($this->_oDb->getRevenueSplit($aFilter, 'module', 1000) as $aRow)
+                if($aRow['value'] === $sSource)
+                    $fBundles = (float)$aRow['net'];
+
+        if($aSource)
+            $aTiles[] = ['key' => 'bundles', 'title' => _t('_bx_analytics_metric_credits_bundles'), 'value' => round($fBundles, 2), 'type' => 'money'];
+
+        $aNotes = [];
+        if($aMoney['credits_only'])
+            $aNotes[] = _t('_bx_analytics_txt_revenue_credits_only');
+        if($aSource && $bPlatform)
+            $aNotes[] = _t('_bx_analytics_txt_credits_all_platforms');
+        // checkouts the ledger doesn't have: paid before it started, or not imported yet
+        if(empty($aSpentRows) && !empty($aSource['metrics']['spent']['value']) && (float)$aSource['metrics']['spent']['value'] > 0)
+            $aNotes[] = _t('_bx_analytics_txt_credits_not_in_ledger');
+
+        return [
+            'money' => $aMoney,
+            'available' => !empty($aSource),
+            'title' => $aSource ? _t($aSource['title']) : '',
+            'tiles' => $aTiles,
+            'chart' => [
+                'kind' => 'lines',
+                'metric' => 'number',
+                'labels' => $aFilter['buckets'],
+                'bucket' => $aFilter['bucket'],
+                'tz' => $aFilter['tz'],
+                'datasets' => $aDatasets,
+            ],
+            'spenders' => [
+                'name' => 'credits_spenders',
+                'caption' => _t('_bx_analytics_txt_credits_spenders'),
+                'columns' => [
+                    ['key' => 'label', 'type' => 'label', 'title' => _t('_bx_analytics_txt_member_name')],
+                    ['key' => 'value', 'type' => 'credits', 'title' => _t('_bx_analytics_txt_credits_spent')],
+                ],
+                'rows' => $aSpenders,
+                'empty' => _t('_bx_analytics_txt_no_credits'),
+            ],
+            'spent' => [
+                'name' => 'credits_spent',
+                'caption' => _t('_bx_analytics_txt_credits_spent_on'),
+                'columns' => [
+                    ['key' => 'label', 'type' => 'label', 'title' => _t('_bx_analytics_tab_revenue_sources')],
+                    ['key' => 'net', 'type' => 'money', 'title' => _t('_bx_analytics_txt_credits_value')],
+                    ['key' => 'orders', 'type' => 'number', 'title' => _t('_bx_analytics_metric_revenue_orders')],
+                ],
+                'rows' => $aSpentRows,
+                'empty' => _t('_bx_analytics_txt_no_credit_orders'),
+            ],
+            'notes' => $aNotes,
+        ];
+    }
+
+    /**
+     * The site's default currency and the payment module the ledger is read for.
+     */
+    public function getMoney()
+    {
+        $oPayments = BxDolPayments::getInstance();
+        $bActive = $oPayments->isActive();
+
+        return [
+            'active' => $bActive,
+            'payment' => $bActive ? (string)$oPayments->getActive() : '',
+            'code' => $bActive ? (string)$oPayments->getCurrencyCode() : '',
+            'credits_only' => $bActive && (bool)$oPayments->isCreditsOnly(),
+        ];
+    }
+
+    /**
+     * What modules report about their own economy through the get_analytics_revenue service,
+     * found in every installed module: [module name => report], the ones of a unit only. The
+     * unit is read first (meta), so a module of another unit doesn't compute its report.
+     *
+     * @param array $aParams from, to, starts, limit; or meta to learn titles and units only
+     * @param string $sUnit money or credits, '' for any
+     */
+    public function getRevenueSources($aParams, $sUnit = '')
+    {
+        $aResult = [];
+        foreach(BxDolModuleQuery::getInstance()->getModulesBy(['type' => 'modules', 'active' => 1]) as $aModule) {
+            if(empty($aModule['name']) || !bx_is_srv($aModule['name'], 'get_analytics_revenue'))
+                continue;
+
+            if($sUnit !== '' && empty($aParams['meta'])) {
+                $aMeta = bx_srv($aModule['name'], 'get_analytics_revenue', [['meta' => true]]);
+                if(empty($aMeta['unit']) || $aMeta['unit'] != $sUnit)
+                    continue;
+            }
+
+            $aReport = bx_srv($aModule['name'], 'get_analytics_revenue', [$aParams]);
+            if(empty($aReport) || !is_array($aReport) || empty($aReport['unit']) || ($sUnit !== '' && $aReport['unit'] != $sUnit))
+                continue;
+
+            if(empty($aParams['meta']) && (empty($aReport['metrics']) || !is_array($aReport['metrics'])))
+                continue;
+
+            $aResult[$aModule['name']] = $aReport;
+        }
+
+        return $aResult;
+    }
+
+    /**
+     * A human name for a ledger value: a module's title, a provider, a platform, a channel.
+     */
+    public function getRevenueLabel($sDimension, $sValue, $sPayment = '')
+    {
+        switch($sDimension) {
+            case 'module':
+                if($sValue === '')
+                    return _t('_bx_analytics_txt_unknown');
+                if($sPayment !== '' && $sValue === $sPayment)
+                    return _t('_bx_analytics_txt_revenue_commissions');
+                return $this->_getModuleTitle($sValue);
+
+            case 'provider':
+                if($sValue === '')
+                    return _t('_bx_analytics_txt_unknown');
+                $sValue = str_replace('_', ' ', $sValue);
+                return mb_strtoupper(get_mb_substr($sValue, 0, 1)) . get_mb_substr($sValue, 1, get_mb_len($sValue) - 1);
+
+            case 'platform':
+                return $sValue !== '' ? $this->getLabel('platform', $sValue) : _t('_bx_analytics_txt_revenue_imported');
+
+            case 'first_channel':
+            case 'last_channel':
+                return $sValue !== '' ? $this->getLabel('channel', $sValue) : _t('_bx_analytics_txt_revenue_untracked');
+
+            case 'last_utm_campaign':
+            case 'first_utm_campaign':
+                return $sValue !== '' ? $sValue : _t('_bx_analytics_txt_revenue_no_campaign');
+        }
+
+        return $sValue;
+    }
+
+    /**
+     * Net revenue of members over all the ledger, for the Users report: [profile id => amount].
+     */
+    public function getLifetimeValues($aProfileIds)
+    {
+        $aMoney = $this->getMoney();
+        if($aMoney['payment'] === '')
+            return [];
+
+        return $this->_oDb->getLifetimeValues($aProfileIds, $aMoney['payment']);
+    }
+
+    /**
+     * Subscription figures over spans of time, sorted and not overlapping. For each span: MRR
+     * and paying subscriptions at its end, trials at its end, new and ended (cancelled, or
+     * unpaid past the grace time) paying subscriptions in it, and churn (of those paying at
+     * its start, the share that ended in it). One pass over the rows, each finding its spans
+     * by binary search.
+     *
+     * @param array $aSpans [[from, to], ...]
+     * @return array a result per span, in the order of $aSpans
+     */
+    protected function _sweepSubscriptions($aRows, $aSpans)
+    {
+        $aSpans = array_values($aSpans);
+        $iSpans = count($aSpans);
+        $aFroms = array_map('intval', array_column($aSpans, 0));
+        $aTos = array_map('intval', array_column($aSpans, 1));
+
+        // counts at the spans' ends (or starts) are ranges of spans: added as differences
+        $aDiff = array_fill_keys(['mrr', 'active', 'trials', 'trial_mrr', 'at_start'], array_fill(0, $iSpans + 1, 0));
+        $aNew = $aEnded = $aChurned = array_fill(0, $iSpans, 0);
+
+        foreach($aRows as $aRow) {
+            $iFirst = (int)$aRow['first_paid'];
+            $iEnd = $this->_getSubscriptionEnd($aRow);
+            $fMrr = (float)$aRow['mrr_default'];
+
+            if($iFirst) {
+                $this->_addSpans($aDiff['mrr'], $aTos, $iFirst, $iEnd, $fMrr);
+                $this->_addSpans($aDiff['active'], $aTos, $iFirst, $iEnd, 1);
+                $this->_addSpans($aDiff['at_start'], $aFroms, $iFirst, $iEnd, 1);
+
+                if(($i = $this->_findSpan($aFroms, $aTos, $iFirst)) !== null)
+                    $aNew[$i]++;
+
+                if($iEnd && ($i = $this->_findSpan($aFroms, $aTos, $iEnd)) !== null) {
+                    $aEnded[$i]++;
+                    if($iFirst < $aFroms[$i])
+                        $aChurned[$i]++;
+                }
+            }
+
+            // in trial from its start until the trial, its first charge or its end is over
+            $iTrialEnd = $this->_getTrialEnd($aRow);
+            if($iTrialEnd !== null) {
+                $iUntil = 0;
+                foreach([$iTrialEnd, $iFirst, $iEnd] as $iTime)
+                    if($iTime)
+                        $iUntil = $iUntil ? min($iUntil, $iTime) : $iTime;
+
+                $this->_addSpans($aDiff['trials'], $aTos, (int)$aRow['started'], $iUntil, 1);
+                $this->_addSpans($aDiff['trial_mrr'], $aTos, (int)$aRow['started'], $iUntil, $fMrr);
+            }
+        }
+
+        $aResults = [];
+        $aSum = array_fill_keys(array_keys($aDiff), 0);
+        for($i = 0; $i < $iSpans; $i++) {
+            foreach($aDiff as $sKey => $aValues)
+                $aSum[$sKey] += $aValues[$i];
+
+            if($aTos[$i] <= $aFroms[$i]) {
+                $aResults[] = ['mrr' => null, 'active' => null, 'trials' => null, 'trial_mrr' => 0, 'new' => null, 'cancelled' => null, 'churn' => null];
+                continue;
+            }
+
+            $aResults[] = [
+                'mrr' => round($aSum['mrr'], 2),
+                'active' => (int)$aSum['active'],
+                'trials' => (int)$aSum['trials'],
+                'trial_mrr' => round($aSum['trial_mrr'], 2),
+                'new' => $aNew[$i],
+                'cancelled' => $aEnded[$i],
+                'churn' => $aSum['at_start'] ? round(100 * $aChurned[$i] / $aSum['at_start'], 1) : null,
+            ];
+        }
+
+        return $aResults;
+    }
+
+    /**
+     * Add a value to the spans whose edge (from `$aEdges`, ascending) is in (`$iAfter`,
+     * `$iUntil`], `$iUntil` 0 for no end.
+     */
+    protected function _addSpans(&$aDiff, $aEdges, $iAfter, $iUntil, $mixedValue)
+    {
+        $iLo = self::_firstAbove($aEdges, $iAfter);
+        $iHi = $iUntil ? self::_firstAbove($aEdges, $iUntil) : count($aEdges);
+        if($iLo >= $iHi)
+            return;
+
+        $aDiff[$iLo] += $mixedValue;
+        $aDiff[$iHi] -= $mixedValue;
+    }
+
+    /**
+     * The span a time falls in, or null.
+     */
+    protected function _findSpan($aFroms, $aTos, $iTime)
+    {
+        $i = self::_firstAbove($aFroms, $iTime) - 1;
+
+        return $i >= 0 && $iTime < $aTos[$i] ? $i : null;
+    }
+
+    /**
+     * Index of the first value greater than `$iTime` in an ascending list, or its length.
+     */
+    protected static function _firstAbove($aList, $iTime)
+    {
+        $iLo = 0;
+        $iHi = count($aList);
+        while($iLo < $iHi) {
+            $iMid = ($iLo + $iHi) >> 1;
+            if($aList[$iMid] > $iTime)
+                $iHi = $iMid;
+            else
+                $iLo = $iMid + 1;
+        }
+
+        return $iLo;
+    }
+
+    /**
+     * A subscription was paying at a time: first paid before it and not ended by then.
+     */
+    protected function _isPaying($aRow, $iTime)
+    {
+        $iFirst = (int)$aRow['first_paid'];
+        $iEnd = $this->_getSubscriptionEnd($aRow);
+
+        return $iFirst && $iFirst < $iTime && (!$iEnd || $iEnd >= $iTime);
+    }
+
+    /**
+     * When a subscription stopped paying, or 0: its cancellation, or for an unpaid one the end
+     * of its last paid period and the grace time (a failed charge the payment module stopped
+     * retrying never cancels it).
+     */
+    protected function _getSubscriptionEnd($aRow)
+    {
+        if((int)$aRow['canceled'])
+            return (int)$aRow['canceled'];
+
+        if($aRow['status'] != BxAnalyticsRevenue::STATUS_UNPAID || !(int)$aRow['last_paid'])
+            return 0;
+
+        return (int)$aRow['last_paid'] + (int)BxAnalyticsRevenue::getPeriodSeconds($aRow['period'], $aRow['period_unit']) + BxAnalyticsRevenue::UNPAID_GRACE;
+    }
+
+    /**
+     * When a subscription's trial ends (0: a trial of unknown length), or null without a trial.
+     */
+    protected function _getTrialEnd($aRow)
+    {
+        if((int)$aRow['trial'] > 0)
+            return (int)$aRow['started'] + 86400 * (int)$aRow['trial'];
+
+        return $aRow['status'] == BxAnalyticsRevenue::STATUS_TRIAL ? 0 : null;
+    }
+
+    /**
+     * Title, link and module of something sold, from its module's get_cart_item.
+     */
+    protected function _getCartItem($sModule, $iItemId)
+    {
+        $aResult = [
+            'title' => _t('_bx_analytics_txt_item_n', (int)$iItemId),
+            'url' => '',
+            'module_title' => $sModule !== '' ? $this->_getModuleTitle($sModule) : '',
+        ];
+
+        if($sModule === '' || !BxDolModuleQuery::getInstance()->isEnabledByName($sModule) || !bx_is_srv($sModule, 'get_cart_item'))
+            return $aResult;
+
+        $aItem = bx_srv($sModule, 'get_cart_item', [(int)$iItemId]);
+        if(!empty($aItem) && is_array($aItem)) {
+            if(!empty($aItem['title']) && is_string($aItem['title']))
+                $aResult['title'] = strip_tags($aItem['title']);
+            if(!empty($aItem['url']) && is_string($aItem['url']))
+                $aResult['url'] = $aItem['url'];
+        }
+
+        return $aResult;
+    }
+
+    protected function _getRevenueFilter($aFilter, $aMoney)
+    {
+        $aFilter['payment'] = $aMoney['payment'];
+
+        return $aFilter;
+    }
+
+    protected function _getRevenueNotes($aMoney, $iUnconverted)
+    {
+        $aNotes = [];
+        if(!$aMoney['active'])
+            $aNotes[] = _t('_bx_analytics_txt_revenue_no_payment');
+        if($aMoney['credits_only'])
+            $aNotes[] = _t('_bx_analytics_txt_revenue_credits_only');
+        if($iUnconverted > 0)
+            $aNotes[] = _t('_bx_analytics_txt_revenue_no_rate', $iUnconverted);
+        // no currency without a payment module: the tab still shows, for other revenue sources to come
+        if($aMoney['code'] !== '')
+            $aNotes[] = _t('_bx_analytics_txt_revenue_as_recorded', $aMoney['code']);
+
+        return $aNotes;
+    }
+
+    /**
+     * Where the import of past payments stands, for administrators.
+     */
+    protected function _getImportStatus()
+    {
+        if(!isAdmin())
+            return [];
+
+        $aStatus = $this->_oModule->getRevenue()->getBackfillStatus(true);
+
+        return $aStatus['state'] == 'unavailable' ? [] : $aStatus;
+    }
+
+    /**
+     * Name, link and picture of a profile, or null when it no longer exists.
+     */
+    public function getMember($iProfileId)
+    {
+        if(!$iProfileId)
+            return null;
+
+        $oProfile = BxDolProfile::getInstance($iProfileId);
+        if(!$oProfile)
+            return null;
+
+        return [
+            'id' => (int)$iProfileId,
+            'name' => $oProfile->getDisplayName(),
+            'url' => $oProfile->getUrl(),
+            'thumb' => $oProfile->getThumb(),
+        ];
+    }
+
+    /**
+     * A readable name for an event: a site action with the module it happened in, or a
+     * custom event's own name.
+     */
+    public function getEventLabel($sType, $sName, $sModule)
+    {
+        $sModuleTitle = $sModule !== '' ? $this->_getModuleTitle($sModule) : '';
+
+        if($sType == BX_ANALYTICS_HIT_UNA) {
+            // an action without a title of its own (a module's, or a newer one) by its name
+            $sKey = '_bx_analytics_action_' . $sName;
+            $sLabel = _t($sKey);
+            if($sLabel === $sKey)
+                $sLabel = $sName;
+            return $sModuleTitle !== '' && $sModule != 'account' ? $sLabel . ' · ' . $sModuleTitle : $sLabel;
+        }
+
+        return $sModuleTitle !== '' ? $sName . ' · ' . $sModuleTitle : $sName;
+    }
+
+    /**
+     * Title of the module an object system belongs to: bx_posts and bx_posts_reactions are both Posts.
+     */
+    protected function _getModuleTitle($sSystem)
+    {
+        if($this->_aModuleTitles === null) {
+            $this->_aModuleTitles = [];
+            foreach(BxDolModuleQuery::getInstance()->getModulesBy(['type' => 'modules', 'active' => 1]) as $aModule)
+                $this->_aModuleTitles[$aModule['name']] = $aModule['title'];
+            uksort($this->_aModuleTitles, function($a, $b) { return get_mb_len($b) - get_mb_len($a); });
+        }
+
+        foreach($this->_aModuleTitles as $sName => $sTitle)
+            if($sSystem === $sName || bx_mb_strpos($sSystem, $sName . '_') === 0)
+                return $sTitle;
+
+        // core objects, like the friends and follows connections, have their own names
+        $sKey = '_bx_analytics_object_' . $sSystem;
+        $sTitle = _t($sKey);
+
+        return $sTitle !== $sKey ? $sTitle : $sSystem;
+    }
+
+    protected function _parseEvent($sValue)
+    {
+        $aParts = explode('|', (string)$sValue);
+        if(count($aParts) != 3 || !in_array($aParts[0], [BX_ANALYTICS_HIT_EVENT, BX_ANALYTICS_HIT_UNA], true) || $aParts[1] === '')
+            return null;
+
+        return ['type' => $aParts[0], 'name' => $aParts[1], 'module' => $aParts[2]];
+    }
+
+    protected function _getLocation($aSession)
+    {
+        $aParts = array_filter([$aSession['city'], $aSession['country'] !== '' ? $this->getLabel('country', $aSession['country']) : '']);
+
+        return !empty($aParts) ? implode(', ', $aParts) : _t('_bx_analytics_txt_unknown');
+    }
+
+    protected function _getDevice($aSession)
+    {
+        $aParts = array_filter([
+            $aSession['device'] !== '' ? $this->getLabel('device', $aSession['device']) : '',
+            $aSession['browser'],
+            trim($aSession['os'] . ' ' . $aSession['os_version']),
+        ]);
+
+        return implode(' · ', $aParts);
+    }
+
+    protected function _getPlatformsText($sPlatforms)
+    {
+        $aLabels = [];
+        foreach(explode(',', (string)$sPlatforms) as $sPlatform)
+            if($sPlatform !== '')
+                $aLabels[] = $this->getLabel('platform', $sPlatform);
+
+        return implode(', ', $aLabels);
+    }
+
+    /**
+     * A human name for a stored dimension value.
+     */
+    public function getLabel($sDimension, $sValue)
+    {
+        if($sValue === '')
+            return _t($sDimension == 'referrer_host' || $sDimension == 'referrer' ? '_bx_analytics_txt_direct' : '_bx_analytics_txt_unknown');
+
+        switch($sDimension) {
+            case 'platform':
+                $aPlatforms = $this->_oConfig->getPlatforms();
+                return isset($aPlatforms[$sValue]) ? _t($aPlatforms[$sValue]) : $sValue;
+
+            case 'channel':
+                return _t('_bx_analytics_channel_' . $sValue);
+
+            case 'device':
+                return _t('_bx_analytics_device_' . $sValue);
+
+            case 'country':
+                if($this->_aCountries === null)
+                    $this->_aCountries = BxDolFormQuery::getDataItems('Country');
+                return isset($this->_aCountries[$sValue]) ? $this->_aCountries[$sValue] : $sValue;
+        }
+
+        return $sValue;
+    }
+
+    /**
+     * Report metrics from summed session columns.
+     */
+    protected function _getKpis($aRow)
+    {
+        $iVisitors = isset($aRow['visitors']) ? (int)$aRow['visitors'] : 0;
+        $iSessions = isset($aRow['sessions']) ? (int)$aRow['sessions'] : 0;
+        $iPageviews = isset($aRow['pageviews']) ? (int)$aRow['pageviews'] : 0;
+        $iBounces = isset($aRow['bounces']) ? (int)$aRow['bounces'] : 0;
+        $iDuration = isset($aRow['duration']) ? (int)$aRow['duration'] : 0;
+
+        return [
+            'visitors' => $iVisitors,
+            'sessions' => $iSessions,
+            'pageviews' => $iPageviews,
+            'pages_per_session' => $iSessions ? round($iPageviews / $iSessions, 2) : 0,
+            'bounce_rate' => $iSessions ? round(100 * $iBounces / $iSessions, 1) : 0,
+            'duration' => $iSessions ? (int)round($iDuration / $iSessions) : 0,
+        ];
+    }
+
+    /**
+     * The tab a report shows: `tab`, or `tab_<name>` from the page URL, or the first one.
+     */
+    protected function _getTab($aParams, $sName, $aTabs)
+    {
+        $sTab = $this->_param($aParams, 'tab', $this->_param($aParams, 'tab_' . $sName, ''));
+
+        return isset($aTabs[$sTab]) ? $sTab : key($aTabs);
+    }
+
+    protected function _getTabs($aTabs, $sActive, $sKeyPrefix = '_bx_analytics_tab_')
+    {
+        $aResult = [];
+        foreach(array_keys($aTabs) as $sName)
+            $aResult[] = ['name' => $sName, 'title' => _t($sKeyPrefix . $sName), 'active' => $sName == $sActive];
+
+        return $aResult;
+    }
+
+    /**
+     * A scalar request parameter, or the default.
+     */
+    protected function _param($aParams, $sKey, $mixedDefault = '')
+    {
+        return isset($aParams[$sKey]) && is_scalar($aParams[$sKey]) ? $aParams[$sKey] : $mixedDefault;
+    }
+
+    /**
+     * Change in percent, or null when there is nothing to compare with.
+     */
+    protected function _change($fCurrent, $fPrevious)
+    {
+        if(!$fPrevious)
+            return null;
+
+        return round(100 * ($fCurrent - $fPrevious) / $fPrevious, 1);
+    }
+}
+
+/** @} */

+ 436 - 0
modules/boonex/analytics/classes/BxAnalyticsRequest.php

@@ -0,0 +1,436 @@
+<?php defined('BX_DOL') or die('hack attempt');
+/**
+ * Copyright (c) UNA, Inc - https://una.io
+ * MIT License - https://opensource.org/licenses/MIT
+ *
+ * @defgroup    Analytics Analytics
+ * @ingroup     UnaModules
+ *
+ * @{
+ */
+
+/**
+ * What the HTTP request of a hit tells about the visitor: address, country, privacy signals.
+ * Reads a $_SERVER-like array, so tests can pass their own.
+ */
+class BxAnalyticsRequest
+{
+    /**
+     * Headers that carry the client address when a proxy or CDN sits in front of the site
+     * and there is no X-Forwarded-For, checked in this order.
+     */
+    protected static $_aIpHeaders = [
+        'HTTP_CF_CONNECTING_IP',
+        'HTTP_TRUE_CLIENT_IP',
+        'HTTP_X_REAL_IP',
+    ];
+
+    /**
+     * The shared address space of carrier-grade NAT, where cloud load balancers (Railway)
+     * connect from; PHP doesn't count it as private.
+     */
+    protected static $_aInternalRanges = ['100.64.0.0/10'];
+
+    /**
+     * Location headers set by CDNs and hosting edges, per edge: country, region, city and
+     * coordinates. With no edge chosen, the first one found is read, in this order. An edge
+     * sets its own headers and passes the others through, so a client can add another CDN's.
+     */
+    protected static $_aGeoHeaders = [
+        'cloudflare' => [
+            'country' => ['HTTP_CF_IPCOUNTRY'],
+            'region' => [],
+            'city' => ['HTTP_CF_IPCITY'],
+            'coordinates' => [['HTTP_CF_IPLATITUDE', 'HTTP_CF_IPLONGITUDE']],
+        ],
+        'vercel' => [
+            'country' => ['HTTP_X_VERCEL_IP_COUNTRY'],
+            'region' => ['HTTP_X_VERCEL_IP_COUNTRY_REGION'],
+            'city' => ['HTTP_X_VERCEL_IP_CITY'],
+            'coordinates' => [['HTTP_X_VERCEL_IP_LATITUDE', 'HTTP_X_VERCEL_IP_LONGITUDE']],
+        ],
+        'cloudfront' => [
+            'country' => ['HTTP_CLOUDFRONT_VIEWER_COUNTRY'],
+            'region' => ['HTTP_CLOUDFRONT_VIEWER_COUNTRY_REGION'],
+            'city' => ['HTTP_CLOUDFRONT_VIEWER_CITY'],
+            'coordinates' => [['HTTP_CLOUDFRONT_VIEWER_LATITUDE', 'HTTP_CLOUDFRONT_VIEWER_LONGITUDE']],
+        ],
+        '' => [
+            'country' => ['HTTP_CF_IPCOUNTRY', 'HTTP_X_VERCEL_IP_COUNTRY', 'HTTP_CLOUDFRONT_VIEWER_COUNTRY', 'HTTP_X_COUNTRY_CODE', 'HTTP_X_APPENGINE_COUNTRY'],
+            'region' => ['HTTP_X_VERCEL_IP_COUNTRY_REGION'],
+            'city' => ['HTTP_X_VERCEL_IP_CITY', 'HTTP_CF_IPCITY'],
+            'coordinates' => [['HTTP_X_VERCEL_IP_LATITUDE', 'HTTP_X_VERCEL_IP_LONGITUDE'], ['HTTP_CF_IPLATITUDE', 'HTTP_CF_IPLONGITUDE']],
+        ],
+    ];
+
+    /**
+     * A visitor id kept by a client (persistent identity mode), and the cookie the classic
+     * tracker keeps it in for site requests.
+     */
+    const VISITOR_ID_PATTERN = '/^[a-zA-Z0-9_-]{16,64}$/';
+    const VISITOR_ID_COOKIE = 'bx_analytics_vid';
+
+    protected $_aServer;
+
+    public function __construct($aServer = null)
+    {
+        $this->_aServer = is_array($aServer) ? $aServer : $_SERVER;
+    }
+
+    /**
+     * Client address. Proxy headers count only when the request comes from a trusted proxy:
+     * a private, loopback or shared (100.64.0.0/10) address (load balancers, the NEO server in
+     * the same network), an address from the trusted list, or a request the caller has
+     * already authenticated (the NEO server's API key).
+     *
+     * X-Forwarded-For is read from the right, as core's visitor address is: each proxy
+     * appends the address it got the request from, so trusted proxies are skipped and the
+     * first address that isn't one is the client. Whatever is left of it came from the
+     * client, which could otherwise pick any address for each request: past the rate limits
+     * and the excluded addresses. CF-Connecting-IP, True-Client-IP and X-Real-IP count only
+     * without X-Forwarded-For, since a proxy that doesn't set them passes the client's own.
+     *
+     * @param array $aTrustedProxies addresses and CIDR ranges whose proxy headers are trusted
+     * @param bool $bTrustedRequest the request carries a valid API key
+     * @return string an IP address, or '' when there is none
+     */
+    public function getIp($aTrustedProxies = [], $bTrustedRequest = false)
+    {
+        $sRemote = $this->_valid($this->_get('REMOTE_ADDR'));
+        if($sRemote === '' && !$bTrustedRequest)
+            return '';
+
+        if(!$bTrustedRequest && !self::isTrustedProxy($sRemote, $aTrustedProxies))
+            return $sRemote;
+
+        $sForwarded = $this->_get('HTTP_X_FORWARDED_FOR');
+        if($sForwarded !== '') {
+            $sIp = $sRemote;
+            foreach(array_reverse(explode(',', $sForwarded)) as $sCandidate) {
+                if(trim($sCandidate) === '')
+                    continue;
+
+                // a hop that didn't know the address ("unknown"): nothing left of it is reliable
+                $sCandidate = $this->_valid($sCandidate);
+                if($sCandidate === '')
+                    break;
+
+                $sIp = $sCandidate;
+                if(!self::isTrustedProxy($sCandidate, $aTrustedProxies))
+                    break;
+            }
+
+            return $sIp;
+        }
+
+        foreach(self::$_aIpHeaders as $sHeader) {
+            $sIp = $this->_valid($this->_get($sHeader));
+            if($sIp !== '')
+                return $sIp;
+        }
+
+        return $sRemote;
+    }
+
+    /**
+     * Whether the proxy headers of a request from an address are trusted: a private,
+     * loopback or shared address, or one in the trusted list.
+     */
+    public static function isTrustedProxy($sIp, $aTrustedProxies = [])
+    {
+        return self::isPrivate($sIp) || self::inList($sIp, self::$_aInternalRanges) || self::inList($sIp, $aTrustedProxies);
+    }
+
+    /**
+     * The address the request came from, before any proxy header is read, or ''.
+     */
+    public function getRemoteAddr()
+    {
+        return $this->_valid($this->_get('REMOTE_ADDR'));
+    }
+
+    /**
+     * ISO 3166-1 alpha-2 country from CDN headers, or ''. "XX" and "T1" (Tor) mean unknown.
+     *
+     * @param string $sEdge the CDN whose headers are read (cloudflare, vercel, cloudfront), '' for any
+     */
+    public function getCountry($sEdge = '')
+    {
+        $aCountry = $this->getCountryHeader($sEdge);
+
+        return $aCountry ? $aCountry['country'] : '';
+    }
+
+    /**
+     * The CDN header the country comes from and its value, or null when there is none.
+     *
+     * @param string $sEdge as for getCountry()
+     * @return array|null header (as in $_SERVER) and country
+     */
+    public function getCountryHeader($sEdge = '')
+    {
+        foreach(self::_getGeoHeaders($sEdge, 'country') as $sHeader) {
+            $sValue = mb_strtoupper(trim($this->_get($sHeader)));
+            if(preg_match('/^[A-Z]{2}$/', $sValue) && !in_array($sValue, ['XX', 'T1', 'ZZ'], true))
+                return ['header' => $sHeader, 'country' => $sValue];
+        }
+
+        return null;
+    }
+
+    /**
+     * Region and city where the edge provides them, URL-decoded, or ''.
+     *
+     * @param string $sEdge as for getCountry()
+     */
+    public function getRegion($sEdge = '')
+    {
+        return $this->_clean(rawurldecode($this->_getFirst(self::_getGeoHeaders($sEdge, 'region'))), 64);
+    }
+
+    public function getCity($sEdge = '')
+    {
+        return $this->_clean(rawurldecode($this->_getFirst(self::_getGeoHeaders($sEdge, 'city'))), 64);
+    }
+
+    /**
+     * Visitor coordinates from the edge (Vercel, CloudFront, or Cloudflare's visitor location
+     * headers), rounded to one decimal (about 10 km), or null.
+     *
+     * @param string $sEdge as for getCountry()
+     * @return array|null [lat, lng]
+     */
+    public function getCoordinates($sEdge = '')
+    {
+        foreach(self::_getGeoHeaders($sEdge, 'coordinates') as list($sLat, $sLng)) {
+            $sLatValue = $this->_get($sLat);
+            $sLngValue = $this->_get($sLng);
+            if(!is_numeric($sLatValue) || !is_numeric($sLngValue))
+                continue;
+
+            $fLat = (float)$sLatValue;
+            $fLng = (float)$sLngValue;
+            if($fLat < -90 || $fLat > 90 || $fLng < -180 || $fLng > 180)
+                continue;
+
+            return [round($fLat, 1), round($fLng, 1)];
+        }
+
+        return null;
+    }
+
+    /**
+     * The client of the current request, for actions recorded on the server.
+     * - no request (cron, background jobs, command line): core
+     * - a page or form of the classic UI: core-web
+     * - NEO says which client it is in X-Neo-Platform (web, ios, android)
+     * - otherwise the NEO web server is the request with the API key, and the native apps
+     *   are told apart by their HTTP stack (CFNetwork on iOS, okhttp on Android)
+     * - any other API client: api
+     *
+     * @param bool $bApi the request came through api.php
+     * @param bool $bApiKey it carries an API key api.php accepted
+     */
+    public function getPlatform($bApi, $bApiKey = false)
+    {
+        // cron and the command line have no request method
+        if($this->_get('REQUEST_METHOD') === '')
+            return BX_ANALYTICS_PLATFORM_CORE;
+
+        if(!$bApi)
+            return BX_ANALYTICS_PLATFORM_CORE_WEB;
+
+        $aNeo = [
+            'web' => BX_ANALYTICS_PLATFORM_NEXT_WEB,
+            'ios' => BX_ANALYTICS_PLATFORM_RN_IOS,
+            'android' => BX_ANALYTICS_PLATFORM_RN_ANDROID,
+        ];
+        $sNeo = mb_strtolower($this->_get('HTTP_X_NEO_PLATFORM'));
+        if(isset($aNeo[$sNeo]))
+            return $aNeo[$sNeo];
+
+        if($bApiKey)
+            return BX_ANALYTICS_PLATFORM_NEXT_WEB;
+
+        $sUserAgent = $this->_get('HTTP_USER_AGENT');
+        if($this->_get('HTTP_ORIGIN') !== '') {
+            if(preg_match('/CFNetwork|Darwin/i', $sUserAgent))
+                return BX_ANALYTICS_PLATFORM_RN_IOS;
+            if(preg_match('/okhttp|Dalvik/i', $sUserAgent))
+                return BX_ANALYTICS_PLATFORM_RN_ANDROID;
+        }
+
+        return BX_ANALYTICS_PLATFORM_API;
+    }
+
+    /**
+     * The visitor id the client keeps (persistent identity mode), on a site request rather
+     * than in a tracker payload: the classic tracker's `bx_analytics_vid` cookie, or NEO's
+     * X-Analytics-Vid header. '' when there is none or it isn't one.
+     */
+    public function getVisitorId()
+    {
+        $sId = $this->_get('HTTP_X_ANALYTICS_VID');
+        if($sId === '')
+            $sId = $this->_getCookie(self::VISITOR_ID_COOKIE);
+
+        return self::isVisitorId($sId) ? $sId : '';
+    }
+
+    /**
+     * Whether a string has the form of a client's visitor id.
+     */
+    public static function isVisitorId($s)
+    {
+        return is_string($s) && preg_match(self::VISITOR_ID_PATTERN, $s) === 1;
+    }
+
+    public function getUserAgent()
+    {
+        return $this->_clean($this->_get('HTTP_USER_AGENT'), 512);
+    }
+
+    public function getLanguage()
+    {
+        $sValue = $this->_get('HTTP_ACCEPT_LANGUAGE');
+        if(!preg_match('/^\s*([a-zA-Z]{2,3}(?:-[a-zA-Z0-9]{2,4})?)/', $sValue, $aMatch))
+            return '';
+
+        return $aMatch[1];
+    }
+
+    /**
+     * The browser asks not to be tracked: Do Not Track or Global Privacy Control.
+     */
+    public function isDoNotTrack()
+    {
+        return $this->_get('HTTP_DNT') === '1' || $this->_get('HTTP_SEC_GPC') === '1';
+    }
+
+    /**
+     * The request comes from another site's page, which a same-origin tracker never does.
+     * Browsers send Sec-Fetch-Site with fetch and sendBeacon; old ones send nothing, which passes.
+     */
+    public function isCrossSite()
+    {
+        $sSite = mb_strtolower($this->_get('HTTP_SEC_FETCH_SITE'));
+        return $sSite === 'cross-site';
+    }
+
+    public static function isPrivate($sIp)
+    {
+        if(!filter_var($sIp, FILTER_VALIDATE_IP))
+            return false;
+
+        return filter_var($sIp, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) === false;
+    }
+
+    /**
+     * Whether an address matches a list of addresses and CIDR ranges (IPv4 and IPv6).
+     */
+    public static function inList($sIp, $aList)
+    {
+        $sIpBin = @inet_pton($sIp);
+        if($sIpBin === false)
+            return false;
+
+        foreach($aList as $sEntry) {
+            $sEntry = trim($sEntry);
+            if($sEntry === '')
+                continue;
+
+            $iBits = null;
+            if(bx_mb_strpos($sEntry, '/') !== false)
+                list($sEntry, $iBits) = explode('/', $sEntry, 2);
+
+            $sNetBin = @inet_pton($sEntry);
+            if($sNetBin === false || strlen($sNetBin) != strlen($sIpBin))
+                continue;
+
+            if($iBits === null || $iBits === '') {
+                if($sNetBin === $sIpBin)
+                    return true;
+                continue;
+            }
+
+            $iBits = (int)$iBits;
+            $iBytes = intdiv($iBits, 8);
+            if(substr($sNetBin, 0, $iBytes) !== substr($sIpBin, 0, $iBytes))
+                continue;
+
+            $iRest = $iBits % 8;
+            if($iRest == 0)
+                return true;
+
+            $iMask = (0xFF << (8 - $iRest)) & 0xFF;
+            if((ord($sNetBin[$iBytes]) & $iMask) === (ord($sIpBin[$iBytes]) & $iMask))
+                return true;
+        }
+
+        return false;
+    }
+
+    protected function _get($sKey)
+    {
+        return isset($this->_aServer[$sKey]) && is_string($this->_aServer[$sKey]) ? trim($this->_aServer[$sKey]) : '';
+    }
+
+    /**
+     * A cookie's value from the request's Cookie header, or ''.
+     */
+    protected function _getCookie($sName)
+    {
+        foreach(explode(';', $this->_get('HTTP_COOKIE')) as $sPair) {
+            $aPair = explode('=', $sPair, 2);
+            if(count($aPair) == 2 && trim($aPair[0]) === $sName)
+                return trim(rawurldecode($aPair[1]));
+        }
+
+        return '';
+    }
+
+    /**
+     * The first of some headers that isn't empty, or ''.
+     */
+    protected function _getFirst($aKeys)
+    {
+        foreach($aKeys as $sKey)
+            if(($sValue = $this->_get($sKey)) !== '')
+                return $sValue;
+
+        return '';
+    }
+
+    /**
+     * Location headers of an edge ('' for any) of a kind: country, region, city, coordinates.
+     * An unknown edge has none.
+     */
+    protected static function _getGeoHeaders($sEdge, $sKind)
+    {
+        $sEdge = (string)$sEdge;
+
+        return isset(self::$_aGeoHeaders[$sEdge][$sKind]) ? self::$_aGeoHeaders[$sEdge][$sKind] : [];
+    }
+
+    protected function _valid($sIp)
+    {
+        $sIp = trim((string)$sIp);
+
+        // "1.2.3.4:5678" and "[::1]:5678" from some proxies
+        if(preg_match('/^\[([0-9a-f:.]+)\](?::\d+)?$/i', $sIp, $aMatch))
+            $sIp = $aMatch[1];
+        elseif(preg_match('/^(\d{1,3}(?:\.\d{1,3}){3}):\d+$/', $sIp, $aMatch))
+            $sIp = $aMatch[1];
+
+        return filter_var($sIp, FILTER_VALIDATE_IP) ? $sIp : '';
+    }
+
+    protected function _clean($s, $iMaxLength)
+    {
+        $s = trim(preg_replace('/[\x00-\x1F\x7F]+/u', ' ', (string)$s));
+
+        return get_mb_substr($s, 0, $iMaxLength);
+    }
+}
+
+/** @} */

+ 1107 - 0
modules/boonex/analytics/classes/BxAnalyticsRevenue.php

@@ -0,0 +1,1107 @@
+<?php defined('BX_DOL') or die('hack attempt');
+/**
+ * Copyright (c) UNA, Inc - https://una.io
+ * MIT License - https://opensource.org/licenses/MIT
+ *
+ * @defgroup    Analytics Analytics
+ * @ingroup     UnaModules
+ *
+ * @{
+ */
+
+/**
+ * The revenue ledger. Listens to the alerts every payment module fires on the `system` unit
+ * and writes one row per money movement: a sale, a renewal, a refund or a reversal of a
+ * processed order. Amounts are what the payment module records, also converted to the site's
+ * default currency with the rate the order was placed at. Subscriptions are kept beside it,
+ * with their price per month (MRR).
+ *
+ * A subscription with a trial is registered by the payment module when the trial starts, with
+ * transactions at the full price although nothing was charged. Those rows are kept with the
+ * kind `trial` (amounts for reference only): no revenue sum, count or chart reads them, they
+ * are never refunded or reversed, and they make a `trial_start` action with no value instead
+ * of a purchase. The first charge after the trial is the order's sale, the later ones renewals.
+ *
+ * A movement the buyer's own request makes carries the buyer's platform, visit and traffic
+ * sources; a webhook or a cron job is the platform `core`. Money is always written; the visit
+ * and the sources only when the request could be tracked.
+ *
+ * Past payments are read in steps through the payment module's paged services (backfill).
+ */
+class BxAnalyticsRevenue
+{
+    const KIND_SALE = 'sale';
+    const KIND_RENEWAL = 'renewal';
+    const KIND_REFUND = 'refund';
+    const KIND_REVERSAL = 'reversal';
+    const KIND_TRIAL = 'trial';
+
+    const TYPE_SINGLE = 'single';
+    const TYPE_RECURRING = 'recurring';
+
+    const STATUS_TRIAL = 'trial';
+    const STATUS_ACTIVE = 'active';
+    const STATUS_UNPAID = 'unpaid';
+    const STATUS_CANCELED = 'canceled';
+
+    const PROVIDER_CREDITS = 'credits';
+    const PROVIDER_APPLE = 'apple_in_app';
+
+    /**
+     * A payment registered further than this from now is history the alert brought along
+     * (renewals of an order recorded before analytics was installed), not something the
+     * current request did.
+     */
+    const LIVE_WINDOW = 86400;
+
+    /**
+     * How long an unpaid subscription still counts as paying after its paid period ends: the
+     * payment module retries a failed charge for a few days and never cancels it by itself.
+     */
+    const UNPAID_GRACE = 604800;
+
+    /**
+     * Subscription period units per month, for MRR.
+     */
+    protected static $_aMonthFactors = [
+        'year' => 1 / 12,
+        'month' => 1,
+        'week' => 52 / 12,
+        'day' => 365.25 / 12,
+        'hour' => 730.5,
+        'minute' => 43830,
+    ];
+
+    /**
+     * Subscription period units in seconds.
+     */
+    protected static $_aUnitSeconds = [
+        'year' => 31557600,
+        'month' => 2629800,
+        'week' => 604800,
+        'day' => 86400,
+        'hour' => 3600,
+        'minute' => 60,
+    ];
+
+    protected $_oModule;
+    protected $_oConfig;
+    protected $_oDb;
+    protected $_oRequest;
+    protected $_iNow;
+
+    /**
+     * What the request knows that tests set instead: `api` (the request came through
+     * api.php), `api_key` (with an accepted API key), `logged` (the signed-in profile).
+     */
+    protected $_aOptions;
+
+    protected $_oCollector = null;
+    protected $_sDefaultCurrency = null;
+    protected $_aModuleNames = [];
+    protected $_aSources = [];
+
+    public function __construct($oModule, $oRequest = null, $iNow = null, $aOptions = [])
+    {
+        $this->_oModule = $oModule;
+        $this->_oConfig = $oModule->_oConfig;
+        $this->_oDb = $oModule->_oDb;
+        $this->_oRequest = $oRequest ? $oRequest : new BxAnalyticsRequest();
+        $this->_iNow = $iNow ? (int)$iNow : time();
+        $this->_aOptions = is_array($aOptions) ? $aOptions : [];
+    }
+
+    /*
+     * Pure helpers
+     */
+
+    /**
+     * How many of a period unit make a month, or null for a unit the payment module doesn't use.
+     */
+    public static function getMonthFactor($sUnit)
+    {
+        return isset(self::$_aMonthFactors[$sUnit]) ? self::$_aMonthFactors[$sUnit] : null;
+    }
+
+    /**
+     * Monthly recurring revenue of a price charged every `$iPeriod` units, or null when the
+     * price or the unit is unknown.
+     */
+    public static function getMrr($fAmount, $iPeriod, $sUnit)
+    {
+        $fFactor = self::getMonthFactor($sUnit);
+        if($fAmount === null || $fFactor === null)
+            return null;
+
+        return round((float)$fAmount * $fFactor / max(1, (int)$iPeriod), 4);
+    }
+
+    /**
+     * Seconds per subscription period unit: [unit => seconds].
+     */
+    public static function getUnitSeconds()
+    {
+        return self::$_aUnitSeconds;
+    }
+
+    /**
+     * Length of a subscription period in seconds, or null for a unit the payment module doesn't use.
+     */
+    public static function getPeriodSeconds($iPeriod, $sUnit)
+    {
+        return isset(self::$_aUnitSeconds[$sUnit]) ? max(1, (int)$iPeriod) * self::$_aUnitSeconds[$sUnit] : null;
+    }
+
+    /**
+     * A charge of a subscription with a trial is its registration when it came in the first
+     * half of the trial: the payment module registers the order when the trial starts, and
+     * the first real charge comes when it ends.
+     *
+     * @param array $aSubscription ledger subscription row: started, trial (days)
+     */
+    public static function isTrialRegistration($aSubscription, $iTs)
+    {
+        $iTrial = !empty($aSubscription['trial']) ? (int)$aSubscription['trial'] : 0;
+
+        return $iTrial > 0 && (int)$iTs < self::getTrialPaidFrom($aSubscription);
+    }
+
+    /**
+     * The time from which charges of a subscription are paid ones, see isTrialRegistration().
+     */
+    public static function getTrialPaidFrom($aSubscription)
+    {
+        $iTrial = !empty($aSubscription['trial']) ? (int)$aSubscription['trial'] : 0;
+
+        return $iTrial > 0 ? (int)$aSubscription['started'] + $iTrial * 43200 : 0;
+    }
+
+    /**
+     * The rate of an order's currency against the default currency, from the snapshot the
+     * payment module stores with the order (`data`: serialized cur_def and cur_rate): 1 for
+     * the default currency (today's, whatever it was when the order was placed), the
+     * snapshot's rate, or null when the snapshot doesn't tell (no rate, or the site's default
+     * currency has changed since).
+     */
+    public static function getSnapshotRate($sCurrency, $sData, $sDefault)
+    {
+        if($sCurrency === '' || $sCurrency === $sDefault)
+            return 1.0;
+
+        $aData = is_string($sData) && $sData !== '' ? @unserialize($sData, ['allowed_classes' => false]) : [];
+        if(!is_array($aData))
+            $aData = [];
+
+        $sSnapshotDefault = !empty($aData['cur_def']) && is_string($aData['cur_def']) ? $aData['cur_def'] : $sDefault;
+        if($sSnapshotDefault !== $sDefault)
+            return null;
+
+        if(!empty($aData['cur_rate']) && is_numeric($aData['cur_rate']) && (float)$aData['cur_rate'] > 0)
+            return (float)$aData['cur_rate'];
+
+        return null;
+    }
+
+    /**
+     * An amount in the default currency, or null without a rate. Rates are quoted against the
+     * default currency, so the order's amount is divided by its rate.
+     */
+    public static function toDefault($fAmount, $mixedRate)
+    {
+        if($mixedRate === null || (float)$mixedRate <= 0)
+            return null;
+
+        return round((float)$fAmount / (float)$mixedRate, 4);
+    }
+
+    /**
+     * A charge of a recurring order is its sale when it is the order's first charge and a
+     * renewal otherwise; every transaction of a single order is a sale. A charge is the
+     * transactions one registration wrote (one per item, add-ons included): they share their time.
+     *
+     * @param int $iChargeTs time of the transaction
+     * @param int $iFirstTs time of the order's first charge
+     */
+    public static function getKind($sType, $iChargeTs, $iFirstTs)
+    {
+        return $sType == self::TYPE_RECURRING && (int)$iChargeTs != (int)$iFirstTs ? self::KIND_RENEWAL : self::KIND_SALE;
+    }
+
+    /**
+     * The kind of a charge, trials included: a charge of a recurring order that registered its
+     * trial (see isTrialRegistration()) is a `trial`, any other follows getKind() with the
+     * order's first paid charge.
+     *
+     * @param array $aSubscription ledger subscription row (started, trial), [] for none
+     * @param int $iFirstPaidTs time of the order's first charge that is not a trial registration
+     */
+    public static function getChargeKind($sType, $aSubscription, $iChargeTs, $iFirstPaidTs)
+    {
+        if($sType == self::TYPE_RECURRING && self::isTrialRegistration($aSubscription, $iChargeTs))
+            return self::KIND_TRIAL;
+
+        return self::getKind($sType, $iChargeTs, $iFirstPaidTs);
+    }
+
+    /**
+     * The action a charge of the current request records: a purchase, a renewal, or the start
+     * of a trial (with no value, it took no money).
+     */
+    public static function getActionName($sKind)
+    {
+        switch($sKind) {
+            case self::KIND_RENEWAL:
+                return 'renewal';
+            case self::KIND_TRIAL:
+                return 'trial_start';
+        }
+
+        return 'purchase';
+    }
+
+    /**
+     * Kinds of an order's charges, from its ledger rows (sale, renewal and trial rows; refund and
+     * reversal rows are read to leave the charges they negate as they are) and its subscription:
+     * [row id => kind] for the rows whose kind differs. Used when the subscription becomes known
+     * after its charges were read (the import reads transactions before subscriptions).
+     */
+    public static function getKindChanges($sType, $aSubscription, $aRows)
+    {
+        $aNegated = [];
+        $iFirst = 0;
+        foreach($aRows as $aRow) {
+            if(in_array($aRow['kind'], [self::KIND_REFUND, self::KIND_REVERSAL], true))
+                $aNegated[(int)$aRow['transaction_id']] = true;
+            else if($sType != self::TYPE_RECURRING || !self::isTrialRegistration($aSubscription, (int)$aRow['ts']))
+                $iFirst = $iFirst ? min($iFirst, (int)$aRow['ts']) : (int)$aRow['ts'];
+        }
+
+        $aChanges = [];
+        foreach($aRows as $aRow) {
+            if(!in_array($aRow['kind'], [self::KIND_SALE, self::KIND_RENEWAL, self::KIND_TRIAL], true) || isset($aNegated[(int)$aRow['transaction_id']]))
+                continue;
+
+            $sKind = self::getChargeKind($sType, $aSubscription, (int)$aRow['ts'], $iFirst);
+            if($sKind != $aRow['kind'])
+                $aChanges[(int)$aRow['id']] = $sKind;
+        }
+
+        return $aChanges;
+    }
+
+    /**
+     * Module and item of an order's first item, from the payment module's item descriptors
+     * (vendor_module_item_count, joined with ":").
+     *
+     * @return array [module id, item id]
+     */
+    public static function getFirstItem($sItems)
+    {
+        $aItems = explode(':', (string)$sItems);
+        $aParts = explode('_', (string)reset($aItems));
+
+        return [isset($aParts[1]) ? (int)$aParts[1] : 0, isset($aParts[2]) ? (int)$aParts[2] : 0];
+    }
+
+    /*
+     * Alerts
+     */
+
+    /**
+     * A payment alert on the `system` unit: extras carry the payment module's name.
+     */
+    public function onPaymentAlert($oAlert)
+    {
+        $aExtras = is_array($oAlert->aExtras) ? $oAlert->aExtras : [];
+        $sPayment = isset($aExtras['module']) && is_string($aExtras['module']) && preg_match('/^[a-z0-9_]{1,32}$/', $aExtras['module']) ? $aExtras['module'] : '';
+        if($sPayment === '')
+            return false;
+
+        $aPending = isset($aExtras['pending']) && is_array($aExtras['pending']) ? $aExtras['pending'] : [];
+        $aSubscription = isset($aExtras['subscription']) && is_array($aExtras['subscription']) ? $aExtras['subscription'] : [];
+
+        switch($oAlert->sAction) {
+            case 'register_payment':
+                return $this->registerPayment($sPayment, $aPending);
+
+            case 'refund_payment':
+                return $this->refundPayment($sPayment, $aPending);
+
+            case 'create_subscription':
+                return $this->createSubscription($sPayment, $aPending, $aSubscription);
+
+            case 'prolong_subscription':
+                return $this->setSubscriptionStatus($sPayment, $aPending, self::STATUS_ACTIVE);
+
+            case 'overdue_subscription':
+                return $this->setSubscriptionStatus($sPayment, $aPending, self::STATUS_UNPAID);
+
+            case 'cancel_subscription':
+                return $this->cancelSubscription($sPayment, $aPending, $aSubscription, isset($aExtras['reason']) && is_string($aExtras['reason']) ? $aExtras['reason'] : '');
+
+            case 'cancel_order':
+                // a pending order never took money
+                if(isset($aExtras['type']) && $aExtras['type'] == 'processed' && isset($aExtras['order']) && is_array($aExtras['order']))
+                    return $this->reverseTransaction($sPayment, $aExtras['order']);
+                return false;
+
+            case 'finalize_checkout':
+                return $this->finalizeCheckout($sPayment, $aPending);
+        }
+
+        return false;
+    }
+
+    /**
+     * An order was paid, a subscription charged or its trial registered: every transaction of
+     * the order the ledger doesn't have yet. A transaction the current request made gets its
+     * context and becomes a purchase, renewal or trial_start event, so goals and funnels can
+     * end in a sale or a trial.
+     *
+     * @return int movements added
+     */
+    public function registerPayment($sPayment, $aPending)
+    {
+        $iPendingId = isset($aPending['id']) ? (int)$aPending['id'] : 0;
+        if(!$iPendingId)
+            return 0;
+
+        // paid before joining: the order gets its client when the account is created
+        if(empty($aPending['client_id']) || !isset($aPending['type'])) {
+            $aFresh = $this->_getPendingOrder($sPayment, $iPendingId);
+            if(!empty($aFresh))
+                $aPending = $aFresh;
+        }
+
+        $aTransactions = $this->_getTransactions($sPayment, $iPendingId);
+        if(empty($aTransactions))
+            return 0;
+
+        $aPending['client_id'] = isset($aPending['client_id']) ? (int)$aPending['client_id'] : 0;
+
+        $sType = isset($aPending['type']) ? $aPending['type'] : self::TYPE_SINGLE;
+        $fRate = $this->getRate(isset($aPending['currency']) ? (string)$aPending['currency'] : '', isset($aPending['data']) ? $aPending['data'] : '');
+
+        // a renewal brings the whole order along: skip the charges the ledger has
+        $aRows = $this->_oDb->getRevenueRows($sPayment, $iPendingId, [self::KIND_SALE, self::KIND_RENEWAL, self::KIND_TRIAL]);
+        $aKnown = array_flip(array_map('intval', array_column($aRows, 'transaction_id')));
+
+        $aNew = [];
+        foreach($aTransactions as $aTransaction)
+            if(!isset($aKnown[(int)$aTransaction['id']]))
+                $aNew[] = $aTransaction;
+        if(empty($aNew))
+            return 0;
+
+        // the subscription tells a trial's registration from a charge: its row is made first
+        // when the subscription began before analytics was installed. A subscription's first
+        // sale a webhook registers keeps the platform of the buyer's checkout.
+        $aSubscription = [];
+        if($sType == self::TYPE_RECURRING && $this->_ensureSubscription($sPayment, $aPending))
+            $aSubscription = $this->_oDb->getSubscriptionRow($sPayment, $iPendingId);
+        $sCheckoutPlatform = !empty($aSubscription['platform']) ? (string)$aSubscription['platform'] : '';
+
+        // the order's first paid charge is the earliest one either side knows (the payment
+        // module deletes a transaction its seller cancels, the ledger keeps it); the
+        // registration of a trial is not one
+        $iFirst = 0;
+        foreach($aRows as $aRow)
+            if($aRow['kind'] != self::KIND_TRIAL)
+                $iFirst = $iFirst ? min($iFirst, (int)$aRow['ts']) : (int)$aRow['ts'];
+        foreach($aNew as $aTransaction) {
+            $iTs = !empty($aTransaction['date']) ? (int)$aTransaction['date'] : $this->_iNow;
+            if(self::getChargeKind($sType, $aSubscription, $iTs, $iTs) != self::KIND_TRIAL)
+                $iFirst = $iFirst ? min($iFirst, $iTs) : $iTs;
+        }
+
+        $iAdded = 0;
+        $aLive = [];
+        $aPaid = [];
+        foreach($aNew as $aTransaction) {
+            $iTs = !empty($aTransaction['date']) ? (int)$aTransaction['date'] : $this->_iNow;
+            $sKind = self::getChargeKind($sType, $aSubscription, $iTs, $iFirst);
+            $bLive = abs($this->_iNow - $iTs) <= self::LIVE_WINDOW;
+
+            // a trial starts at the checkout, like a sale
+            $sContextKind = $sKind == self::KIND_TRIAL ? self::KIND_SALE : $sKind;
+            $aContext = $bLive ? $this->getContext((int)$aPending['client_id'], isset($aPending['provider']) ? $aPending['provider'] : '', $sContextKind, $iTs, $sContextKind == self::KIND_SALE ? $sCheckoutPlatform : '') : $this->_getImportContext((int)$aPending['client_id'], $iTs);
+            $aRow = $this->_getLedgerRow($sPayment, $aPending, $aTransaction, $sKind, $iTs, $fRate, $aContext);
+            if(!$this->_oDb->insertRevenue($aRow))
+                continue;
+
+            $iAdded++;
+            if($sKind != self::KIND_TRIAL)
+                $aPaid[$iTs] = $iTs;
+            if($bLive)
+                $aLive[] = [$aRow, $aContext];
+        }
+
+        // the registration of a trial took no money: the subscription pays from its first charge
+        if(!empty($aPaid) && $sType == self::TYPE_RECURRING)
+            $this->_oDb->addSubscriptionPayments($sPayment, $iPendingId, count($aPaid), min($aPaid), max($aPaid));
+
+        // no amounts or order numbers in the properties: the events report shows them to
+        // everyone who may use analytics, revenue or not
+        foreach($aLive as list($aRow, $aContext))
+            if($aContext['track'])
+                $this->_getCollector()->collectAction(self::getActionName($aRow['kind']), $aRow['module'], $aRow['item_id'], $aRow['client_id'], [
+                    'platform' => $aContext['platform'],
+                    'api_key' => $this->_isApiKey(),
+                    // decided by getContext: a webhook or a cron job is not the buyer's request
+                    'request' => $aContext['buyer'],
+                    'value' => $aRow['kind'] != self::KIND_TRIAL && $aRow['amount_default'] !== null ? (float)$aRow['amount_default'] : 0,
+                    'props' => [
+                        'provider' => $aRow['provider'],
+                    ],
+                ]);
+
+        return $iAdded;
+    }
+
+    /**
+     * An order was refunded. The payment module has deleted its transactions by now, so the
+     * ledger negates its own rows: every sale of a single order, and only the latest charge of
+     * a recurring one (the provider refunds one charge). A refund keeps the platform and the
+     * sources of the sale it takes back, so net revenue per source stays right. A transaction
+     * its seller already cancelled (a reversal) is not taken back again, a trial's registration
+     * took no money and is never taken back (a refund during the trial adds nothing), and an
+     * order is refunded once: the payment module deletes it.
+     *
+     * @return int movements added
+     */
+    public function refundPayment($sPayment, $aPending)
+    {
+        $iPendingId = isset($aPending['id']) ? (int)$aPending['id'] : 0;
+        if(!$iPendingId)
+            return 0;
+
+        $aNegated = [];
+        foreach($this->_oDb->getRevenueRows($sPayment, $iPendingId, [self::KIND_REFUND, self::KIND_REVERSAL]) as $aRow) {
+            if($aRow['kind'] == self::KIND_REFUND)
+                return 0;
+
+            $aNegated[(int)$aRow['transaction_id']] = true;
+        }
+
+        $aRows = [];
+        foreach($this->_oDb->getRevenueRows($sPayment, $iPendingId, [self::KIND_SALE, self::KIND_RENEWAL]) as $aRow)
+            if(!isset($aNegated[(int)$aRow['transaction_id']]))
+                $aRows[] = $aRow;
+
+        if(empty($aRows))
+            return 0;
+
+        // the latest charge: every item of it
+        if(isset($aPending['type']) && $aPending['type'] == self::TYPE_RECURRING) {
+            $iLast = max(array_map('intval', array_column($aRows, 'ts')));
+            $aRows = array_filter($aRows, function($aRow) use ($iLast) { return (int)$aRow['ts'] == $iLast; });
+        }
+
+        $iAdded = 0;
+        foreach($aRows as $aRow)
+            $iAdded += $this->_insertNegation($aRow, self::KIND_REFUND);
+
+        return $iAdded;
+    }
+
+    /**
+     * A processed order (one transaction) was cancelled by its seller: the money goes back.
+     */
+    public function reverseTransaction($sPayment, $aOrder)
+    {
+        $iTransactionId = isset($aOrder['id']) ? (int)$aOrder['id'] : 0;
+        if(!$iTransactionId)
+            return 0;
+
+        $aRow = $this->_oDb->getRevenueRow($sPayment, $iTransactionId, [self::KIND_SALE, self::KIND_RENEWAL]);
+        if(empty($aRow) || !empty($this->_oDb->getRevenueRow($sPayment, $iTransactionId, [self::KIND_REFUND, self::KIND_REVERSAL])))
+            return 0;
+
+        return $this->_insertNegation($aRow, self::KIND_REVERSAL);
+    }
+
+    /**
+     * A subscription was registered: its plan and price per month.
+     */
+    public function createSubscription($sPayment, $aPending, $aSubscription)
+    {
+        $iPendingId = isset($aPending['id']) ? (int)$aPending['id'] : 0;
+        if(!$iPendingId)
+            return false;
+
+        $aContext = $this->getContext(isset($aPending['client_id']) ? (int)$aPending['client_id'] : 0, isset($aPending['provider']) ? $aPending['provider'] : '', self::KIND_SALE, $this->_iNow);
+
+        $aRow = $this->_getSubscriptionRow($sPayment, $aPending, $aSubscription);
+        $aRow['started'] = $this->_iNow;
+        $aRow['platform'] = $aContext['platform'];
+
+        return $this->_oDb->saveSubscriptionRow($aRow);
+    }
+
+    public function setSubscriptionStatus($sPayment, $aPending, $sStatus)
+    {
+        $iPendingId = isset($aPending['id']) ? (int)$aPending['id'] : 0;
+        if(!$iPendingId || !$this->_ensureSubscription($sPayment, $aPending))
+            return false;
+
+        $aRow = $this->_oDb->getSubscriptionRow($sPayment, $iPendingId);
+        if(!empty($aRow['canceled']))
+            return false;
+
+        return $this->_oDb->updateSubscriptionRow($sPayment, $iPendingId, ['status' => $sStatus]) > 0;
+    }
+
+    /**
+     * A subscription was cancelled, with the reason the member or the provider gave.
+     */
+    public function cancelSubscription($sPayment, $aPending, $aSubscription, $sReason = '')
+    {
+        $iPendingId = isset($aPending['id']) ? (int)$aPending['id'] : 0;
+        if(!$iPendingId)
+            return false;
+
+        if(empty($this->_oDb->getSubscriptionRow($sPayment, $iPendingId))) {
+            $aRow = $this->_getSubscriptionRow($sPayment, $aPending, $aSubscription);
+            $aRow['started'] = !empty($aSubscription['date_add']) ? (int)$aSubscription['date_add'] : $this->_iNow;
+            $this->_oDb->saveSubscriptionRow($aRow);
+        }
+
+        return $this->_oDb->updateSubscriptionRow($sPayment, $iPendingId, [
+            'status' => self::STATUS_CANCELED,
+            'canceled' => $this->_iNow,
+            'reason' => get_mb_substr(trim($sReason), 0, 255),
+        ]) > 0;
+    }
+
+    /**
+     * The buyer finished the checkout in their own request: the order's rows a webhook
+     * registered first get the buyer's platform, visit and sources.
+     */
+    public function finalizeCheckout($sPayment, $aPending)
+    {
+        $iPendingId = isset($aPending['id']) ? (int)$aPending['id'] : 0;
+        if(!$iPendingId)
+            return 0;
+
+        $iClientId = isset($aPending['client_id']) ? (int)$aPending['client_id'] : 0;
+        $aContext = $this->getContext($iClientId, '', self::KIND_SALE, $this->_iNow);
+        if(!$aContext['buyer'])
+            return 0;
+
+        $aSet = ['platform' => $aContext['platform']];
+        if($aContext['link'])
+            $aSet = array_merge($aSet, ['session_id' => $aContext['session_id']], $this->_getSourceColumns($aContext));
+
+        $iUpdated = $this->_oDb->updateRevenueContext($sPayment, $iPendingId, $aSet);
+
+        $aSubscription = $this->_oDb->getSubscriptionRow($sPayment, $iPendingId);
+        if(!empty($aSubscription) && in_array($aSubscription['platform'], ['', BX_ANALYTICS_PLATFORM_CORE], true))
+            $this->_oDb->updateSubscriptionRow($sPayment, $iPendingId, ['platform' => $aContext['platform']]);
+
+        return $iUpdated;
+    }
+
+    /*
+     * Context and conversion
+     */
+
+    /**
+     * Who made a movement and how: the buyer's own request has its platform (and, when it can
+     * be tracked, the running visit); anything else (a webhook, a cron job, a seller or an
+     * administrator acting on the order) is the platform `core`. An Apple in-app sale outside
+     * the buyer's request still came from the iOS app, and so does a sale whose checkout
+     * platform is known (`$sCheckoutPlatform`, the subscription's, when its first charge
+     * comes by webhook). Traffic sources come from the buyer's first visit and the latest one
+     * before the movement.
+     *
+     * @return array platform, buyer (it is the buyer's request), track (the action may be
+     *      recorded), link (the member may be linked to visits), session_id, first and last
+     *      (channel, referrer_host, utm_campaign)
+     */
+    public function getContext($iClientId, $sProvider, $sKind, $iTs, $sCheckoutPlatform = '')
+    {
+        $iClientId = (int)$iClientId;
+        $bApiKey = $this->_isApiKey();
+
+        $sPlatform = $this->_oRequest->getPlatform($this->_isApi(), $bApiKey);
+        $bBuyer = $sPlatform != BX_ANALYTICS_PLATFORM_CORE && $iClientId && $this->_getLoggedProfileId() == $iClientId;
+        if(!$bBuyer) {
+            if($sKind == self::KIND_SALE && $sCheckoutPlatform !== '' && $sCheckoutPlatform != BX_ANALYTICS_PLATFORM_CORE)
+                $sPlatform = $sCheckoutPlatform;
+            else
+                $sPlatform = $sProvider == self::PROVIDER_APPLE && $sKind == self::KIND_SALE ? BX_ANALYTICS_PLATFORM_RN_IOS : BX_ANALYTICS_PLATFORM_CORE;
+        }
+
+        $bTrack = $this->_getCollector()->isTracked($sPlatform, $iClientId, $bApiKey, $bBuyer);
+        $bLink = $bTrack && $iClientId && $this->_oConfig->isLinkMembers();
+
+        $aContext = [
+            'platform' => $sPlatform,
+            'buyer' => $bBuyer,
+            'track' => $bTrack,
+            'link' => $bLink,
+            'session_id' => 0,
+            'first' => [],
+            'last' => [],
+        ];
+
+        if(!$bLink)
+            return $aContext;
+
+        if($bBuyer) {
+            $aSession = $this->_getCollector()->getRequestSession($sPlatform, $iClientId, $bApiKey);
+            if(!empty($aSession) && (int)$aSession['profile_id'] == $iClientId)
+                $aContext['session_id'] = (int)$aSession['id'];
+        }
+
+        return array_merge($aContext, $this->_getSources($iClientId, $iTs));
+    }
+
+    /**
+     * The rate of an order's currency against the default one: the order's own snapshot, or
+     * today's rate when the snapshot has none, or null when no rate is known.
+     */
+    public function getRate($sCurrency, $sData)
+    {
+        $sDefault = $this->getDefaultCurrency();
+        $mixedRate = self::getSnapshotRate((string)$sCurrency, $sData, $sDefault);
+        if($mixedRate !== null)
+            return $mixedRate;
+
+        if($sDefault === '' || $sCurrency === '')
+            return null;
+
+        $mixedConverted = BxDolPayments::getInstance()->convert(1, $sDefault, $sCurrency);
+
+        return is_numeric($mixedConverted) && (float)$mixedConverted > 0 ? (float)$mixedConverted : null;
+    }
+
+    public function getDefaultCurrency()
+    {
+        if($this->_sDefaultCurrency === null) {
+            $oPayments = BxDolPayments::getInstance();
+            $this->_sDefaultCurrency = $oPayments->isActive() ? (string)$oPayments->getCurrencyCode() : '';
+        }
+
+        return $this->_sDefaultCurrency;
+    }
+
+    /*
+     * Import of past payments
+     */
+
+    /**
+     * Read past transactions, then subscriptions, from the active payment module a page at a
+     * time until the deadline. The cursors are saved after every page, so the next step (the
+     * hourly cron, or the Studio button) continues where this one stopped; rows the ledger has
+     * already are skipped. Refunded orders can't be imported: the payment module deleted them.
+     *
+     * @param int $iDeadline unix time to stop by
+     * @return array the import status, see getBackfillStatus()
+     */
+    public function backfill($iDeadline)
+    {
+        $CNF = &$this->_oConfig->CNF;
+
+        $sPayment = $this->_getActivePayment();
+        if($sPayment === '' || !bx_is_srv($sPayment, 'get_transactions_after', 'Orders'))
+            return $this->getBackfillStatus();
+
+        while(time() < $iDeadline) {
+            $sCursor = (string)getParam($CNF['OPTION_BACKFILL']);
+            if($sCursor !== '' && (int)$sCursor >= 0) {
+                $aRows = bx_srv_ic($sPayment, 'get_transactions_after', [(int)$sCursor, $CNF['BACKFILL_PAGE']], 'Orders');
+                if(empty($aRows) || !is_array($aRows)) {
+                    setParam($CNF['OPTION_BACKFILL'], '-1');
+                    if((string)getParam($CNF['OPTION_BACKFILL_SBS']) === '')
+                        setParam($CNF['OPTION_BACKFILL_SBS'], 'active:0');
+                    continue;
+                }
+
+                $this->importTransactions($sPayment, $aRows);
+                $aLast = end($aRows);
+                setParam($CNF['OPTION_BACKFILL'], (string)(int)$aLast['id']);
+                continue;
+            }
+
+            $sCursor = (string)getParam($CNF['OPTION_BACKFILL_SBS']);
+            if(!preg_match('/^(active|deleted):(\d+)$/', $sCursor, $aMatch) || !bx_is_srv($sPayment, 'get_subscriptions_after', 'Subscriptions'))
+                break;
+
+            $bDeleted = $aMatch[1] == 'deleted';
+            $aRows = bx_srv_ic($sPayment, 'get_subscriptions_after', [(int)$aMatch[2], $CNF['BACKFILL_PAGE'], $bDeleted], 'Subscriptions');
+            if(empty($aRows) || !is_array($aRows)) {
+                setParam($CNF['OPTION_BACKFILL_SBS'], $bDeleted ? '-1' : 'deleted:0');
+                continue;
+            }
+
+            $this->importSubscriptions($sPayment, $aRows, $bDeleted);
+            $aLast = end($aRows);
+            setParam($CNF['OPTION_BACKFILL_SBS'], $aMatch[1] . ':' . (int)$aLast['pending_id']);
+        }
+
+        return $this->getBackfillStatus();
+    }
+
+    /**
+     * Start the import over: every past payment is read again, and the ones the ledger has
+     * are skipped.
+     */
+    public function restartBackfill()
+    {
+        setParam($this->_oConfig->CNF['OPTION_BACKFILL'], '0');
+        setParam($this->_oConfig->CNF['OPTION_BACKFILL_SBS'], '');
+    }
+
+    /**
+     * @param bool $bCount count the rows the import wrote (it reads the whole ledger)
+     * @return array state (unavailable: no payment module; running; done), imported (rows
+     *      the import wrote, 0 unless counted), cursor (the last transaction read)
+     */
+    public function getBackfillStatus($bCount = false)
+    {
+        $CNF = &$this->_oConfig->CNF;
+
+        $sPayment = $this->_getActivePayment();
+        if($sPayment === '' || !bx_is_srv($sPayment, 'get_transactions_after', 'Orders'))
+            return ['state' => 'unavailable', 'imported' => 0, 'cursor' => 0];
+
+        $sCursor = (string)getParam($CNF['OPTION_BACKFILL']);
+        $sSbs = (string)getParam($CNF['OPTION_BACKFILL_SBS']);
+        $bDone = ($sCursor === '' || (int)$sCursor < 0) && !preg_match('/^(active|deleted):\d+$/', $sSbs);
+
+        return [
+            'state' => $bDone ? 'done' : 'running',
+            'imported' => $bCount ? $this->_oDb->getRevenueImportedCount($sPayment) : 0,
+            'cursor' => max(0, (int)$sCursor),
+        ];
+    }
+
+    /**
+     * Past transactions into the ledger, with the time they were registered and no platform.
+     * A transaction the ledger has as a sale, a renewal or a trial is skipped whatever its kind,
+     * and kinds follow the same rule as live payments (see registerPayment()) with the
+     * subscriptions the ledger has. The import reads transactions before subscriptions, so the
+     * trial of a subscription the ledger doesn't have yet is applied when importSubscriptions()
+     * reads it.
+     *
+     * @return int movements added
+     */
+    public function importTransactions($sPayment, $aRows)
+    {
+        $aTypes = [];
+        foreach($aRows as $aRow)
+            $aTypes[(int)$aRow['pending_id']] = !empty($aRow['type']) ? $aRow['type'] : self::TYPE_SINGLE;
+
+        // trials of the recurring orders on this page: [pending id => started, trial]
+        $aTrials = $this->_oDb->getSubscriptionTrials($sPayment, array_keys(array_filter($aTypes, function($sType) { return $sType == self::TYPE_RECURRING; })));
+
+        $aFirst = [];
+        foreach($aRows as $aRow) {
+            $iPendingId = (int)$aRow['pending_id'];
+            $aSubscription = isset($aTrials[$iPendingId]) ? $aTrials[$iPendingId] : [];
+            if(self::getChargeKind($aTypes[$iPendingId], $aSubscription, (int)$aRow['date'], (int)$aRow['date']) != self::KIND_TRIAL)
+                $aFirst[$iPendingId] = isset($aFirst[$iPendingId]) ? min($aFirst[$iPendingId], (int)$aRow['date']) : (int)$aRow['date'];
+        }
+
+        // a renewal on this page may belong to an order whose sale the ledger already has
+        foreach($this->_oDb->getRevenueFirstCharges($sPayment, array_keys($aTypes)) as $iPendingId => $iTs)
+            $aFirst[(int)$iPendingId] = isset($aFirst[(int)$iPendingId]) ? min($aFirst[(int)$iPendingId], (int)$iTs) : (int)$iTs;
+
+        $aKnown = array_flip($this->_oDb->getRevenueCharged($sPayment, array_column($aRows, 'id')));
+
+        $iAdded = 0;
+        foreach($aRows as $aRow) {
+            if(isset($aKnown[(int)$aRow['id']]))
+                continue;
+
+            $aPending = [
+                'id' => (int)$aRow['pending_id'],
+                'client_id' => (int)$aRow['client_id'],
+                'type' => !empty($aRow['type']) ? $aRow['type'] : self::TYPE_SINGLE,
+                'provider' => isset($aRow['provider']) ? (string)$aRow['provider'] : '',
+                'order' => isset($aRow['order']) ? (string)$aRow['order'] : '',
+                'currency' => !empty($aRow['pending_currency']) ? $aRow['pending_currency'] : (isset($aRow['currency']) ? $aRow['currency'] : ''),
+                'data' => isset($aRow['data']) ? $aRow['data'] : '',
+            ];
+
+            $iTs = (int)$aRow['date'];
+            $sKind = self::getChargeKind($aPending['type'], isset($aTrials[$aPending['id']]) ? $aTrials[$aPending['id']] : [], $iTs, isset($aFirst[$aPending['id']]) ? $aFirst[$aPending['id']] : 0);
+            $fRate = $this->getRate($aPending['currency'], $aPending['data']);
+
+            $iAdded += $this->_oDb->insertRevenue($this->_getLedgerRow($sPayment, $aPending, $aRow, $sKind, $iTs, $fRate, $this->_getImportContext((int)$aRow['client_id'], $iTs)));
+        }
+
+        return $iAdded;
+    }
+
+    /**
+     * Past subscriptions, current or cancelled, with their payments taken from the ledger. The
+     * charges of a subscription with a trial follow its trial: the import read them before it
+     * knew it (see importTransactions()).
+     */
+    public function importSubscriptions($sPayment, $aRows, $bDeleted)
+    {
+        $iImported = 0;
+        foreach($aRows as $aRow) {
+            if(empty($aRow['pending_id']) || (isset($aRow['type']) && $aRow['type'] != self::TYPE_RECURRING))
+                continue;
+
+            $aPending = [
+                'id' => (int)$aRow['pending_id'],
+                'client_id' => (int)$aRow['client_id'],
+                'seller_id' => (int)$aRow['seller_id'],
+                'provider' => (string)$aRow['provider'],
+                'items' => (string)$aRow['items'],
+                'amount' => (float)$aRow['amount'],
+                'currency' => (string)$aRow['currency'],
+                'data' => (string)$aRow['data'],
+            ];
+
+            $aSubscription = $this->_getSubscriptionRow($sPayment, $aPending, $aRow);
+            $aTrial = ['trial' => $aSubscription['trial'], 'started' => (int)$aRow['date_add']];
+            if($aTrial['trial'] > 0)
+                foreach(self::getKindChanges(self::TYPE_RECURRING, $aTrial, $this->_oDb->getRevenueRows($sPayment, $aPending['id'])) as $iId => $sKind)
+                    $this->_oDb->updateRevenueKind($iId, $sKind);
+
+            $aPaid = $this->_oDb->getRevenuePaid($sPayment, $aPending['id'], self::getTrialPaidFrom($aTrial));
+
+            $iImported += (int)$this->_oDb->importSubscriptionRow(array_merge($aSubscription, [
+                'status' => $bDeleted ? self::STATUS_CANCELED : $aSubscription['status'],
+                'started' => (int)$aRow['date_add'],
+                'first_paid' => (int)$aPaid['first'],
+                'last_paid' => (int)$aPaid['last'],
+                'paid_count' => (int)$aPaid['count'],
+                'canceled' => $bDeleted ? max(1, (int)$aRow['deleted']) : 0,
+                'reason' => $bDeleted ? get_mb_substr((string)$aRow['reason'], 0, 255) : '',
+                'platform' => '',
+            ]));
+        }
+
+        return $iImported;
+    }
+
+    /*
+     * Internals
+     */
+
+    /**
+     * A ledger row for a transaction of an order.
+     */
+    protected function _getLedgerRow($sPayment, $aPending, $aTransaction, $sKind, $iTs, $fRate, $aContext)
+    {
+        $iModuleId = (int)$aTransaction['module_id'];
+        $sProvider = isset($aPending['provider']) ? (string)$aPending['provider'] : (isset($aTransaction['provider']) ? (string)$aTransaction['provider'] : '');
+        $sCurrency = !empty($aTransaction['currency']) ? (string)$aTransaction['currency'] : (!empty($aPending['currency']) ? (string)$aPending['currency'] : $this->getDefaultCurrency());
+        $fAmount = round((float)$aTransaction['amount'], 4);
+
+        return array_merge([
+            'ts' => (int)$iTs,
+            'kind' => $sKind,
+            'cash' => $sProvider == self::PROVIDER_CREDITS ? 0 : 1,
+            'payment' => $sPayment,
+            'pending_id' => !empty($aTransaction['pending_id']) ? (int)$aTransaction['pending_id'] : (int)$aPending['id'],
+            'transaction_id' => (int)$aTransaction['id'],
+            'order' => get_mb_substr(isset($aPending['order']) ? (string)$aPending['order'] : (isset($aTransaction['order']) ? (string)$aTransaction['order'] : ''), 0, 32),
+            'module_id' => $iModuleId,
+            'module' => $this->_getModuleName($iModuleId),
+            'item_id' => max(0, (int)$aTransaction['item_id']),
+            'item_count' => max(0, (int)$aTransaction['item_count']),
+            'seller_id' => max(0, (int)$aTransaction['seller_id']),
+            'author_id' => max(0, (int)$aTransaction['author_id']),
+            'client_id' => max(0, !empty($aTransaction['client_id']) ? (int)$aTransaction['client_id'] : (isset($aPending['client_id']) ? (int)$aPending['client_id'] : 0)),
+            'provider' => get_mb_substr($sProvider, 0, 32),
+            'type' => isset($aPending['type']) ? (string)$aPending['type'] : self::TYPE_SINGLE,
+            'amount' => $fAmount,
+            'currency' => get_mb_substr($sCurrency, 0, 4),
+            // 0: no rate known, the amount isn't converted
+            'rate' => $fRate !== null ? $fRate : 0,
+            'amount_default' => self::toDefault($fAmount, $fRate),
+            'platform' => $aContext['platform'],
+            'session_id' => (int)$aContext['session_id'],
+        ], $this->_getSourceColumns($aContext));
+    }
+
+    /**
+     * A refund or reversal of a ledger row: the same order, item and sources, negative amounts.
+     */
+    protected function _insertNegation($aRow, $sKind)
+    {
+        unset($aRow['id']);
+
+        return $this->_oDb->insertRevenue(array_merge($aRow, [
+            'ts' => $this->_iNow,
+            'kind' => $sKind,
+            'amount' => -abs((float)$aRow['amount']),
+            'amount_default' => $aRow['amount_default'] !== null ? -abs((float)$aRow['amount_default']) : null,
+            'session_id' => 0,
+        ]));
+    }
+
+    /**
+     * A subscription row from an order and the payment module's subscription.
+     */
+    protected function _getSubscriptionRow($sPayment, $aPending, $aSubscription)
+    {
+        list($iModuleId, $iItemId) = self::getFirstItem(isset($aPending['items']) ? $aPending['items'] : '');
+
+        $iPeriod = max(1, isset($aSubscription['period']) ? (int)$aSubscription['period'] : 1);
+        $sUnit = isset($aSubscription['period_unit']) ? (string)$aSubscription['period_unit'] : '';
+        $fAmount = self::toDefault(isset($aPending['amount']) ? (float)$aPending['amount'] : 0, $this->getRate(isset($aPending['currency']) ? (string)$aPending['currency'] : '', isset($aPending['data']) ? $aPending['data'] : ''));
+
+        $sStatus = isset($aSubscription['status']) ? (string)$aSubscription['status'] : '';
+        if(!in_array($sStatus, [self::STATUS_TRIAL, self::STATUS_ACTIVE, self::STATUS_UNPAID, self::STATUS_CANCELED], true))
+            $sStatus = self::STATUS_UNPAID;
+
+        return [
+            'payment' => $sPayment,
+            'pending_id' => (int)$aPending['id'],
+            'subscription_id' => get_mb_substr(isset($aSubscription['subscription_id']) ? (string)$aSubscription['subscription_id'] : '', 0, 32),
+            'client_id' => isset($aPending['client_id']) ? max(0, (int)$aPending['client_id']) : 0,
+            'seller_id' => isset($aPending['seller_id']) ? max(0, (int)$aPending['seller_id']) : 0,
+            'module_id' => $iModuleId,
+            'module' => $this->_getModuleName($iModuleId),
+            'item_id' => $iItemId,
+            'provider' => get_mb_substr(isset($aPending['provider']) ? (string)$aPending['provider'] : '', 0, 32),
+            'period' => $iPeriod,
+            'period_unit' => get_mb_substr($sUnit, 0, 16),
+            'trial' => isset($aSubscription['trial']) ? max(0, (int)$aSubscription['trial']) : 0,
+            'amount_default' => $fAmount,
+            'mrr_default' => self::getMrr($fAmount, $iPeriod, $sUnit),
+            'status' => $sStatus,
+        ];
+    }
+
+    /**
+     * The ledger's row for a subscription, created from the payment module's when the
+     * subscription began before analytics was installed.
+     */
+    protected function _ensureSubscription($sPayment, $aPending)
+    {
+        $iPendingId = (int)$aPending['id'];
+        if(!empty($this->_oDb->getSubscriptionRow($sPayment, $iPendingId)))
+            return true;
+
+        $aSubscription = [];
+        if(bx_is_srv($sPayment, 'get_subscriptions_info', 'Subscriptions')) {
+            $aList = bx_srv_ic($sPayment, 'get_subscriptions_info', [['pending_id' => $iPendingId]], 'Subscriptions');
+            if(!empty($aList) && is_array($aList))
+                $aSubscription = reset($aList);
+        }
+
+        if(empty($aPending['items']) || !isset($aPending['amount'])) {
+            $aFresh = $this->_getPendingOrder($sPayment, $iPendingId);
+            if(!empty($aFresh))
+                $aPending = $aFresh;
+        }
+
+        $aRow = $this->_getSubscriptionRow($sPayment, $aPending, is_array($aSubscription) ? $aSubscription : []);
+        $aRow['started'] = !empty($aSubscription['date_add']) ? (int)$aSubscription['date_add'] : $this->_iNow;
+
+        return $this->_oDb->saveSubscriptionRow($aRow);
+    }
+
+    /**
+     * Context of a movement read from history: no platform and no visit, sources when the
+     * member's visits may be linked.
+     */
+    protected function _getImportContext($iClientId, $iTs)
+    {
+        $aContext = ['platform' => '', 'buyer' => false, 'track' => false, 'link' => false, 'session_id' => 0, 'first' => [], 'last' => []];
+        if(!$iClientId || !$this->_oConfig->isLinkMembers() || !$this->_getCollector()->isTracked(BX_ANALYTICS_PLATFORM_CORE, $iClientId, false, false))
+            return $aContext;
+
+        return array_merge($aContext, $this->_getSources($iClientId, $iTs));
+    }
+
+    protected function _getSources($iClientId, $iTs)
+    {
+        $sKey = $iClientId . ':' . $iTs;
+        if(!isset($this->_aSources[$sKey]))
+            $this->_aSources[$sKey] = $this->_oDb->getSessionSources($iClientId, $iTs);
+
+        return $this->_aSources[$sKey];
+    }
+
+    protected function _getSourceColumns($aContext)
+    {
+        $aColumns = [];
+        foreach(['first', 'last'] as $sWhich) {
+            $aSource = !empty($aContext[$sWhich]) && is_array($aContext[$sWhich]) ? $aContext[$sWhich] : [];
+            $aColumns[$sWhich . '_channel'] = isset($aSource['channel']) ? (string)$aSource['channel'] : '';
+            $aColumns[$sWhich . '_referrer_host'] = isset($aSource['referrer_host']) ? (string)$aSource['referrer_host'] : '';
+            $aColumns[$sWhich . '_utm_campaign'] = isset($aSource['utm_campaign']) ? (string)$aSource['utm_campaign'] : '';
+        }
+
+        return $aColumns;
+    }
+
+    protected function _getModuleName($iModuleId)
+    {
+        $iModuleId = (int)$iModuleId;
+        if(!$iModuleId)
+            return '';
+
+        if(!isset($this->_aModuleNames[$iModuleId])) {
+            $aModule = BxDolModuleQuery::getInstance()->getModuleById($iModuleId);
+            $this->_aModuleNames[$iModuleId] = !empty($aModule['name']) ? get_mb_substr($aModule['name'], 0, 32) : '';
+        }
+
+        return $this->_aModuleNames[$iModuleId];
+    }
+
+    /**
+     * Transactions of an order from the payment module that reported it.
+     */
+    protected function _getTransactions($sPayment, $iPendingId)
+    {
+        if(!bx_is_srv($sPayment, 'get_orders_info', 'Orders'))
+            return [];
+
+        $aRows = bx_srv_ic($sPayment, 'get_orders_info', [['pending_id' => (int)$iPendingId]], 'Orders');
+
+        return is_array($aRows) ? $aRows : [];
+    }
+
+    protected function _getPendingOrder($sPayment, $iPendingId)
+    {
+        if(!bx_is_srv($sPayment, 'get_pending_orders_info', 'Orders'))
+            return [];
+
+        $aRows = bx_srv_ic($sPayment, 'get_pending_orders_info', [['id' => (int)$iPendingId]], 'Orders');
+
+        return !empty($aRows) && is_array($aRows) ? reset($aRows) : [];
+    }
+
+    protected function _getActivePayment()
+    {
+        $oPayments = BxDolPayments::getInstance();
+
+        return $oPayments->isActive() ? (string)$oPayments->getActive() : '';
+    }
+
+    protected function _getCollector()
+    {
+        if($this->_oCollector === null)
+            $this->_oCollector = new BxAnalyticsCollector($this->_oModule, $this->_oRequest, $this->_iNow);
+
+        return $this->_oCollector;
+    }
+
+    protected function _getLoggedProfileId()
+    {
+        return array_key_exists('logged', $this->_aOptions) ? (int)$this->_aOptions['logged'] : (int)bx_get_logged_profile_id();
+    }
+
+    protected function _isApi()
+    {
+        return array_key_exists('api', $this->_aOptions) ? (bool)$this->_aOptions['api'] : defined('BX_API');
+    }
+
+    protected function _isApiKey()
+    {
+        return array_key_exists('api_key', $this->_aOptions) ? (bool)$this->_aOptions['api_key'] : $this->_oModule->isApiKeyRequest();
+    }
+}
+
+/** @} */

+ 280 - 0
modules/boonex/analytics/classes/BxAnalyticsSource.php

@@ -0,0 +1,280 @@
+<?php defined('BX_DOL') or die('hack attempt');
+/**
+ * Copyright (c) UNA, Inc - https://una.io
+ * MIT License - https://opensource.org/licenses/MIT
+ *
+ * @defgroup    Analytics Analytics
+ * @ingroup     UnaModules
+ *
+ * @{
+ */
+
+/**
+ * Traffic source of a session: referrer host, UTM parameters, click ids and the channel they add up to.
+ */
+class BxAnalyticsSource
+{
+    const CHANNEL_DIRECT = 'direct';
+    const CHANNEL_INTERNAL = 'internal';
+    const CHANNEL_SEARCH = 'search';
+    const CHANNEL_PAID_SEARCH = 'paid_search';
+    const CHANNEL_SOCIAL = 'social';
+    const CHANNEL_PAID_SOCIAL = 'paid_social';
+    const CHANNEL_EMAIL = 'email';
+    const CHANNEL_AI = 'ai';
+    const CHANNEL_DISPLAY = 'display';
+    const CHANNEL_AFFILIATE = 'affiliate';
+    const CHANNEL_CAMPAIGN = 'campaign';
+    const CHANNEL_REFERRAL = 'referral';
+
+    /**
+     * Host suffixes per channel. A host matches a suffix when it equals it or ends with "." . suffix.
+     * A suffix ending with "." matches any top level domain (google.com, google.co.uk).
+     */
+    protected static $_aHosts = [
+        self::CHANNEL_AI => [
+            'chatgpt.com', 'chat.openai.com', 'openai.com', 'perplexity.ai', 'claude.ai', 'gemini.google.com', 'bard.google.com',
+            'copilot.microsoft.com', 'copilot.cloud.microsoft', 'you.com', 'phind.com', 'poe.com', 'chat.deepseek.com', 'meta.ai',
+            'grok.com', 'chat.mistral.ai',
+        ],
+        self::CHANNEL_SEARCH => [
+            'google.', 'bing.com', 'yahoo.', 'duckduckgo.com', 'yandex.', 'ya.ru', 'baidu.com', 'ecosia.org', 'qwant.com',
+            'search.brave.com', 'startpage.com', 'naver.com', 'seznam.cz', 'sogou.com', 'so.com', 'ask.com', 'aol.com',
+            'kagi.com', 'mojeek.com', 'search.yahoo.co.jp', 'daum.net', 'coccoc.com', 'petalsearch.com',
+        ],
+        self::CHANNEL_SOCIAL => [
+            'facebook.com', 'fb.com', 'fb.me', 'messenger.com', 'instagram.com', 't.co', 'twitter.com', 'x.com', 'linkedin.com',
+            'lnkd.in', 'reddit.com', 'redd.it', 'pinterest.', 'pin.it', 'tiktok.com', 'youtube.com', 'youtu.be', 'vk.com', 'vk.ru',
+            'ok.ru', 't.me', 'telegram.org', 'telegram.me', 'whatsapp.com', 'wa.me', 'threads.net', 'threads.com', 'bsky.app',
+            'mastodon.social', 'quora.com', 'tumblr.com', 'discord.com', 'discord.gg', 'snapchat.com', 'weibo.com', 'weibo.cn',
+            'news.ycombinator.com', 'line.me', 'kakao.com', 'twitch.tv', 'medium.com', 'substack.com', 'nextdoor.com',
+            'xing.com', 'flickr.com', 'vimeo.com', 'dribbble.com', 'behance.net', 'stackoverflow.com', 'github.com',
+        ],
+        self::CHANNEL_EMAIL => [
+            'mail.google.com', 'inbox.google.com', 'outlook.live.com', 'outlook.office.com', 'outlook.office365.com',
+            'mail.yahoo.com', 'mail.yandex.ru', 'mail.ru', 'e.mail.ru', 'mail.aol.com', 'mail.proton.me', 'mail.zoho.com',
+            'icloud.com', 'fastmail.com', 'gmx.net', 'gmx.com', 'web.de', 't-online.de',
+        ],
+    ];
+
+    /**
+     * Click id parameters that ad networks append, with the channel each one means.
+     */
+    protected static $_aClickIds = [
+        'gclid' => self::CHANNEL_PAID_SEARCH,
+        'gbraid' => self::CHANNEL_PAID_SEARCH,
+        'wbraid' => self::CHANNEL_PAID_SEARCH,
+        'msclkid' => self::CHANNEL_PAID_SEARCH,
+        'yclid' => self::CHANNEL_PAID_SEARCH,
+        'dclid' => self::CHANNEL_DISPLAY,
+        'ttclid' => self::CHANNEL_PAID_SOCIAL,
+        'li_fat_id' => self::CHANNEL_PAID_SOCIAL,
+        'twclid' => self::CHANNEL_PAID_SOCIAL,
+        'sccid' => self::CHANNEL_PAID_SOCIAL,
+        'epik' => self::CHANNEL_PAID_SOCIAL,
+    ];
+
+    public static $aUtm = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content'];
+
+    /**
+     * Work out the source of a session from its first page.
+     *
+     * @param string $sReferrer document.referrer or the app's referrer, may be empty
+     * @param string $sUrl the landing page URL with its query string, for UTM parameters and click ids
+     * @param array $aInternalHosts hosts of the site and its clients: a referrer from one of them is not a source
+     * @return array with keys referrer (scheme and query stripped), referrer_host, channel and the utm_* keys
+     */
+    public static function classify($sReferrer, $sUrl = '', $aInternalHosts = [])
+    {
+        $aQuery = self::_query($sUrl);
+
+        $aResult = [
+            'referrer' => '',
+            'referrer_host' => '',
+            'channel' => self::CHANNEL_DIRECT,
+        ];
+        foreach(self::$aUtm as $sKey)
+            $aResult[$sKey] = isset($aQuery[$sKey]) && is_string($aQuery[$sKey]) ? self::_clean($aQuery[$sKey], 128) : '';
+
+        // ref= / source= are common in links without UTM (Product Hunt, newsletters)
+        if($aResult['utm_source'] === '')
+            foreach(['ref', 'source'] as $sKey)
+                if(isset($aQuery[$sKey]) && is_string($aQuery[$sKey]) && $aQuery[$sKey] !== '') {
+                    $aResult['utm_source'] = self::_clean($aQuery[$sKey], 128);
+                    break;
+                }
+
+        $sHost = self::host($sReferrer);
+        $bInternal = $sHost !== '' && self::_isInternal($sHost, $aInternalHosts);
+        if($sHost !== '' && !$bInternal) {
+            $aResult['referrer_host'] = $sHost;
+            $aResult['referrer'] = self::_clean(preg_replace(['/^[a-z][a-z0-9+.-]*:\/\/(www\.)?/i', '/[?#].*$/'], '', $sReferrer), 255);
+        }
+
+        $aResult['channel'] = self::channel($aResult['referrer_host'], $aResult['utm_source'], $aResult['utm_medium'], array_keys($aQuery));
+        if($aResult['channel'] === self::CHANNEL_DIRECT && $bInternal)
+            $aResult['channel'] = self::CHANNEL_INTERNAL;
+
+        return $aResult;
+    }
+
+    /**
+     * Channel from a referrer host, UTM source and medium, and the landing page's query parameter names.
+     */
+    public static function channel($sReferrerHost, $sUtmSource = '', $sUtmMedium = '', $aQueryKeys = [])
+    {
+        $sMedium = mb_strtolower(trim($sUtmMedium));
+        $sSource = mb_strtolower(trim($sUtmSource));
+        $sSourceChannel = $sSource !== '' ? self::_hostChannel($sSource) : '';
+        $sReferrerChannel = $sReferrerHost !== '' ? self::_hostChannel($sReferrerHost) : '';
+        $sKnown = $sSourceChannel ? $sSourceChannel : $sReferrerChannel;
+
+        $bPaid = (bool)preg_match('/^(.*cpc|ppc|paid.*|cpm|cpv|cpa|retargeting|sem)$/', $sMedium);
+        if($bPaid) {
+            if(preg_match('/social/', $sMedium) || $sKnown === self::CHANNEL_SOCIAL)
+                return self::CHANNEL_PAID_SOCIAL;
+            if($sKnown === self::CHANNEL_SEARCH || preg_match('/search|cpc|ppc|sem/', $sMedium))
+                return self::CHANNEL_PAID_SEARCH;
+            return self::CHANNEL_DISPLAY;
+        }
+
+        // an explicit medium says more than a click id that an ad network appended
+        if($sMedium !== '') {
+            if(preg_match('/^(e[-_ ]?mail|newsletter|mailing)/', $sMedium))
+                return self::CHANNEL_EMAIL;
+            if(preg_match('/^(social|social[-_ ]?(network|media)|sm|post)$/', $sMedium))
+                return self::CHANNEL_SOCIAL;
+            if(preg_match('/^(display|banner|expandable|interstitial)$/', $sMedium))
+                return self::CHANNEL_DISPLAY;
+            if($sMedium === 'affiliate' || $sMedium === 'partner')
+                return self::CHANNEL_AFFILIATE;
+            if($sMedium === 'organic')
+                return $sKnown === self::CHANNEL_SOCIAL ? self::CHANNEL_SOCIAL : self::CHANNEL_SEARCH;
+            if($sMedium === 'referral')
+                return $sKnown ? $sKnown : self::CHANNEL_REFERRAL;
+        }
+
+        foreach(self::$_aClickIds as $sKey => $sChannel)
+            if(in_array($sKey, $aQueryKeys, true))
+                return $sChannel;
+
+        if($sKnown)
+            return $sKnown;
+
+        if($sSource !== '' || $sMedium !== '')
+            return self::CHANNEL_CAMPAIGN;
+
+        return $sReferrerHost !== '' ? self::CHANNEL_REFERRAL : self::CHANNEL_DIRECT;
+    }
+
+    /**
+     * Lower case host of a URL without "www.", or '' when there is none. Android apps send
+     * referrers like android-app://com.google.android.gm, which become the package name.
+     */
+    public static function host($sUrl)
+    {
+        $sUrl = trim((string)$sUrl);
+        if($sUrl === '')
+            return '';
+
+        $sHost = parse_url(bx_mb_strpos($sUrl, '//') === false ? '//' . $sUrl : $sUrl, PHP_URL_HOST);
+        if(!is_string($sHost) || $sHost === '')
+            return '';
+
+        $sHost = mb_strtolower(rtrim($sHost, '.'));
+        if(bx_mb_strpos($sHost, 'www.') === 0)
+            $sHost = get_mb_substr($sHost, 4, get_mb_len($sHost));
+
+        return preg_match('/^[a-z0-9.-]{1,253}$/', $sHost) ? $sHost : '';
+    }
+
+    protected static function _hostChannel($sHost)
+    {
+        $sHost = mb_strtolower($sHost);
+
+        // Android app referrers
+        $aApps = [
+            'com.google.android.gm' => self::CHANNEL_EMAIL,
+            'com.google.android.googlequicksearchbox' => self::CHANNEL_SEARCH,
+            'com.facebook.katana' => self::CHANNEL_SOCIAL,
+            'com.instagram.android' => self::CHANNEL_SOCIAL,
+            'com.twitter.android' => self::CHANNEL_SOCIAL,
+            'com.linkedin.android' => self::CHANNEL_SOCIAL,
+            'com.reddit.frontpage' => self::CHANNEL_SOCIAL,
+            'org.telegram.messenger' => self::CHANNEL_SOCIAL,
+            'com.whatsapp' => self::CHANNEL_SOCIAL,
+            'com.openai.chatgpt' => self::CHANNEL_AI,
+        ];
+        if(isset($aApps[$sHost]))
+            return $aApps[$sHost];
+
+        foreach(self::$_aHosts as $sChannel => $aSuffixes)
+            foreach($aSuffixes as $sSuffix) {
+                if(bx_mb_strpos($sSuffix, '/') !== false)
+                    continue;
+
+                if(get_mb_substr($sSuffix, -1, 1) === '.') {
+                    // google. matches google.com, google.co.uk, news.google.com
+                    if(preg_match('/(^|\.)' . preg_quote($sSuffix, '/') . '[a-z]{2,3}(\.[a-z]{2})?$/', $sHost))
+                        return $sChannel;
+                    continue;
+                }
+
+                if($sHost === $sSuffix || self::_endsWith($sHost, '.' . $sSuffix))
+                    return $sChannel;
+            }
+
+        // a bare UTM source like "google", "facebook" or "newsletter"
+        if(bx_mb_strpos($sHost, '.') === false) {
+            if(preg_match('/^(google|bing|yahoo|duckduckgo|yandex|baidu|ecosia|naver|brave)$/', $sHost))
+                return self::CHANNEL_SEARCH;
+            if(preg_match('/^(facebook|fb|instagram|ig|twitter|x|linkedin|reddit|pinterest|tiktok|youtube|vk|telegram|whatsapp|threads|bluesky|mastodon|discord|snapchat)$/', $sHost))
+                return self::CHANNEL_SOCIAL;
+            if(preg_match('/^(chatgpt|openai|perplexity|claude|gemini|copilot|deepseek|grok)$/', $sHost))
+                return self::CHANNEL_AI;
+            if(preg_match('/^(newsletter|email|mail|gmail|outlook)$/', $sHost))
+                return self::CHANNEL_EMAIL;
+        }
+
+        return '';
+    }
+
+    protected static function _isInternal($sHost, $aInternalHosts)
+    {
+        foreach($aInternalHosts as $sInternal) {
+            $sInternal = self::host($sInternal);
+            if($sInternal !== '' && ($sHost === $sInternal || self::_endsWith($sHost, '.' . $sInternal)))
+                return true;
+        }
+
+        return false;
+    }
+
+    protected static function _endsWith($s, $sEnd)
+    {
+        $iLength = get_mb_len($sEnd);
+
+        return get_mb_len($s) >= $iLength && get_mb_substr($s, -$iLength, $iLength) === $sEnd;
+    }
+
+    protected static function _query($sUrl)
+    {
+        $sQuery = parse_url((string)$sUrl, PHP_URL_QUERY);
+        if(!is_string($sQuery) || $sQuery === '')
+            return [];
+
+        $aQuery = [];
+        parse_str($sQuery, $aQuery);
+
+        return $aQuery;
+    }
+
+    protected static function _clean($s, $iMaxLength)
+    {
+        $s = trim(preg_replace('/[\x00-\x1F\x7F]+/u', ' ', (string)$s));
+
+        return get_mb_substr($s, 0, $iMaxLength);
+    }
+}
+
+/** @} */

+ 55 - 0
modules/boonex/analytics/classes/BxAnalyticsStudioMenu.php

@@ -0,0 +1,55 @@
+<?php defined('BX_DOL') or die('hack attempt');
+/**
+ * Copyright (c) UNA, Inc - https://una.io
+ * MIT License - https://opensource.org/licenses/MIT
+ *
+ * @defgroup    Analytics Analytics
+ * @ingroup     UnaModules
+ *
+ * @{
+ */
+
+/**
+ * The Studio side menu in sections (Traffic, Behavior, Conversion...). Each section's items are
+ * drawn by Studio's own menu object, so they look and behave like any Studio menu; this class
+ * only adds the section headings around them.
+ */
+class BxAnalyticsStudioMenu
+{
+    protected $_oTemplate;
+    protected $_aSections;
+
+    /**
+     * @param object $oTemplate the module template
+     * @param array $aSections list of ['name' => section name, 'title' => heading or '', 'menu' => Studio menu object]
+     */
+    public function __construct($oTemplate, $aSections)
+    {
+        $this->_oTemplate = $oTemplate;
+        $this->_aSections = $aSections;
+    }
+
+    public function getCode()
+    {
+        $aSections = [];
+        foreach($this->_aSections as $aSection)
+            $aSections[] = [
+                'name' => $aSection['name'],
+                'bx_if:title' => [
+                    'condition' => $aSection['title'] !== '',
+                    'content' => [
+                        'name' => $aSection['name'],
+                        'title' => $aSection['title'],
+                    ],
+                ],
+                'attrs' => $aSection['title'] !== '' ? 'role="group" aria-labelledby="bx-analytics-std-menu-' . $aSection['name'] . '"' : '',
+                'menu' => $aSection['menu']->getCode(),
+            ];
+
+        return $this->_oTemplate->parseHtmlByName('studio_menu.html', [
+            'bx_repeat:sections' => $aSections,
+        ]);
+    }
+}
+
+/** @} */

+ 49 - 0
modules/boonex/analytics/classes/BxAnalyticsStudioMenuItems.php

@@ -0,0 +1,49 @@
+<?php defined('BX_DOL') or die('hack attempt');
+/**
+ * Copyright (c) UNA, Inc - https://una.io
+ * MIT License - https://opensource.org/licenses/MIT
+ *
+ * @defgroup    Analytics Analytics
+ * @ingroup     UnaModules
+ *
+ * @{
+ */
+
+/**
+ * Studio side menu items with Lucide glyphs on Studio's gray tiles, like Studio's own pages.
+ * An item names its glyph in `lucide`; the rest is Studio's menu as is.
+ */
+class BxAnalyticsStudioMenuItems extends BxTemplStudioMenu
+{
+    protected $_oIconset;
+
+    public function __construct($aObject, $oTemplate = false)
+    {
+        parent::__construct($aObject, $oTemplate);
+
+        $this->_oIconset = BxDolIconset::getObjectInstance('sys_lucide');
+    }
+
+    protected function _getMenuItem($aItem)
+    {
+        $sLucide = isset($aItem['lucide']) ? $aItem['lucide'] : '';
+
+        $aItem = parent::_getMenuItem($aItem);
+        if($aItem === false || $sLucide === '' || !$this->_oIconset)
+            return $aItem;
+
+        $sSvg = $this->_oIconset->getIconHtml($sLucide, ['class' => 'bx-analytics-std-menu-icon', 'aria-hidden' => 'true']);
+        if($sSvg === false)
+            return $aItem;
+
+        return array_merge($aItem, [
+            'bx_if:icon' => ['condition' => false, 'content' => ['icon' => '']],
+            'bx_if:image' => ['condition' => false, 'content' => ['icon_url' => '', 'attrs' => '']],
+            'bx_if:image_inline' => ['condition' => true, 'content' => ['image' => $sSvg]],
+            'bx_if:show_icon' => ['condition' => true, 'content' => []],
+            'bx_if:show_icon_bg' => ['condition' => true, 'content' => []],
+        ]);
+    }
+}
+
+/** @} */

+ 128 - 0
modules/boonex/analytics/classes/BxAnalyticsStudioPage.php

@@ -0,0 +1,128 @@
+<?php defined('BX_DOL') or die('hack attempt');
+/**
+ * Copyright (c) UNA, Inc - https://una.io
+ * MIT License - https://opensource.org/licenses/MIT
+ *
+ * @defgroup    Analytics Analytics
+ * @ingroup     UnaModules
+ *
+ * @{
+ */
+
+/**
+ * Studio page of the module: every report section in a side menu grouped like the reports
+ * themselves (traffic, behavior, conversion, revenue, health, community, explore), then setup
+ * and settings. A section shows the same block services the site's page builder offers, with
+ * the filter bar on top, so a report looks and counts the same in Studio and on any page.
+ */
+class BxAnalyticsStudioPage extends BxTemplStudioModule
+{
+    protected $_sModule;
+    protected $_oModule;
+
+    protected $sPageDefault = 'overview';
+
+    /**
+     * Section => [page => Lucide glyph]. The empty section has no heading.
+     */
+    protected $_aSections = [
+        '' => ['overview' => 'chart-line'],
+        'traffic' => ['realtime' => 'zap', 'pages' => 'file-text', 'sources' => 'share-2', 'audience' => 'users', 'map' => 'earth', 'bots' => 'bot'],
+        'behavior' => ['sessions' => 'history', 'users' => 'user', 'events' => 'mouse-pointer-click'],
+        'conversion' => ['goals' => 'target', 'funnels' => 'funnel', 'journeys' => 'route', 'retention' => 'repeat'],
+        'revenue' => ['revenue' => 'circle-dollar-sign', 'subscriptions' => 'refresh-cw', 'sales' => 'shopping-cart', 'credits' => 'coins'],
+        'health' => ['errors' => 'triangle-alert', 'performance' => 'gauge'],
+        'community' => ['content' => 'layers'],
+        'explore' => ['query' => 'database', 'api' => 'code', 'dashboards' => 'layout-dashboard'],
+        'configure' => ['setup' => 'plug', 'settings' => 'settings'],
+    ];
+
+    public function __construct($sModule, $mixedPageName, $sPage = "")
+    {
+        $this->_sModule = 'bx_analytics';
+        $this->_oModule = BxDolModule::getInstance($this->_sModule);
+
+        parent::__construct($sModule, $mixedPageName, $sPage);
+
+        $aSettings = $this->aMenuItems[BX_DOL_STUDIO_MOD_TYPE_SETTINGS];
+
+        $this->aMenuItems = [];
+        foreach($this->_aSections as $aPages)
+            foreach($aPages as $sPage => $sIcon)
+                $this->aMenuItems[$sPage] = $sPage == BX_DOL_STUDIO_MOD_TYPE_SETTINGS ? $aSettings : [
+                    'name' => $sPage,
+                    'icon' => '',
+                    'lucide' => $sIcon,
+                    'title' => '_bx_analytics_lmi_cpt_' . $sPage,
+                ];
+    }
+
+    /**
+     * The side menu in sections, each drawn by Studio's own menu object.
+     */
+    protected function getPageMenuObject($aMenu = [], $aMarkers = [])
+    {
+        $this->_oModule->_oTemplate->addStudioCss(['studio.css']);
+
+        bx_import('StudioMenu', $this->_oModule->_aModule);
+        bx_import('StudioMenuItems', $this->_oModule->_aModule);
+
+        $aByName = [];
+        foreach($aMenu as $aItem)
+            $aByName[$aItem['name']] = $aItem;
+
+        $aSections = [];
+        foreach($this->_aSections as $sSection => $aPages) {
+            $aItems = [];
+            foreach(array_keys($aPages) as $sPage)
+                if(isset($aByName[$sPage]))
+                    $aItems[] = $aByName[$sPage];
+
+            if(empty($aItems))
+                continue;
+
+            $oMenu = new BxAnalyticsStudioMenuItems(['template' => 'menu_side.html', 'menu_items' => $aItems]);
+            if(!empty($aMarkers))
+                $oMenu->addMarkers($aMarkers);
+            $oMenu->setInlineIcons($this->bPageMenuIconsInline);
+
+            $aSections[] = [
+                'name' => $sSection !== '' ? $sSection : 'main',
+                'title' => $sSection !== '' ? _t('_bx_analytics_section_' . $sSection) : '',
+                'menu' => $oMenu,
+            ];
+        }
+
+        return new BxAnalyticsStudioMenu($this->_oModule->_oTemplate, $aSections);
+    }
+
+    /**
+     * Report sections: the page name is the section the module renders.
+     */
+    public function getPageCode($sPage = '', $bWrap = true)
+    {
+        if(empty($sPage))
+            $sPage = $this->sPage;
+
+        if($sPage != BX_DOL_STUDIO_MOD_TYPE_SETTINGS && isset($this->aMenuItems[$sPage]) && !method_exists($this, 'get' . bx_gen_method_name($sPage))) {
+            $sResult = parent::getPageCode('', $bWrap);
+            if($sResult === false)
+                return false;
+
+            $this->_oModule->_oTemplate->setStudio(true);
+
+            return $this->getBlockCode(['content' => $this->_oModule->getStudioSection($sPage)]) . $sResult;
+        }
+
+        return parent::getPageCode($sPage, $bWrap);
+    }
+
+    protected function getContent()
+    {
+        $this->_oModule->_oTemplate->setStudio(true);
+
+        return $this->_oModule->serviceGetCanvas();
+    }
+}
+
+/** @} */

+ 1952 - 4
modules/boonex/analytics/classes/BxAnalyticsTemplate.php

@@ -13,12 +13,29 @@ bx_import ('BxDolModuleTemplate');
 
 class BxAnalyticsTemplate extends BxBaseModGeneralTemplate
 {
+    /**
+     * Blocks are drawn inside Studio: assets go to the Studio template.
+     */
+    protected $_bStudio = false;
+
+    protected $_aRanges = ['today', 'yesterday', '24h', '7d', '30d', '90d', '12m'];
+
     function __construct(&$oConfig, &$oDb)
     {
         $this->MODULE = 'bx_analytics';
         parent::__construct($oConfig, $oDb);
     }
-    
+
+    public function setStudio($bStudio)
+    {
+        $this->_bStudio = (bool)$bStudio;
+    }
+
+    public function isStudio()
+    {
+        return $this->_bStudio;
+    }
+
     function getCanvas()
     {
         $aTmp = $this->getModule()->getSelectedModules();
@@ -27,17 +44,1948 @@ class BxAnalyticsTemplate extends BxBaseModGeneralTemplate
         $aModulesList2 = [];
         foreach($aModules as $sModule)
             $aModulesList2[] = ['value' => $sModule, 'title' => $aModulesList[$sModule]];
-        
+
         $iDaysBefore = getParam('bx_analytics_default_interval_day');
         $sDate = date('d/m/Y', time() - $iDaysBefore * 86400) . ' - ' . date('d/m/Y');
-        $this->addJs(['chart.umd.min.js', 'chartjs-adapter-moment.min.js', 'analytics.js', BX_DIRECTORY_PATH_MODULES . 'boonex/analytics/plugins/daterangepicker/|daterangepicker.js', BX_DIRECTORY_PATH_MODULES . 'boonex/analytics/plugins/datatables/|datatables.min.js']);
-        $this->addCss(['main.css', BX_DIRECTORY_PATH_MODULES . 'boonex/analytics/plugins/daterangepicker/|daterangepicker.css', BX_DIRECTORY_PATH_MODULES . 'boonex/analytics/plugins/datatables/|datatables.min.css']);
+        $aJs = ['chart.umd.min.js', 'chartjs-adapter-moment.min.js', 'analytics.js', BX_DIRECTORY_PATH_MODULES . 'boonex/analytics/plugins/daterangepicker/|daterangepicker.js', BX_DIRECTORY_PATH_MODULES . 'boonex/analytics/plugins/datatables/|datatables.min.js'];
+        $aCss = ['main.css', BX_DIRECTORY_PATH_MODULES . 'boonex/analytics/plugins/daterangepicker/|daterangepicker.css', BX_DIRECTORY_PATH_MODULES . 'boonex/analytics/plugins/datatables/|datatables.min.css'];
+        $this->_addAssets($aJs, $aCss);
+
         return $this->getJsCode('analytics') . $this->parseHtmlByName('canvas.html', [
             'bx_repeat:items' => $aModulesList2,
             'interval' => $sDate,
             'export_to_csv_title' => _t('_bx_analytics_txt_export_to_csv_title')
         ]);
     }
+
+    /**
+     * The classic UI tracker: a small deferred script configured by data attributes.
+     */
+    public function getTrackerCode()
+    {
+        return $this->parseHtmlByName('tracker.html', [
+            'src' => $this->_getTrackerUrl(),
+            'endpoint' => BX_DOL_URL_ROOT . $this->_oConfig->getBaseUri() . 'collect',
+            'platform' => BX_ANALYTICS_PLATFORM_CORE_WEB,
+            'identity' => $this->_oConfig->getIdentityMode(),
+            'dnt' => $this->_oConfig->isRespectDnt() ? 1 : 0,
+            'errors' => $this->_oConfig->isTrackErrors() ? 1 : 0,
+            // percent of page loads that measure vitals, 0 for none
+            'vitals' => $this->_oConfig->isTrackVitals() ? $this->_oConfig->getVitalsSample() : 0,
+        ]);
+    }
+
+    /**
+     * The tracker's URL. Every classic page loads it, so when the site minifies its scripts
+     * it is minified too, once per revision, into the public cache next to the site's
+     * scripts (clearing the JS cache removes it); the source file otherwise, or when the
+     * cache can't be written.
+     */
+    protected function _getTrackerUrl()
+    {
+        $iRevision = (int)getParam('sys_revision');
+        $sSource = 'boonex/analytics/js/tracker.js';
+        $sUrl = BX_DOL_URL_MODULES . $sSource . '?v=' . $iRevision;
+        if(bx_is_dbg() || getParam('sys_template_cache_js_enable') != 'on' || getParam('sys_template_cache_minify_js_enable') != 'on')
+            return $sUrl;
+
+        $sName = $this->getCacheFilePrefix('js') . 'bx_analytics_tracker_' . bx_site_hash($sSource . $iRevision) . '.js';
+        $sPath = BX_DIRECTORY_PATH_CACHE_PUBLIC . $sName;
+        if(!file_exists($sPath)) {
+            $sCode = @file_get_contents(BX_DIRECTORY_PATH_MODULES . $sSource);
+            $sMinified = $sCode !== false ? BxDolMinify::getInstance()->minifyJs($sCode) : '';
+            // written aside and moved in, so a page never loads a half-written file
+            $sTemp = $sPath . '.' . uniqid() . '.tmp';
+            if($sMinified === '' || @file_put_contents($sTemp, $sMinified) === false || !@rename($sTemp, $sPath)) {
+                @unlink($sTemp);
+                return $sUrl;
+            }
+            @chmod($sPath, BX_DOL_FILE_RIGHTS);
+        }
+
+        return BX_DOL_URL_CACHE_PUBLIC . $sName;
+    }
+
+    /**
+     * A report block on a page: the dashboard script, once per page, and the report.
+     *
+     * @param string $sReport report name, see BxAnalyticsModule::getReportData
+     * @param array $aData report data
+     * @param object $oFilter the filter the data was built with
+     * @param array $aParams block parameters (group, table...) the script sends back on reload
+     */
+    public function getBlock($sReport, $aData, $oFilter, $aParams = [])
+    {
+        $this->_addDashboardAssets($sReport);
+
+        return $this->_getJsInit() . $this->getReportHtml($sReport, $aData, $oFilter, $aParams);
+    }
+
+    /**
+     * Block HTML for a report, on first render and when the dashboard script reloads it.
+     */
+    public function getReportHtml($sReport, $aData, $oFilter, $aParams = [])
+    {
+        $sMethod = '_get' . bx_gen_method_name($sReport) . 'Html';
+        if(!method_exists($this, $sMethod))
+            return '';
+
+        $sContent = $this->$sMethod($aData, $oFilter, $aParams);
+        if($sContent === '')
+            return '';
+
+        return $this->parseHtmlByName('block.html', [
+            'report' => $sReport,
+            'params' => $this->_esc(json_encode((object)$aParams)),
+            'class' => str_replace('_', '-', $sReport),
+            'content' => $sContent,
+        ]);
+    }
+
+    /**
+     * A Studio section: blocks in a grid, the wide ones across all columns.
+     *
+     * @param array $aBlocks list of ['title' => heading or '', 'content' => HTML, 'wide' => bool]
+     */
+    public function getStudioSection($aBlocks)
+    {
+        $aCards = [];
+        foreach($aBlocks as $aBlock) {
+            if($aBlock['content'] === '' || $aBlock['content'] === [])
+                continue;
+
+            $aCards[] = [
+                'class' => !empty($aBlock['wide']) ? 'bx-analytics-studio-wide' : '',
+                'bx_if:title' => [
+                    'condition' => !empty($aBlock['title']),
+                    'content' => ['title' => !empty($aBlock['title']) ? $aBlock['title'] : ''],
+                ],
+                'content' => $aBlock['content'],
+            ];
+        }
+
+        return $this->parseHtmlByName('studio_section.html', [
+            'bx_repeat:cards' => $aCards,
+        ]);
+    }
+
+    /**
+     * Traffic block of the Studio dashboard.
+     */
+    public function getStudioDashboardTraffic($aOverview, $aRealtime, $aPlatforms)
+    {
+        $this->addStudioCss(['dashboard.css']);
+
+        $aValues = array_map(function($a) { return $a['future'] ? null : $a['visitors']; }, $aOverview['series']);
+        $iMax = max(array_merge([1], array_map('intval', $aValues)));
+        $iCount = count($aValues);
+        $aPoints = [];
+        foreach($aValues as $i => $mixedValue)
+            if($mixedValue !== null)
+                $aPoints[] = round($iCount > 1 ? 100 * $i / ($iCount - 1) : 50, 2) . ',' . round(38 - 36 * $mixedValue / $iMax, 2);
+
+        $aKpis = [];
+        foreach(['visitors', 'pageviews'] as $sMetric) {
+            $aKpi = $aOverview['kpis'][$sMetric];
+            $aKpis[] = [
+                'title' => _t('_bx_analytics_metric_' . $sMetric),
+                'value' => $this->formatValue($sMetric, $aKpi['value']),
+                'change' => $this->_formatChange($aKpi['change']),
+                'trend' => $this->_trend($sMetric, $aKpi['change']),
+            ];
+        }
+
+        $aRows = [];
+        foreach($aPlatforms['items'] as $aItem)
+            $aRows[] = [
+                'label' => $this->_esc($aItem['label']),
+                'share' => $aItem['share'],
+                'bar' => $aItem['bar'],
+            ];
+
+        return $this->parseHtmlByName('studio_traffic.html', [
+            'online' => $this->formatValue('number', $aRealtime['online']),
+            'online_title' => _t('_bx_analytics_txt_online_now', $aRealtime['window']),
+            'bx_repeat:kpis' => $aKpis,
+            'points' => implode(' ', $aPoints),
+            'chart_label' => _t('_bx_analytics_txt_chart_visitors_7d'),
+            'bx_if:platforms' => [
+                'condition' => !empty($aRows),
+                'content' => ['bx_repeat:platforms' => $aRows],
+            ],
+            'bx_if:empty' => [
+                'condition' => empty($aRows),
+                'content' => ['empty' => _t('_bx_analytics_txt_no_data_yet')],
+            ],
+            'link' => BX_DOL_URL_STUDIO . 'module.php?name=' . $this->MODULE . '&page=overview',
+            'link_title' => _t('_bx_analytics_txt_open_analytics'),
+        ]);
+    }
+
+    /**
+     * A number for display by its kind: number (950, 9.5K), percent, duration (seconds),
+     * ms, pages_per_session, or a metric name.
+     */
+    public function formatValue($sType, $mixedValue)
+    {
+        if($mixedValue === null || $mixedValue === '')
+            return '';
+
+        switch($sType) {
+            case 'bounce_rate':
+            case 'percent':
+            case 'rate':
+                return (abs($mixedValue) < 10 && round($mixedValue) != $mixedValue ? number_format((float)$mixedValue, 1) : round($mixedValue)) . '%';
+
+            // shares close to 100%, such as crash-free visits: 99.95%, not 100%
+            case 'percent_precise':
+                return rtrim(rtrim(number_format((float)$mixedValue, 2), '0'), '.') . '%';
+
+            case 'pages_per_session':
+            case 'decimal':
+                return number_format((float)$mixedValue, 1);
+
+            // good, needs_improvement or poor, as a coloured label
+            case 'rating':
+                return $this->parseHtmlByName('rating.html', [
+                    'rating' => str_replace('_', '-', $this->_esc($mixedValue)),
+                    'title' => _t('_bx_analytics_rating_' . preg_replace('/[^a-z_]/', '', (string)$mixedValue)),
+                ]);
+
+            // a small line of values over time
+            case 'spark':
+                return is_array($mixedValue) ? $this->_getSparkHtml($mixedValue) : '';
+
+            case 'duration':
+            case 'engaged':
+                $iSeconds = (int)round($mixedValue);
+                if($iSeconds < 60)
+                    return $iSeconds . _t('_bx_analytics_txt_duration_s');
+                if($iSeconds < 3600)
+                    return intdiv($iSeconds, 60) . _t('_bx_analytics_txt_duration_m') . ' ' . ($iSeconds % 60) . _t('_bx_analytics_txt_duration_s');
+                return intdiv($iSeconds, 3600) . _t('_bx_analytics_txt_duration_h') . ' ' . intdiv($iSeconds % 3600, 60) . _t('_bx_analytics_txt_duration_m');
+
+            case 'ms':
+                return $mixedValue >= 1000 ? number_format($mixedValue / 1000, 2) . _t('_bx_analytics_txt_duration_s') : round($mixedValue) . _t('_bx_analytics_txt_ms');
+
+            case 'cls':
+                return number_format((float)$mixedValue, 3);
+
+            case 'money':
+                // the site's default currency, the way the payment module shows it
+                return $this->_esc(_t_format_currency((float)$mixedValue));
+
+            case 'credits':
+                return rtrim(rtrim(number_format((float)$mixedValue, 2), '0'), '.');
+
+            case 'datetime':
+                return bx_time_js((int)$mixedValue, BX_FORMAT_DATE_TIME, true);
+
+            case 'date':
+                return bx_time_js((int)$mixedValue, BX_FORMAT_DATE, true);
+
+            case 'text':
+                return $this->_esc($mixedValue);
+        }
+
+        return $this->_formatNumber($mixedValue);
+    }
+
+    protected function _getFiltersHtml($aData, $oFilter)
+    {
+        $aFilter = $oFilter->get();
+
+        $aRanges = [];
+        foreach($this->_aRanges as $sRange)
+            $aRanges[] = [
+                'value' => $sRange,
+                'title' => _t('_bx_analytics_range_' . $sRange),
+                'selected' => $aFilter['range'] == $sRange ? 'selected="selected"' : '',
+            ];
+        if($aFilter['range'] == 'custom')
+            $aRanges[] = [
+                'value' => 'custom',
+                'title' => $this->_esc($aFilter['from_date'] . ' - ' . $aFilter['to_date']),
+                'selected' => 'selected="selected"',
+            ];
+
+        $aPlatforms = [['value' => '', 'title' => _t('_bx_analytics_txt_all_platforms'), 'selected' => '']];
+        foreach($this->_oConfig->getPlatforms() as $sCode => $sTitle)
+            $aPlatforms[] = [
+                'value' => $sCode,
+                'title' => _t($sTitle),
+                'selected' => $aFilter['platform'] == $sCode ? 'selected="selected"' : '',
+            ];
+
+        $aChips = [];
+        foreach($aData['chips'] as $aChip)
+            $aChips[] = [
+                'name' => $aChip['name'],
+                'title' => $aChip['title'],
+                'value' => $this->_esc($aChip['label']),
+                'remove' => $this->_esc(_t('_bx_analytics_txt_remove_filter', $aChip['title'])),
+            ];
+
+        return $this->parseHtmlByName('filters.html', [
+            'range_label' => _t('_bx_analytics_txt_range'),
+            'bx_repeat:ranges' => $aRanges,
+            'platform_label' => _t('_bx_analytics_txt_platform'),
+            'bx_repeat:platforms' => $aPlatforms,
+            'bx_if:chips' => [
+                'condition' => !empty($aChips),
+                'content' => ['bx_repeat:chips' => $aChips],
+            ],
+            'online' => $this->formatValue('number', $aData['online']),
+            'online_title' => _t('_bx_analytics_txt_online_now', $aData['window']),
+        ]);
+    }
+
+    protected function _getOverviewHtml($aOverview, $oFilter)
+    {
+        $sMetric = bx_get('metric');
+        $aMetrics = $this->getModule()->getReports()->getMetrics();
+        if(!in_array($sMetric, $aMetrics, true))
+            $sMetric = 'visitors';
+
+        $aKpis = [];
+        foreach($aOverview['kpis'] as $sName => $aKpi)
+            $aKpis[] = [
+                'metric' => $sName,
+                'pressed' => $sName == $sMetric ? 'true' : 'false',
+                'title' => _t('_bx_analytics_metric_' . $sName),
+                'value' => $this->formatValue($sName, $aKpi['value']),
+                'change' => $this->_formatChange($aKpi['change']),
+                'change_title' => $this->_esc(_t('_bx_analytics_txt_previous_period', $this->formatValue($sName, $aKpi['previous']))),
+                'trend' => $this->_trend($sName, $aKpi['change']),
+            ];
+
+        $aTitles = [];
+        foreach($aMetrics as $sName)
+            $aTitles[$sName] = _t('_bx_analytics_metric_' . $sName);
+
+        return $this->parseHtmlByName('overview.html', [
+            'kpis_label' => _t('_bx_analytics_txt_key_figures'),
+            'bx_repeat:kpis' => $aKpis,
+            'chart_label' => $this->_esc(_t('_bx_analytics_txt_chart_label')),
+            'chart_json' => $this->_json([
+                'metric' => $sMetric,
+                'bucket' => $aOverview['bucket'],
+                'tz' => $aOverview['tz'],
+                'series' => $aOverview['series'],
+                'titles' => $aTitles,
+            ]),
+        ]);
+    }
+
+    protected function _getTopHtml($aTop, $oFilter)
+    {
+        $aItems = [];
+        foreach($aTop['items'] as $aItem)
+            $aItems[] = [
+                'dimension' => $aTop['dimension'],
+                'value' => $this->_esc($aItem['value']),
+                'label' => $this->_esc($aItem['label']),
+                'visitors' => $this->formatValue('number', $aItem['visitors']),
+                'share' => $aItem['share'],
+                'bar' => $aItem['bar'],
+                'filter_title' => $this->_esc(_t('_bx_analytics_txt_filter_by', $aItem['label'])),
+            ];
+
+        return $this->parseHtmlByName('top.html', [
+            'tabs' => $this->_getTabsHtml($aTop['group'], $aTop['tabs']),
+            'group' => $aTop['group'],
+            'column' => $aTop['column'],
+            'visitors' => _t('_bx_analytics_metric_visitors'),
+            'bx_if:items' => [
+                'condition' => !empty($aItems),
+                'content' => ['bx_repeat:items' => $aItems],
+            ],
+            'bx_if:empty' => [
+                'condition' => empty($aItems),
+                'content' => ['empty' => _t('_bx_analytics_txt_no_data')],
+            ],
+        ]);
+    }
+
+    protected function _getRealtimeHtml($aData, $oFilter)
+    {
+        $iMax = 1;
+        foreach($aData['minutes'] as $aMinute)
+            $iMax = max($iMax, $aMinute['pageviews']);
+
+        $aMinutes = [];
+        foreach($aData['minutes'] as $aMinute)
+            $aMinutes[] = [
+                'height' => $aMinute['pageviews'] ? max(4, round(100 * $aMinute['pageviews'] / $iMax)) : 0,
+                'title' => $this->_esc(_t('_bx_analytics_txt_pageviews_at', $aMinute['pageviews'], gmdate('H:i', $aMinute['t']) . ' UTC')),
+            ];
+
+        $aPlatforms = [];
+        foreach($aData['platforms'] as $aPlatform)
+            $aPlatforms[] = [
+                'label' => $this->_esc($aPlatform['label']),
+                'visitors' => $this->formatValue('number', $aPlatform['visitors']),
+                'share' => $aPlatform['share'],
+            ];
+
+        $aRecent = [];
+        foreach($aData['recent'] as $aItem) {
+            $aMeta = array_filter([
+                $aItem['platform_label'],
+                $aItem['city'] !== '' ? $aItem['city'] . ', ' . $aItem['country_label'] : ($aItem['country'] !== '' ? $aItem['country_label'] : ''),
+                $aItem['device'],
+                $aItem['member'] ? _t('_bx_analytics_txt_member') : '',
+            ]);
+            $aRecent[] = [
+                'time' => bx_time_js($aItem['ts'], BX_FORMAT_DATE_TIME),
+                'title' => $this->_esc($aItem['title'] !== '' ? $aItem['title'] : $aItem['path']),
+                'path' => $this->_esc($aItem['path']),
+                'meta' => $this->_esc(implode(' · ', $aMeta)),
+            ];
+        }
+
+        return $this->parseHtmlByName('realtime.html', [
+            'online' => $this->formatValue('number', $aData['online']),
+            'online_title' => _t('_bx_analytics_txt_online_now', $aData['window']),
+            'bx_if:platforms' => [
+                'condition' => !empty($aPlatforms),
+                'content' => ['bx_repeat:platforms' => $aPlatforms],
+            ],
+            'minutes_label' => $this->_esc(_t('_bx_analytics_txt_pageviews_30m')),
+            'bx_repeat:minutes' => $aMinutes,
+            'axis_start' => _t('_bx_analytics_txt_minutes_ago', 30),
+            'axis_end' => _t('_bx_analytics_txt_now'),
+            'recent_title' => _t('_bx_analytics_txt_latest_pageviews'),
+            'bx_if:recent' => [
+                'condition' => !empty($aRecent),
+                'content' => ['bx_repeat:recent' => $aRecent],
+            ],
+            'bx_if:empty' => [
+                'condition' => empty($aRecent),
+                'content' => ['empty' => _t('_bx_analytics_txt_no_recent')],
+            ],
+        ]);
+    }
+
+    protected function _getMapHtml($aData, $oFilter)
+    {
+        $aCountries = [];
+        foreach(array_slice($aData['countries'], 0, 12) as $aCountry)
+            $aCountries[] = [
+                'value' => $this->_esc($aCountry['country']),
+                'label' => $this->_esc($aCountry['label']),
+                'visitors' => $this->formatValue('number', $aCountry['visitors']),
+                'share' => $aCountry['share'],
+                'bar' => $aCountry['bar'],
+                'filter_title' => $this->_esc(_t('_bx_analytics_txt_filter_by', $aCountry['label'])),
+            ];
+
+        return $this->parseHtmlByName('map.html', [
+            'tabs' => $this->_getTabsHtml('map', $aData['tabs']),
+            'refresh' => $aData['tab'] == 'live' ? 'data-bx-analytics-refresh="30"' : '',
+            'map_label' => $this->_esc(_t('_bx_analytics_txt_map_label')),
+            'map_json' => $this->_json([
+                'countries' => $aData['countries'],
+                'points' => $aData['points'],
+                'provider' => $aData['provider'],
+                'visitors' => _t('_bx_analytics_metric_visitors'),
+            ]),
+            'column' => _t('_bx_analytics_tab_countries'),
+            'visitors' => _t('_bx_analytics_metric_visitors'),
+            'bx_if:countries' => [
+                'condition' => !empty($aCountries),
+                'content' => ['bx_repeat:countries' => $aCountries],
+            ],
+            'bx_if:empty' => [
+                'condition' => empty($aCountries),
+                'content' => ['empty' => _t($aData['tab'] == 'live' ? '_bx_analytics_txt_no_recent' : '_bx_analytics_txt_no_data')],
+            ],
+            'bx_if:unknown' => [
+                'condition' => $aData['unknown'] > 0,
+                'content' => ['unknown' => _t('_bx_analytics_txt_map_unknown', $this->formatValue('number', $aData['unknown']))],
+            ],
+        ]);
+    }
+
+    protected function _getBotsHtml($aData, $oFilter)
+    {
+        return $this->parseHtmlByName('bots.html', [
+            'tabs' => $this->_getTabsHtml('bots', $aData['tabs']),
+            'tiles' => $this->_getTilesHtml($aData['tiles']),
+            'chart_label' => $this->_esc(_t('_bx_analytics_txt_chart_bots')),
+            'chart_json' => $this->_json($aData['chart']),
+            'bots' => $this->_getTableHtml($aData['bots'], $oFilter),
+            'pages_title' => $aData['pages']['caption'],
+            'pages' => $this->_getTableHtml($aData['pages'], $oFilter),
+        ]);
+    }
+
+    protected function _getEventsHtml($aData, $oFilter)
+    {
+        if($aData['view'] == 'list')
+            return $this->_getTableHtml($aData, $oFilter);
+
+        $aProps = [];
+        foreach($aData['props'] as $aTable)
+            $aProps[] = [
+                'title' => $this->_esc($aTable['caption']),
+                'table' => $this->_getTableHtml($aTable, $oFilter),
+            ];
+
+        return $this->parseHtmlByName('event.html', [
+            'back' => _t('_bx_analytics_txt_back_events'),
+            'title' => $this->_esc($aData['title']),
+            'tiles' => $this->_getTilesHtml($aData['tiles']),
+            'chart_label' => $this->_esc(_t('_bx_analytics_txt_chart_event', $aData['title'])),
+            'chart_json' => $this->_json($aData['chart']),
+            'platforms_title' => _t('_bx_analytics_tab_platforms'),
+            'platforms' => $this->_getBarsHtml($aData['platforms'], 'count'),
+            'bx_repeat:props' => $aProps,
+            'bx_if:no_props' => [
+                'condition' => empty($aProps),
+                'content' => [
+                    'props_title' => _t('_bx_analytics_txt_properties'),
+                    'no_props' => _t('_bx_analytics_txt_no_properties'),
+                ],
+            ],
+        ]);
+    }
+
+    protected function _getSessionsHtml($aData, $oFilter)
+    {
+        if($aData['view'] == 'list')
+            return $this->_getTableHtml($aData, $oFilter);
+
+        return $this->_getSessionHtml($aData);
+    }
+
+    protected function _getUsersHtml($aData, $oFilter)
+    {
+        if($aData['view'] == 'list')
+            return $this->_getTableHtml($aData, $oFilter);
+
+        if($aData['view'] == 'detail')
+            return $this->_getSessionHtml($aData);
+
+        return $this->parseHtmlByName('user.html', [
+            'back' => _t('_bx_analytics_txt_back_members'),
+            'member' => $this->_getMemberHtml($aData['member']),
+            'tiles' => $this->_getTilesHtml($aData['tiles']),
+            'sessions_title' => _t('_bx_analytics_lmi_cpt_sessions'),
+            'sessions' => $this->_getTableHtml($aData['sessions'], $oFilter),
+            'platforms_title' => _t('_bx_analytics_tab_platforms'),
+            'platforms' => $this->_getBarsHtml($aData['platforms'], 'count'),
+            'actions_title' => $aData['actions']['caption'],
+            'actions' => $this->_getTableHtml($aData['actions'], $oFilter),
+        ]);
+    }
+
+    protected function _getSessionHtml($aData)
+    {
+        $aFacts = [];
+        foreach($aData['facts'] as $aFact)
+            $aFacts[] = [
+                'title' => $aFact['title'],
+                'value' => $aFact['value'] === '' ? '&mdash;' : $this->formatValue($aFact['type'], $aFact['value']),
+            ];
+
+        $aKinds = [
+            BX_ANALYTICS_HIT_PAGEVIEW => _t('_bx_analytics_event_type_pageview'),
+            BX_ANALYTICS_HIT_EVENT => _t('_bx_analytics_event_type_event'),
+            BX_ANALYTICS_HIT_UNA => _t('_bx_analytics_event_type_una'),
+        ];
+
+        $aTimeline = [];
+        foreach($aData['timeline'] as $aItem)
+            $aTimeline[] = [
+                'type' => $aItem['type'],
+                'offset' => sprintf('%d:%02d', intdiv($aItem['offset'], 60), $aItem['offset'] % 60),
+                'kind' => isset($aKinds[$aItem['type']]) ? $aKinds[$aItem['type']] : $this->_esc($aItem['type']),
+                'label' => $this->_esc($aItem['label']),
+                'bx_if:path' => [
+                    'condition' => $aItem['path'] !== '' && $aItem['path'] !== $aItem['label'],
+                    'content' => ['path' => $this->_esc($aItem['path'])],
+                ],
+                'engaged' => $aItem['engaged'] ? $this->formatValue('duration', $aItem['engaged']) : '',
+            ];
+
+        return $this->parseHtmlByName('session.html', [
+            'back' => _t('_bx_analytics_txt_back_sessions'),
+            'member' => $this->_getMemberHtml($aData['member']),
+            'bx_repeat:facts' => $aFacts,
+            'timeline_title' => _t('_bx_analytics_txt_timeline'),
+            'bx_if:timeline' => [
+                'condition' => !empty($aTimeline),
+                'content' => ['bx_repeat:timeline' => $aTimeline],
+            ],
+            'bx_if:empty' => [
+                'condition' => empty($aTimeline),
+                'content' => ['empty' => _t('_bx_analytics_txt_no_data')],
+            ],
+        ]);
+    }
+
+    protected function _getMemberHtml($aMember)
+    {
+        $bMember = !empty($aMember);
+
+        return $this->parseHtmlByName('member.html', [
+            'bx_if:thumb' => [
+                'condition' => $bMember && !empty($aMember['thumb']),
+                'content' => ['thumb' => $bMember ? $this->_esc($aMember['thumb']) : ''],
+            ],
+            'bx_if:no_thumb' => [
+                'condition' => !$bMember || empty($aMember['thumb']),
+                'content' => [],
+            ],
+            'bx_if:link' => [
+                'condition' => $bMember,
+                'content' => [
+                    'url' => $bMember ? $this->_esc($aMember['url']) : '',
+                    'name' => $bMember ? $this->_esc($aMember['name']) : '',
+                ],
+            ],
+            'bx_if:text' => [
+                'condition' => !$bMember,
+                'content' => ['name' => _t('_bx_analytics_txt_guest')],
+            ],
+        ]);
+    }
+
+    /**
+     * Shares as bars: items with label, a count under `$sKey` and share in percent.
+     */
+    protected function _getBarsHtml($aItems, $sKey)
+    {
+        $iMax = 1;
+        foreach($aItems as $aItem)
+            $iMax = max($iMax, (int)$aItem[$sKey]);
+
+        $aRows = [];
+        foreach($aItems as $aItem)
+            $aRows[] = [
+                'label' => $this->_esc($aItem['label']),
+                'value' => $this->formatValue('number', $aItem[$sKey]),
+                'share' => $aItem['share'],
+                'bar' => round(100 * $aItem[$sKey] / $iMax, 1),
+            ];
+
+        return $this->parseHtmlByName('bars.html', [
+            'bx_if:items' => [
+                'condition' => !empty($aRows),
+                'content' => ['bx_repeat:items' => $aRows],
+            ],
+            'bx_if:empty' => [
+                'condition' => empty($aRows),
+                'content' => ['empty' => _t('_bx_analytics_txt_no_data')],
+            ],
+        ]);
+    }
+
+    protected function _getJourneysHtml($aData, $oFilter)
+    {
+        $aEntries = [];
+        foreach($aData['entries'] as $aEntry)
+            $aEntries[] = [
+                'page' => $this->_esc($aEntry['page']),
+                'label' => $this->_esc($aEntry['page']),
+                'sessions' => $this->formatValue('number', $aEntry['sessions']),
+                'active' => $aEntry['active'] ? 'bx-analytics-row-btn-active' : '',
+                'pressed' => $aEntry['active'] ? 'true' : 'false',
+            ];
+
+        $fNodes = function($aItems, $bOpen) use ($aData) {
+            $aResult = [];
+            foreach($aItems as $aItem) {
+                $bExit = !empty($aItem['exit']);
+                $bActive = !$bExit && $bOpen && $aItem['page'] === $aData['via'];
+                $aResult[] = [
+                    'label' => $bExit ? _t('_bx_analytics_txt_left_site') : $this->_esc($aItem['page']),
+                    'share' => $aItem['share'],
+                    'class' => ($bExit ? 'bx-analytics-flow-exit' : '') . ($bActive ? ' bx-analytics-flow-node-active' : ''),
+                    'attrs' => $bOpen && !$bExit ? 'data-bx-analytics-open="via" data-bx-analytics-value="' . $this->_esc($aItem['page']) . '" aria-pressed="' . ($bActive ? 'true' : 'false') . '"' : 'disabled',
+                ];
+            }
+            return $aResult;
+        };
+
+        $iMax = 1;
+        foreach($aData['paths'] as $aPath)
+            $iMax = max($iMax, $aPath['sessions']);
+
+        $aPathRows = [];
+        foreach($aData['paths'] as $aPath)
+            $aPathRows[] = [
+                'label' => implode(' → ', $aPath['pages']),
+                'bar' => round(100 * $aPath['sessions'] / $iMax, 1),
+                'values' => ['sessions' => $aPath['sessions'], 'share' => $aPath['share']],
+            ];
+
+        return $this->parseHtmlByName('journeys.html', [
+            'entries_title' => _t('_bx_analytics_txt_start_page'),
+            'bx_if:entries' => [
+                'condition' => !empty($aEntries),
+                'content' => ['bx_repeat:entries' => $aEntries],
+            ],
+            'bx_if:no_entries' => [
+                'condition' => empty($aEntries),
+                'content' => ['empty' => _t('_bx_analytics_txt_no_data')],
+            ],
+            'bx_if:flow' => [
+                'condition' => $aData['jfrom'] !== '',
+                'content' => [
+                    'flow_label' => $this->_esc(_t('_bx_analytics_txt_journey_from', $aData['jfrom'])),
+                    'step1' => _t('_bx_analytics_txt_step', 1),
+                    'step2' => _t('_bx_analytics_txt_step', 2),
+                    'step3' => _t('_bx_analytics_txt_step', 3),
+                    'from' => $this->_esc($aData['jfrom']),
+                    'from_sessions' => $this->formatValue('number', $aData['from_sessions']),
+                    'bx_repeat:next' => $fNodes($aData['next'], true),
+                    'bx_repeat:after' => $fNodes($aData['after'], false),
+                    'bx_if:no_after' => [
+                        'condition' => empty($aData['after']),
+                        'content' => ['no_after' => _t('_bx_analytics_txt_pick_next')],
+                    ],
+                ],
+            ],
+            'paths_title' => _t('_bx_analytics_txt_top_paths'),
+            'paths' => $this->_getTableHtml([
+                'name' => 'paths',
+                'caption' => _t('_bx_analytics_txt_top_paths'),
+                'columns' => [
+                    ['key' => 'label', 'type' => 'label', 'title' => _t('_bx_analytics_txt_path')],
+                    ['key' => 'sessions', 'type' => 'number', 'title' => _t('_bx_analytics_metric_sessions')],
+                    ['key' => 'share', 'type' => 'percent', 'title' => _t('_bx_analytics_txt_share')],
+                ],
+                'rows' => $aPathRows,
+            ], $oFilter),
+            'bx_if:truncated' => [
+                'condition' => $aData['truncated'],
+                'content' => ['truncated' => _t('_bx_analytics_txt_journeys_truncated')],
+            ],
+        ]);
+    }
+
+    protected function _getRetentionHtml($aData, $oFilter)
+    {
+        $aWeeks = [];
+        for($i = 0; $i < $aData['weeks']; $i++)
+            $aWeeks[] = ['week' => _t('_bx_analytics_txt_week_n', $i)];
+
+        $oTz = new DateTimeZone($aData['tz']);
+        $aRows = [];
+        foreach($aData['rows'] as $aRow) {
+            $aCells = [];
+            foreach($aRow['weeks'] as $mixedPercent)
+                $aCells[] = [
+                    'heat' => $mixedPercent !== null ? round($mixedPercent / 100, 3) : 0,
+                    'strong' => $mixedPercent !== null && $mixedPercent >= 45 ? 'bx-analytics-cohort-strong' : '',
+                    'percent' => $mixedPercent !== null && $aRow['size'] ? $this->formatValue('percent', $mixedPercent) : '',
+                ];
+
+            $aRows[] = [
+                'start' => (new DateTime('@' . $aRow['start']))->setTimezone($oTz)->format('Y-m-d'),
+                'size' => $this->formatValue('number', $aRow['size']),
+                'bx_repeat:cells' => $aCells,
+            ];
+        }
+
+        return $this->parseHtmlByName('retention.html', [
+            'tabs' => $this->_getTabsHtml('retention', $aData['tabs']),
+            'bx_if:cookieless' => [
+                'condition' => $aData['cookieless'],
+                'content' => ['cookieless' => _t('_bx_analytics_txt_retention_cookieless')],
+            ],
+            'caption' => $this->_esc(_t('_bx_analytics_lmi_cpt_retention')),
+            'cohort_title' => _t('_bx_analytics_txt_cohort'),
+            'size_title' => _t('_bx_analytics_metric_visitors'),
+            'bx_repeat:weeks' => $aWeeks,
+            'bx_repeat:rows' => $aRows,
+        ]);
+    }
+
+    protected function _getGoalsHtml($aData, $oFilter)
+    {
+        $sCsrf = $aData['manage'] ? (string)BxDolForm::getCsrfToken() : '';
+        $aTypes = ['page' => _t('_bx_analytics_txt_goal_type_page'), 'event' => _t('_bx_analytics_txt_goal_type_event')];
+
+        $fMax = 0;
+        foreach($aData['goals'] as $aGoal)
+            $fMax = max($fMax, $aGoal['rate']);
+
+        $aGoals = [];
+        $aEdit = null;
+        foreach($aData['goals'] as $aGoal) {
+            if($aData['edit'] !== '' && $aGoal['id'] === (int)$aData['edit'])
+                $aEdit = $aGoal;
+
+            $aGoals[] = [
+                'id' => $aGoal['id'],
+                'name' => $this->_esc($aGoal['name']),
+                'type' => _t('_bx_analytics_txt_goal_badge_' . $aGoal['type']),
+                'pattern' => $this->_esc($aGoal['pattern']),
+                'rate' => $this->formatValue('percent', $aGoal['rate']),
+                'change' => $this->_formatChange($aGoal['change']),
+                'trend' => $this->_trend('', $aGoal['change']),
+                'conversions' => _t('_bx_analytics_txt_conversions', $this->formatValue('number', $aGoal['conversions']), $this->formatValue('number', $aGoal['visitors'])),
+                'bar' => $fMax ? round(100 * $aGoal['rate'] / $fMax, 1) : 0,
+                'bx_if:actions' => [
+                    'condition' => $aData['manage'],
+                    'content' => [
+                        'id' => $aGoal['id'],
+                        'edit' => _t('_bx_analytics_txt_edit'),
+                        'delete' => _t('_bx_analytics_txt_delete'),
+                        'confirm' => $this->_esc(_t('_bx_analytics_txt_goal_delete_confirm', $aGoal['name'])),
+                        'csrf' => $this->_esc($sCsrf),
+                    ],
+                ],
+            ];
+        }
+
+        $bForm = $aData['manage'] && ($aEdit || $aData['edit'] === 'new');
+
+        return $this->parseHtmlByName('goals.html', [
+            'bx_if:goals' => [
+                'condition' => !empty($aGoals),
+                'content' => ['bx_repeat:goals' => $aGoals],
+            ],
+            'bx_if:empty' => [
+                'condition' => empty($aGoals),
+                'content' => ['empty' => _t('_bx_analytics_txt_no_goals')],
+            ],
+            'bx_if:add' => [
+                'condition' => $aData['manage'] && !$bForm,
+                'content' => ['add' => _t('_bx_analytics_txt_add_goal')],
+            ],
+            'bx_if:form' => [
+                'condition' => $bForm,
+                'content' => [
+                    'form_title' => _t($aEdit ? '_bx_analytics_txt_edit_goal' : '_bx_analytics_txt_add_goal'),
+                    'form_id' => $aEdit ? $aEdit['id'] : 0,
+                    'form_csrf' => $this->_esc($sCsrf),
+                    'form_name' => $aEdit ? $this->_esc($aEdit['name']) : '',
+                    'form_pattern' => $aEdit ? $this->_esc($aEdit['pattern']) : '',
+                    'page_selected' => !$aEdit || $aEdit['type'] == 'page' ? 'selected' : '',
+                    'event_selected' => $aEdit && $aEdit['type'] == 'event' ? 'selected' : '',
+                    'label_name' => _t('_bx_analytics_txt_name'),
+                    'label_type' => _t('_bx_analytics_txt_goal_type'),
+                    'label_pattern' => _t('_bx_analytics_txt_goal_match'),
+                    'type_page' => $aTypes['page'],
+                    'type_event' => $aTypes['event'],
+                    'hint' => _t('_bx_analytics_txt_match_hint'),
+                    'save' => _t('_bx_analytics_txt_save'),
+                    'cancel' => _t('_bx_analytics_txt_cancel'),
+                ],
+            ],
+        ]);
+    }
+
+    protected function _getFunnelsHtml($aData, $oFilter)
+    {
+        $sCsrf = $aData['manage'] ? (string)BxDolForm::getCsrfToken() : '';
+        $aTypes = ['page' => _t('_bx_analytics_txt_goal_type_page'), 'event' => _t('_bx_analytics_txt_goal_type_event')];
+
+        $aList = [];
+        foreach($aData['funnels'] as $aFunnel)
+            $aList[] = [
+                'id' => $aFunnel['id'],
+                'name' => $this->_esc($aFunnel['name']),
+                'active' => $aFunnel['active'] ? 'bx-analytics-chip-active' : '',
+                'pressed' => $aFunnel['active'] ? 'true' : 'false',
+            ];
+
+        $aSteps = [];
+        foreach($aData['steps'] as $i => $aStep)
+            $aSteps[] = [
+                'number' => $i + 1,
+                'pattern' => $this->_esc($aStep['pattern']),
+                'type' => _t('_bx_analytics_txt_goal_badge_' . $aStep['type']),
+                'rate_bar' => $aStep['rate'],
+                'sessions' => _t('_bx_analytics_txt_n_sessions', $this->formatValue('number', $aStep['sessions'])),
+                'rate' => $this->formatValue('percent', $aStep['rate']),
+                'bx_if:drop' => [
+                    'condition' => $aStep['drop'] !== null,
+                    'content' => ['drop' => _t('_bx_analytics_txt_dropped', $this->formatValue('percent', (float)$aStep['drop']))],
+                ],
+            ];
+
+        $sEdit = (string)$aData['edit'];
+        $bNew = $sEdit === 'new';
+        $bEdit = $aData['funnel'] && $sEdit !== '' && (int)$sEdit === $aData['funnel']['id'];
+        $bForm = $aData['manage'] && ($bNew || $bEdit);
+
+        $aRows = [];
+        $aDefinition = $bEdit ? $aData['definition'] : [];
+        for($i = 0; $i < $aData['max_steps']; $i++) {
+            $aStep = isset($aDefinition[$i]) ? $aDefinition[$i] : ['type' => 'page', 'pattern' => ''];
+            $aRows[] = [
+                'row_number' => $i + 1,
+                'row_type_label' => _t('_bx_analytics_txt_step_type', $i + 1),
+                'row_pattern_label' => _t('_bx_analytics_txt_step_match', $i + 1),
+                'row_page' => $aStep['type'] == 'page' ? 'selected' : '',
+                'row_type_page' => $aTypes['page'],
+                'row_type_event' => $aTypes['event'],
+                'row_event' => $aStep['type'] == 'event' ? 'selected' : '',
+                'row_pattern' => $this->_esc($aStep['pattern']),
+            ];
+        }
+
+        return $this->parseHtmlByName('funnels.html', [
+            'bx_if:list' => [
+                'condition' => count($aList) > 1,
+                'content' => [
+                    'list_label' => $this->_esc(_t('_bx_analytics_lmi_cpt_funnels')),
+                    'bx_repeat:list' => $aList,
+                ],
+            ],
+            'bx_if:manage' => [
+                'condition' => $aData['manage'] && !$bForm,
+                'content' => [
+                    'add' => _t('_bx_analytics_txt_add_funnel'),
+                    'bx_if:current' => [
+                        'condition' => (bool)$aData['funnel'],
+                        'content' => [
+                            'current_id' => $aData['funnel'] ? $aData['funnel']['id'] : 0,
+                            'edit' => _t('_bx_analytics_txt_edit'),
+                            'delete' => _t('_bx_analytics_txt_delete'),
+                            'confirm' => $this->_esc(_t('_bx_analytics_txt_funnel_delete_confirm', $aData['funnel'] ? $aData['funnel']['name'] : '')),
+                            'csrf' => $this->_esc($sCsrf),
+                        ],
+                    ],
+                ],
+            ],
+            'bx_if:steps' => [
+                'condition' => !empty($aSteps) && !$bForm,
+                'content' => ['bx_repeat:steps' => $aSteps],
+            ],
+            'bx_if:empty' => [
+                'condition' => empty($aSteps) && !$bForm,
+                'content' => ['empty' => _t('_bx_analytics_txt_no_funnels')],
+            ],
+            'bx_if:form' => [
+                'condition' => $bForm,
+                'content' => [
+                    'form_title' => _t($bEdit ? '_bx_analytics_txt_edit_funnel' : '_bx_analytics_txt_add_funnel'),
+                    'form_id' => $bEdit ? $aData['funnel']['id'] : 0,
+                    'form_csrf' => $this->_esc($sCsrf),
+                    'form_name' => $bEdit ? $this->_esc($aData['funnel']['name']) : '',
+                    'label_name' => _t('_bx_analytics_txt_name'),
+                    'bx_repeat:rows' => $aRows,
+                    'hint' => _t('_bx_analytics_txt_funnel_hint'),
+                    'save' => _t('_bx_analytics_txt_save'),
+                    'cancel' => _t('_bx_analytics_txt_cancel'),
+                ],
+            ],
+        ]);
+    }
+
+    protected function _getRevenueHtml($aData, $oFilter)
+    {
+        return $this->parseHtmlByName('revenue.html', [
+            'tiles' => $this->_getTilesHtml($aData['tiles']),
+            'chart_label' => $this->_esc(_t('_bx_analytics_txt_chart_revenue')),
+            'chart_json' => $this->_json($aData['chart']),
+            'split' => $this->_getTableHtml($aData['split'], $oFilter),
+            'notes' => $this->_getNotesHtml($aData['notes']),
+            'import' => $this->_getImportHtml($aData['import']),
+        ]);
+    }
+
+    protected function _getSubscriptionsHtml($aData, $oFilter)
+    {
+        return $this->parseHtmlByName('subscriptions.html', [
+            'tiles' => $this->_getTilesHtml($aData['tiles']),
+            'chart_label' => $this->_esc(_t('_bx_analytics_txt_chart_mrr')),
+            'chart_json' => $this->_json($aData['chart']),
+            'movement_title' => _t('_bx_analytics_txt_sbs_movement'),
+            'movement_label' => $this->_esc(_t('_bx_analytics_txt_sbs_movement')),
+            'movement_json' => $this->_json($aData['movement']),
+            'plans_title' => $this->_esc($aData['plans']['caption']),
+            'plans' => $this->_getTableHtml($aData['plans'], $oFilter),
+            'reasons_title' => $this->_esc($aData['reasons']['caption']),
+            'reasons' => $this->_getTableHtml($aData['reasons'], $oFilter),
+            'notes' => $this->_getNotesHtml($aData['notes']),
+        ]);
+    }
+
+    protected function _getSalesHtml($aData, $oFilter)
+    {
+        return $this->parseHtmlByName('sales.html', [
+            'tiles' => $this->_getTilesHtml($aData['tiles']),
+            'items_title' => $this->_esc($aData['items']['caption']),
+            'items' => $this->_getTableHtml($aData['items'], $oFilter),
+            'sellers_title' => $this->_esc($aData['sellers']['caption']),
+            'sellers' => $this->_getTableHtml($aData['sellers'], $oFilter),
+            'bx_if:invoices' => [
+                'condition' => !empty($aData['invoices']),
+                'content' => [
+                    'invoices_title' => _t('_bx_analytics_txt_sales_invoices'),
+                    'invoices' => !empty($aData['invoices']) ? $this->_getTilesHtml($aData['invoices']) : '',
+                ],
+            ],
+            'notes' => $this->_getNotesHtml($aData['notes']),
+        ]);
+    }
+
+    protected function _getCreditsHtml($aData, $oFilter)
+    {
+        if(empty($aData['available']))
+            return $this->parseHtmlByName('credits.html', [
+                'bx_if:credits' => ['condition' => false, 'content' => []],
+                'bx_if:none' => [
+                    'condition' => true,
+                    'content' => ['empty' => _t('_bx_analytics_txt_credits_none')],
+                ],
+                'notes' => '',
+            ]);
+
+        return $this->parseHtmlByName('credits.html', [
+            'bx_if:credits' => [
+                'condition' => true,
+                'content' => [
+                    'tiles' => $this->_getTilesHtml($aData['tiles']),
+                    'chart_label' => $this->_esc(_t('_bx_analytics_txt_chart_credits')),
+                    'chart_json' => $this->_json($aData['chart']),
+                    'spenders_title' => $this->_esc($aData['spenders']['caption']),
+                    'spenders' => $this->_getTableHtml($aData['spenders'], $oFilter),
+                    'spent_title' => $this->_esc($aData['spent']['caption']),
+                    'spent' => $this->_getTableHtml($aData['spent'], $oFilter),
+                ],
+            ],
+            'bx_if:none' => ['condition' => false, 'content' => []],
+            'notes' => $this->_getNotesHtml($aData['notes']),
+        ]);
+    }
+
+    /**
+     * Errors: the list with its key figures, or one error group. Messages, sources and
+     * stacks come from visitors' browsers and apps: every one is escaped.
+     */
+    protected function _getErrorsHtml($aData, $oFilter)
+    {
+        if($aData['view'] == 'list')
+            return $this->parseHtmlByName('errors.html', [
+                'tiles' => $this->_getTilesHtml($aData['tiles']),
+                'table' => $this->_getTableHtml($aData, $oFilter),
+            ]);
+
+        if(!empty($aData['missing']))
+            return $this->parseHtmlByName('error_missing.html', [
+                'back' => _t('_bx_analytics_txt_back_errors'),
+                'text' => $this->_esc($aData['title']),
+            ]);
+
+        $sCsrf = $aData['manage'] ? (string)BxDolForm::getCsrfToken() : '';
+        $aActions = [];
+        foreach($aData['actions'] as $aAction)
+            $aActions[] = [
+                'action_op' => $aAction['op'],
+                'action_id' => (int)$aData['id'],
+                'action_csrf' => $this->_esc($sCsrf),
+                'action_title' => $aAction['title'],
+            ];
+
+        $aFacts = [];
+        foreach($aData['facts'] as $aFact)
+            $aFacts[] = [
+                'fact_title' => $aFact['title'],
+                'fact_value' => $this->formatValue($aFact['type'], $aFact['value']),
+            ];
+
+        $aBreakdowns = [];
+        foreach($aData['breakdowns'] as $aTable)
+            $aBreakdowns[] = [
+                'breakdown_title' => $this->_esc($aTable['caption']),
+                'breakdown' => $this->_getTableHtml($aTable, $oFilter),
+            ];
+
+        $bStack = $aData['stack'] !== '';
+
+        return $this->parseHtmlByName('error.html', [
+            'back' => _t('_bx_analytics_txt_back_errors'),
+            'title' => $this->_esc($aData['title']),
+            'bx_if:actions' => [
+                'condition' => !empty($aActions),
+                'content' => [
+                    'actions_label' => $this->_esc(_t('_bx_analytics_txt_error_actions')),
+                    'bx_repeat:actions' => $aActions,
+                ],
+            ],
+            'bx_repeat:facts' => $aFacts,
+            'tiles' => $this->_getTilesHtml($aData['tiles']),
+            'chart_label' => $this->_esc(_t('_bx_analytics_txt_chart_error', $aData['title'])),
+            'chart_json' => $this->_json($aData['chart']),
+            'stack_title' => _t('_bx_analytics_txt_error_stack'),
+            'bx_if:no_stack' => [
+                'condition' => !$bStack,
+                'content' => ['no_stack' => _t($aData['stack_hidden'] ? '_bx_analytics_txt_error_stack_hidden' : '_bx_analytics_txt_error_no_stack')],
+            ],
+            'bx_if:breakdowns' => [
+                'condition' => !empty($aBreakdowns),
+                'content' => ['bx_repeat:breakdowns' => $aBreakdowns],
+            ],
+            'latest_title' => $this->_esc($aData['occurrences']['caption']),
+            'latest' => $this->_getTableHtml($aData['occurrences'], $oFilter),
+            // last: visitor text goes in after every placeholder is filled
+            'bx_if:stack' => [
+                'condition' => $bStack,
+                'content' => [
+                    'stack_label' => $this->_esc(_t('_bx_analytics_txt_error_stack')),
+                    'stack' => $this->_esc($aData['stack']),
+                ],
+            ],
+        ]);
+    }
+
+    /**
+     * Performance: a card per metric (75th percentile, rating, distribution) that picks the
+     * metric for the chart and the tables.
+     */
+    protected function _getPerformanceHtml($aData, $oFilter)
+    {
+        $aDevices = [];
+        foreach($aData['devices'] as $aDevice)
+            $aDevices[] = [
+                'device_value' => $aDevice['value'],
+                'device_title' => $aDevice['title'],
+                'device_selected' => $aDevice['selected'] ? 'selected="selected"' : '',
+            ];
+
+        $aCards = [];
+        foreach($aData['cards'] as $aCard)
+            $aCards[] = [
+                'card_metric' => $aCard['metric'],
+                'card_title' => $aCard['title'],
+                'card_value' => $aCard['value'] !== null ? $this->formatValue($aCard['unit'], $aCard['value']) : _t('_bx_analytics_txt_not_enough_data'),
+                'card_rating' => $aCard['rating'] !== '' ? str_replace('_', '-', $aCard['rating']) : 'none',
+                'card_rating_title' => $aCard['rating'] !== '' ? _t('_bx_analytics_rating_' . $aCard['rating']) : '',
+                'card_pressed' => $aCard['active'] ? 'true' : 'false',
+                'card_good' => $aCard['good'],
+                'card_ni' => $aCard['needs_improvement'],
+                'card_poor' => $aCard['poor'],
+                'card_dist' => $aCard['samples'] ? _t('_bx_analytics_txt_vital_distribution', $this->formatValue('percent', $aCard['good']), $this->formatValue('percent', $aCard['needs_improvement']), $this->formatValue('percent', $aCard['poor'])) : '',
+                'card_samples' => _t('_bx_analytics_txt_n_samples', $this->formatValue('number', $aCard['samples'])),
+                'card_thresholds' => _t('_bx_analytics_txt_vital_thresholds', $this->formatValue($aCard['unit'], $aCard['good_max']), $this->formatValue($aCard['unit'], $aCard['poor_min'])),
+            ];
+
+        return $this->parseHtmlByName('performance.html', [
+            'device_label' => _t('_bx_analytics_txt_device'),
+            'bx_repeat:devices' => $aDevices,
+            'cards_label' => $this->_esc(_t('_bx_analytics_txt_vitals_metrics')),
+            'bx_repeat:cards' => $aCards,
+            'chart_title' => $this->_esc(_t('_bx_analytics_txt_chart_vital', $aData['metric_title'])),
+            'chart_label' => $this->_esc(_t('_bx_analytics_txt_chart_vital', $aData['metric_title'])),
+            'chart_json' => $this->_json($aData['chart']),
+            'platforms_title' => $this->_esc($aData['platforms']['caption']),
+            'platforms' => $this->_getTableHtml($aData['platforms'], $oFilter),
+            'pages_title' => $this->_esc($aData['pages']['caption']),
+            'pages' => $this->_getTableHtml($aData['pages'], $oFilter),
+        ]);
+    }
+
+    /**
+     * A sparkline of values over time, scaled to its own maximum.
+     */
+    protected function _getSparkHtml($aValues)
+    {
+        $aValues = array_values($aValues);
+        $iCount = count($aValues);
+        if(!$iCount)
+            return '';
+
+        $fMax = max(1, max($aValues));
+        $aPoints = [];
+        foreach($aValues as $i => $mixedValue)
+            $aPoints[] = round($iCount > 1 ? 100 * $i / ($iCount - 1) : 50, 2) . ',' . round(22 - 20 * (float)$mixedValue / $fMax, 2);
+        if($iCount == 1)
+            $aPoints[] = '100,' . round(22 - 20 * (float)$aValues[0] / $fMax, 2);
+
+        return $this->parseHtmlByName('spark.html', [
+            'points' => implode(' ', $aPoints),
+        ]);
+    }
+
+    /**
+     * Short notes under a report: how amounts are counted, what is missing.
+     */
+    protected function _getNotesHtml($aNotes)
+    {
+        if(empty($aNotes))
+            return '';
+
+        $aItems = [];
+        foreach($aNotes as $sNote)
+            $aItems[] = ['note' => $this->_esc($sNote)];
+
+        return $this->parseHtmlByName('notes.html', [
+            'bx_repeat:notes' => $aItems,
+        ]);
+    }
+
+    /**
+     * The import of past payments, with a button to run a step now, for administrators.
+     */
+    protected function _getImportHtml($aImport)
+    {
+        if(empty($aImport) || empty($aImport['state']))
+            return '';
+
+        $bRunning = $aImport['state'] == 'running';
+
+        return $this->parseHtmlByName('import.html', [
+            'title' => _t('_bx_analytics_txt_import_title'),
+            'text' => $this->_esc(_t($bRunning ? '_bx_analytics_txt_import_running' : '_bx_analytics_txt_import_done', $this->formatValue('number', $aImport['imported']))),
+            'hint' => _t('_bx_analytics_txt_import_hint'),
+            'button' => _t($bRunning ? '_bx_analytics_txt_import_continue' : '_bx_analytics_txt_import_again'),
+            'op' => $bRunning ? 'import' : 'restart',
+            'confirm' => $bRunning ? '' : $this->_esc(_t('_bx_analytics_txt_import_confirm')),
+            'csrf' => $this->_esc((string)BxDolForm::getCsrfToken()),
+        ]);
+    }
+
+    /**
+     * Key figures that are not chart switches: title, value and an optional note or change.
+     */
+    protected function _getTilesHtml($aTiles)
+    {
+        $aItems = [];
+        foreach($aTiles as $aTile)
+            $aItems[] = [
+                'title' => $aTile['title'],
+                // nothing to measure (a rate without a base): say so instead of an empty tile
+                'value' => $aTile['value'] === null ? _t('_bx_analytics_txt_not_available') : $this->formatValue(isset($aTile['type']) ? $aTile['type'] : 'number', $aTile['value']),
+                'bx_if:sub' => [
+                    'condition' => !empty($aTile['sub']),
+                    'content' => ['sub' => !empty($aTile['sub']) ? $this->_esc($aTile['sub']) : ''],
+                ],
+                'bx_if:change' => [
+                    'condition' => isset($aTile['change']) && $aTile['change'] !== null,
+                    'content' => [
+                        'change' => isset($aTile['change']) ? $this->_formatChange($aTile['change']) : '',
+                        'trend' => isset($aTile['change']) ? $this->_trend(isset($aTile['metric']) ? $aTile['metric'] : '', $aTile['change']) : 'flat',
+                    ],
+                ],
+            ];
+
+        return $this->parseHtmlByName('tiles.html', [
+            'bx_repeat:tiles' => $aItems,
+        ]);
+    }
+
+    /**
+     * Query: what to break down and sort by, the first values over time, the table, and the
+     * CSV download of every value.
+     */
+    protected function _getQueryHtml($aData, $oFilter)
+    {
+        $aDimensions = ['event' => [], 'session' => []];
+        foreach($aData['dimensions'] as $aDimension)
+            $aDimensions[$aDimension['kind']][] = [
+                'dim_value' => $aDimension['name'],
+                'dim_title' => $aDimension['title'],
+                'dim_selected' => $aDimension['name'] == $aData['dimension'] ? 'selected="selected"' : '',
+            ];
+
+        $aMetrics = [];
+        foreach($aData['metrics'] as $aMetric)
+            $aMetrics[] = [
+                'metric_value' => $aMetric['name'],
+                'metric_title' => $aMetric['title'],
+                'metric_selected' => $aMetric['name'] == $aData['metric'] ? 'selected="selected"' : '',
+            ];
+
+        $aDirs = [];
+        foreach(['desc', 'asc'] as $sDir)
+            $aDirs[] = [
+                'dir_value' => $sDir,
+                'dir_title' => _t('_bx_analytics_txt_sort_' . $sDir),
+                'dir_selected' => $sDir == $aData['dir'] ? 'selected="selected"' : '',
+            ];
+
+        $bRows = !empty($aData['rows']);
+        $sExportUrl = bx_append_url_params(BX_DOL_URL_ROOT . $this->_oConfig->getBaseUri() . 'export/query/', http_build_query(array_merge($oFilter->getParams(), [
+            'q_dim' => $aData['dimension'],
+            'q_metric' => $aData['metric'],
+            'q_dir' => $aData['dir'],
+        ]), '', '&', PHP_QUERY_RFC3986));
+
+        return $this->parseHtmlByName('query.html', [
+            'dimension_label' => _t('_bx_analytics_txt_dimension'),
+            'event_dims_label' => _t('_bx_analytics_txt_dims_event'),
+            'bx_repeat:event_dims' => $aDimensions['event'],
+            'session_dims_label' => _t('_bx_analytics_txt_dims_session'),
+            'bx_repeat:session_dims' => $aDimensions['session'],
+            'metric_label' => _t('_bx_analytics_txt_metric'),
+            'bx_repeat:metrics' => $aMetrics,
+            'dir_label' => _t('_bx_analytics_txt_sort'),
+            'bx_repeat:dirs' => $aDirs,
+            'bx_if:download' => [
+                'condition' => $bRows,
+                'content' => [
+                    'export_url' => $this->_esc($sExportUrl),
+                    'download' => _t('_bx_analytics_txt_download_csv'),
+                ],
+            ],
+            'bx_if:chart' => [
+                'condition' => $bRows,
+                'content' => [
+                    'chart_label' => $this->_esc(_t('_bx_analytics_txt_chart_query', _t('_bx_analytics_metric_' . $aData['metric']), count($aData['chart']['series']))),
+                    'chart_json' => $this->_json($aData['chart']),
+                ],
+            ],
+            'table' => $this->_getTableHtml($aData, $oFilter),
+            'bx_if:truncated' => [
+                'condition' => $aData['total'] > $aData['export_max'],
+                'content' => [
+                    'truncated' => _t('_bx_analytics_txt_csv_truncated', $this->formatValue('number', $aData['export_max']), $this->formatValue('number', $aData['total'])),
+                ],
+            ],
+        ]);
+    }
+
+    /**
+     * A table report: columns with types, rows with a label cell that filters, links or is plain.
+     */
+    protected function _getTableHtml($aTable, $oFilter)
+    {
+        $aColumns = [];
+        foreach($aTable['columns'] as $aColumn)
+            $aColumns[] = [
+                'type' => $aColumn['type'],
+                'title' => $aColumn['title'],
+                // the column the rows are sorted by: ascending or descending
+                'sort' => isset($aColumn['sort']) && in_array($aColumn['sort'], ['ascending', 'descending'], true) ? 'aria-sort="' . $aColumn['sort'] . '"' : '',
+            ];
+
+        $aRows = [];
+        foreach($aTable['rows'] as $aRow) {
+            $aCells = [];
+            foreach($aTable['columns'] as $aColumn) {
+                $sKey = $aColumn['key'];
+                $bLabel = $aColumn['type'] == 'label';
+
+                $aCells[] = [
+                    'type' => $aColumn['type'],
+                    'bx_if:label' => [
+                        'condition' => $bLabel,
+                        'content' => $bLabel ? $this->_getLabelCell($aRow) : [],
+                    ],
+                    'bx_if:value' => [
+                        'condition' => !$bLabel,
+                        'content' => [
+                            'cell_value' => !$bLabel ? $this->formatValue($aColumn['type'], isset($aRow['values'][$sKey]) ? $aRow['values'][$sKey] : null) : '',
+                        ],
+                    ],
+                ];
+            }
+
+            $aRows[] = ['bx_repeat:cells' => $aCells];
+        }
+
+        $aPager = isset($aTable['pages']) && $aTable['pages'] > 1 ? [
+            'name' => $aTable['name'],
+            'page' => $aTable['page'],
+            'pages' => $aTable['pages'],
+            'prev' => max(1, $aTable['page'] - 1),
+            'next' => min($aTable['pages'], $aTable['page'] + 1),
+            'prev_disabled' => $aTable['page'] <= 1 ? 'disabled' : '',
+            'next_disabled' => $aTable['page'] >= $aTable['pages'] ? 'disabled' : '',
+            'txt_prev' => _t('_bx_analytics_txt_prev'),
+            'txt_next' => _t('_bx_analytics_txt_next'),
+            'txt_page' => _t('_bx_analytics_txt_page_of', $aTable['page'], $aTable['pages']),
+        ] : [];
+
+        return $this->parseHtmlByName('table.html', [
+            'tabs' => !empty($aTable['tabs']) ? $this->_getTabsHtml($aTable['name'], $aTable['tabs']) : '',
+            'panel_attrs' => !empty($aTable['tabs']) ? 'role="tabpanel" id="bx-analytics-panel-' . $aTable['name'] . '"' : '',
+            'name' => $aTable['name'],
+            'caption' => $this->_esc($aTable['caption']),
+            'bx_if:rows' => [
+                'condition' => !empty($aRows),
+                'content' => [
+                    'caption' => $this->_esc($aTable['caption']),
+                    'bx_repeat:columns' => $aColumns,
+                    'bx_repeat:rows' => $aRows,
+                ],
+            ],
+            'bx_if:empty' => [
+                'condition' => empty($aRows),
+                'content' => ['empty' => !empty($aTable['empty']) ? $aTable['empty'] : _t('_bx_analytics_txt_no_data')],
+            ],
+            'bx_if:pager' => [
+                'condition' => !empty($aPager),
+                'content' => $aPager,
+            ],
+        ]);
+    }
+
+    protected function _getLabelCell($aRow)
+    {
+        $sLabel = $this->_esc($aRow['label']);
+        $sSub = isset($aRow['sub']) ? $this->_esc($aRow['sub']) : '';
+        $bFilter = !empty($aRow['filter']);
+        $bLink = !$bFilter && !empty($aRow['link']);
+        $bOpen = !$bFilter && !$bLink && !empty($aRow['open']);
+
+        return [
+            'bar' => isset($aRow['bar']) ? $aRow['bar'] : 0,
+            // placeholder names are unique across the row: the parser fills every one with
+            // the same name from any part of the row
+            'bx_if:filter' => [
+                'condition' => $bFilter,
+                'content' => [
+                    'filter_dim' => $bFilter ? $aRow['filter']['dim'] : '',
+                    'filter_value' => $bFilter ? $this->_esc($aRow['filter']['value']) : '',
+                    'filter_title' => $this->_esc(_t('_bx_analytics_txt_filter_by', $aRow['label'])),
+                    'label' => $sLabel,
+                ],
+            ],
+            'bx_if:link' => [
+                'condition' => $bLink,
+                'content' => [
+                    'link_href' => $bLink ? $this->_esc($aRow['link']) : '',
+                    'label' => $sLabel,
+                ],
+            ],
+            'bx_if:open' => [
+                'condition' => $bOpen,
+                'content' => [
+                    'open_param' => $bOpen ? $aRow['open']['param'] : '',
+                    'open_value' => $bOpen ? $this->_esc($aRow['open']['value']) : '',
+                    'label' => $sLabel,
+                ],
+            ],
+            'bx_if:text' => [
+                'condition' => !$bFilter && !$bLink && !$bOpen,
+                'content' => ['label' => $sLabel],
+            ],
+            'bx_if:sub' => [
+                'condition' => $sSub !== '',
+                'content' => ['sub' => $sSub],
+            ],
+        ];
+    }
+
+    protected function _getTabsHtml($sGroup, $aTabs)
+    {
+        if(count($aTabs) < 2)
+            return '';
+
+        $aItems = [];
+        foreach($aTabs as $aTab)
+            $aItems[] = [
+                'group' => $sGroup,
+                'name' => $aTab['name'],
+                'title' => $aTab['title'],
+                'selected' => $aTab['active'] ? 'true' : 'false',
+                'tabindex' => $aTab['active'] ? '0' : '-1',
+            ];
+
+        return $this->parseHtmlByName('tabs.html', [
+            'group' => $sGroup,
+            'label' => $this->_esc(_t('_bx_analytics_group_' . $sGroup)),
+            'bx_repeat:tabs' => $aItems,
+        ]);
+    }
+
+    /**
+     * Studio > API: pick a report, edit its parameters, see the call as bx_srv, as an
+     * api.php URL and as NEO's request, and run it.
+     *
+     * @param array $aData catalog (see BxAnalyticsModule::getApiCatalog), api_enabled, api_access
+     */
+    public function getStudioApi($aData)
+    {
+        $aCatalog = $aData['catalog'];
+
+        $aNotices = [];
+        if(!$aData['api_enabled'])
+            $aNotices[] = ['notice' => $this->_esc(_t('_bx_analytics_txt_api_disabled'))];
+        elseif(!$aData['api_access'])
+            $aNotices[] = ['notice' => $this->_esc(_t('_bx_analytics_txt_api_no_access'))];
+
+        // a top list first: it shows what parameters do
+        $sSelected = isset($aCatalog['reports']['top']) ? 'top' : (string)key($aCatalog['reports']);
+
+        $aReports = [];
+        $aDocs = [];
+        foreach($aCatalog['reports'] as $sReport => $aParams) {
+            $aReports[] = [
+                'report_value' => $sReport,
+                'report_title' => $this->_esc(_t('_bx_analytics_report_' . $sReport)),
+                'report_selected' => $sReport == $sSelected ? 'selected="selected"' : '',
+            ];
+
+            $aDocs[] = [
+                'doc_report' => $sReport,
+                'doc_hidden' => $sReport == $sSelected ? '' : 'hidden',
+                'doc_table' => $this->_getApiParamsHtml($aParams, _t('_bx_analytics_txt_api_report_params', _t('_bx_analytics_report_' . $sReport))),
+            ];
+        }
+
+        $this->_addToolsAssets();
+
+        return $this->_getToolsJsInit() . $this->parseHtmlByName('studio_api.html', [
+            'bx_if:notices' => [
+                'condition' => !empty($aNotices),
+                'content' => ['bx_repeat:notices' => $aNotices],
+            ],
+            'intro' => _t('_bx_analytics_txt_api_intro'),
+            'csrf' => $this->_esc((string)BxDolForm::getCsrfToken()),
+            'report_label' => _t('_bx_analytics_txt_api_report'),
+            'bx_repeat:reports' => $aReports,
+            'params_label' => _t('_bx_analytics_txt_api_params'),
+            'params_hint' => _t('_bx_analytics_txt_api_params_hint'),
+            'docs_label' => _t('_bx_analytics_txt_api_docs'),
+            'params_default' => $this->_esc($sSelected == 'top' ? '{"group": "pages", "range": "7d"}' : '{"range": "7d"}'),
+            'common_table' => $this->_getApiParamsHtml($aCatalog['common'], _t('_bx_analytics_txt_api_common_params')),
+            'bx_repeat:docs' => $aDocs,
+            'code_srv' => $this->_getCodeHtml('bx-analytics-api-srv', _t('_bx_analytics_txt_api_srv'), ''),
+            'code_url' => $this->_getCodeHtml('bx-analytics-api-url', _t('_bx_analytics_txt_api_url'), ''),
+            'code_neo' => $this->_getCodeHtml('bx-analytics-api-neo', _t('_bx_analytics_txt_api_neo'), ''),
+            'run' => _t('_bx_analytics_txt_api_run'),
+            'response_label' => _t('_bx_analytics_txt_api_response'),
+            'copy' => _t('_bx_analytics_txt_copy'),
+            'copied' => $this->_esc(_t('_bx_analytics_txt_copied')),
+            'catalog_json' => $this->_json([
+                'root' => BX_DOL_URL_ROOT,
+                'module' => $this->MODULE,
+                'reports' => array_keys($aCatalog['reports']),
+            ]),
+        ]);
+    }
+
+    /**
+     * A table of parameters: name and what it accepts.
+     */
+    protected function _getApiParamsHtml($aParams, $sCaption)
+    {
+        $aRows = [];
+        foreach($aParams as $sName => $aParam) {
+            switch($aParam['type']) {
+                case 'enum':
+                    $sAccepts = _t('_bx_analytics_txt_api_type_enum', implode(', ', $aParam['values']));
+                    break;
+                case 'enum_by':
+                    $aLists = [];
+                    foreach($aParam['values'] as $sBy => $aValues)
+                        $aLists[] = $sBy . ': ' . implode(', ', $aValues);
+                    $sAccepts = _t('_bx_analytics_txt_api_type_enum_by', $aParam['by'], implode('; ', $aLists));
+                    break;
+                case 'int':
+                    $sAccepts = isset($aParam['max']) ? _t('_bx_analytics_txt_api_type_int_range', $aParam['min'], $aParam['max']) : _t('_bx_analytics_txt_api_type_int', $aParam['min']);
+                    break;
+                case 'date':
+                    $sAccepts = _t('_bx_analytics_txt_api_type_date');
+                    break;
+                case 'dims':
+                    $sAccepts = _t('_bx_analytics_txt_api_type_dims', implode(', ', $aParam['values']));
+                    break;
+                default:
+                    $sAccepts = _t('_bx_analytics_txt_api_type_string');
+            }
+            if(isset($aParam['default']))
+                $sAccepts .= ' ' . _t('_bx_analytics_txt_api_default', $aParam['default']);
+
+            $aRows[] = [
+                'param_name' => $this->_esc($sName),
+                'param_accepts' => $this->_esc($sAccepts),
+            ];
+        }
+
+        return $this->parseHtmlByName('api_params.html', [
+            'caption' => $this->_esc($sCaption),
+            'bx_if:params' => [
+                'condition' => !empty($aRows),
+                'content' => [
+                    'caption' => $this->_esc($sCaption),
+                    'col_param' => _t('_bx_analytics_txt_api_col_param'),
+                    'col_accepts' => _t('_bx_analytics_txt_api_col_accepts'),
+                    'bx_repeat:params' => $aRows,
+                ],
+            ],
+            'bx_if:none' => [
+                'condition' => empty($aRows),
+                'content' => ['none' => _t('_bx_analytics_txt_api_no_params')],
+            ],
+        ]);
+    }
+
+    /**
+     * Studio > Dashboards: site pages with analytics blocks, and a form for a new one.
+     *
+     * @param array $aData pages (see BxAnalyticsModule::getDashboards)
+     */
+    public function getStudioDashboards($aData)
+    {
+        $aPages = [];
+        foreach($aData['pages'] as $aPage)
+            $aPages[] = [
+                'view_url' => $this->_esc($aPage['view_url']),
+                'page_title' => $this->_esc($aPage['title']),
+                'blocks' => $this->formatValue('number', $aPage['blocks']),
+                'api_blocks' => $this->formatValue('number', $aPage['api_blocks']),
+                'edit_url' => $this->_esc($aPage['edit_url']),
+                'edit' => _t('_bx_analytics_txt_dash_edit'),
+                'edit_label' => $this->_esc(_t('_bx_analytics_txt_dash_edit_label', $aPage['title'])),
+            ];
+
+        $this->_addToolsAssets();
+
+        return $this->_getToolsJsInit() . $this->parseHtmlByName('studio_dashboards.html', [
+            'intro' => _t('_bx_analytics_txt_dash_intro'),
+            'bx_if:pages' => [
+                'condition' => !empty($aPages),
+                'content' => [
+                    'caption' => $this->_esc(_t('_bx_analytics_txt_dash_pages')),
+                    'col_page' => _t('_bx_analytics_txt_dash_col_page'),
+                    'col_blocks' => _t('_bx_analytics_txt_dash_blocks'),
+                    'col_neo' => _t('_bx_analytics_txt_dash_neo_blocks'),
+                    'col_actions' => _t('_bx_analytics_txt_dash_col_actions'),
+                    'bx_repeat:pages' => $aPages,
+                ],
+            ],
+            'bx_if:empty' => [
+                'condition' => empty($aPages),
+                'content' => ['empty' => _t('_bx_analytics_txt_dash_empty')],
+            ],
+            'neo_note' => _t('_bx_analytics_txt_dash_neo_note'),
+            'create_title' => _t('_bx_analytics_txt_dash_create'),
+            'csrf' => $this->_esc((string)BxDolForm::getCsrfToken()),
+            'label_title' => _t('_bx_analytics_txt_dash_title'),
+            'create_hint' => _t('_bx_analytics_txt_dash_create_hint'),
+            'disable_warning' => _t('_bx_analytics_txt_dash_disable_warning'),
+            'create' => _t('_bx_analytics_txt_dash_create_btn'),
+        ]);
+    }
+
+    /**
+     * Studio > Setup: tracker status per platform, checks of the admin's own browser, what
+     * each client needs, and custom events.
+     *
+     * @param array $aData status (see BxAnalyticsModule::getSetupStatus), tracking, injection,
+     *      track_actions, alerts, api_enabled, api_key, api_origin
+     */
+    public function getStudioSetup($aData)
+    {
+        $sRoot = BX_DOL_URL_ROOT;
+        $sCsrf = $this->_esc((string)BxDolForm::getCsrfToken());
+
+        // what NEO and the apps need from the site
+        $aNeoChecks = [
+            ['on' => $aData['api_enabled'], 'text' => _t('_bx_analytics_txt_setup_neo_api')],
+            ['on' => $aData['api_key'] || $aData['api_origin'], 'text' => _t('_bx_analytics_txt_setup_neo_access')],
+        ];
+        foreach([BX_ANALYTICS_PLATFORM_NEXT_WEB, BX_ANALYTICS_PLATFORM_RN_IOS, BX_ANALYTICS_PLATFORM_RN_ANDROID] as $sPlatform) {
+            $aStatus = $aData['status'][$sPlatform];
+            $aNeoChecks[] = [
+                'on' => $aStatus['enabled'],
+                'text' => _t($aStatus['enabled'] ? '_bx_analytics_txt_setup_platform_on' : '_bx_analytics_txt_setup_platform_off', _t($this->_oConfig->getPlatforms()[$sPlatform])),
+            ];
+        }
+
+        $aChecks = [];
+        foreach($aNeoChecks as $aCheck)
+            $aChecks[] = [
+                'check_state' => $aCheck['on'] ? 'yes' : 'no',
+                'check_icon' => $this->_getIconHtml($aCheck['on'] ? 'check' : 'x'),
+                'check_text' => $this->_esc($aCheck['text']),
+            ];
+
+        $aAlerts = [];
+        foreach($aData['alerts'] as $sAlert)
+            $aAlerts[] = ['alert' => $this->_esc($sAlert)];
+
+        $sCurl = "curl -X POST '" . $sRoot . "api.php?r=" . $this->MODULE . "/collect' \\\n"
+            . "  -H 'Authorization: Bearer YOUR_API_KEY' \\\n"
+            . "  -H 'Content-Type: application/json' \\\n"
+            . "  -d '{\"platform\": \"api\", \"app\": \"my-client\", \"app_version\": \"1.0.0\", \"hits\": [{\"type\": \"pageview\", \"url\": \"https://example.com/pricing\", \"title\": \"Pricing\"}, {\"type\": \"event\", \"name\": \"signup_click\", \"url\": \"https://example.com/pricing\", \"props\": {\"plan\": \"pro\"}}]}'";
+
+        $this->_addToolsAssets();
+
+        return $this->_getToolsJsInit() . $this->parseHtmlByName('studio_setup.html', [
+            'bx_if:tracking_off' => [
+                'condition' => !$aData['tracking'],
+                'content' => [
+                    'tracking_off' => _t('_bx_analytics_txt_setup_tracking_off'),
+                    'settings_url' => $this->_esc(BX_DOL_URL_STUDIO . 'module.php?name=' . $this->MODULE . '&page=settings'),
+                    'settings' => _t('_bx_analytics_txt_setup_open_settings'),
+                ],
+            ],
+            'status_title' => _t('_bx_analytics_txt_setup_status'),
+            'status' => $this->getSetupStatusHtml($aData['status']),
+            'check_title' => _t('_bx_analytics_txt_setup_check_title'),
+            'check_hint' => _t('_bx_analytics_txt_setup_check_hint'),
+            'csrf' => $sCsrf,
+            'check' => _t('_bx_analytics_txt_setup_check'),
+            'test' => _t('_bx_analytics_txt_setup_test'),
+            'test_warning' => _t('_bx_analytics_txt_setup_test_warning'),
+            'install_title' => _t('_bx_analytics_txt_setup_install'),
+            'core_web_title' => _t('_bx_analytics_platform_core_web'),
+            'core_web_text' => _t('_bx_analytics_txt_setup_core_web'),
+            'injection_state' => $aData['injection'] ? 'yes' : 'no',
+            'injection_icon' => $this->_getIconHtml($aData['injection'] ? 'check' : 'x'),
+            'injection' => _t($aData['injection'] ? '_bx_analytics_txt_setup_injection_on' : '_bx_analytics_txt_setup_injection_off'),
+            'core_web_exclusions' => _t('_bx_analytics_txt_setup_core_web_exclusions'),
+            'neo_title' => _t('_bx_analytics_txt_setup_neo_title'),
+            'neo_text' => _t('_bx_analytics_txt_setup_neo'),
+            'bx_repeat:neo_checks' => $aChecks,
+            'api_title' => _t('_bx_analytics_platform_api'),
+            'api_text' => _t('_bx_analytics_txt_setup_api'),
+            'code_curl' => $this->_getCodeHtml('bx-analytics-setup-curl', _t('_bx_analytics_txt_setup_curl'), $sCurl),
+            'core_title' => _t('_bx_analytics_platform_core'),
+            'core_text' => _t('_bx_analytics_txt_setup_core'),
+            'actions_state' => $aData['track_actions'] ? 'yes' : 'no',
+            'actions_icon' => $this->_getIconHtml($aData['track_actions'] ? 'check' : 'x'),
+            'actions' => _t($aData['track_actions'] ? '_bx_analytics_txt_setup_actions_on' : '_bx_analytics_txt_setup_actions_off'),
+            'alerts_label' => _t('_bx_analytics_txt_setup_alerts'),
+            'bx_repeat:alerts' => $aAlerts,
+            'events_title' => _t('_bx_analytics_txt_setup_events'),
+            'events_hint' => _t('_bx_analytics_txt_setup_events_hint'),
+            'code_classic' => $this->_getCodeHtml('bx-analytics-setup-js', _t('_bx_analytics_txt_setup_events_classic'), "window.bx_analytics && bx_analytics.track('signup_click', {plan: 'pro'});"),
+            'code_neo' => $this->_getCodeHtml('bx-analytics-setup-neo', _t('_bx_analytics_txt_setup_events_neo'), "import { trackEvent } from 'app/lib/analytics';\n\ntrackEvent('signup_click', { plan: 'pro' });"),
+            'code_server' => $this->_getCodeHtml('bx-analytics-setup-srv', _t('_bx_analytics_txt_setup_events_server'), "bx_srv('" . $this->MODULE . "', 'track', ['export_done', ['rows' => 120]]);"),
+            'events_warning' => _t('_bx_analytics_txt_setup_events_warning'),
+        ]);
+    }
+
+    /**
+     * The tracker status table, on the page and after a test event.
+     */
+    public function getSetupStatusHtml($aStatus)
+    {
+        $aPlatforms = $this->_oConfig->getPlatforms();
+
+        $aRows = [];
+        foreach($aStatus as $sPlatform => $aRow) {
+            $bApp = $this->_oConfig->isAppPlatform($sPlatform);
+            $aRows[] = [
+                'platform_title' => _t($aPlatforms[$sPlatform]),
+                'status' => $aRow['status'],
+                'status_icon' => $this->_getIconHtml($aRow['status']),
+                'status_title' => _t('_bx_analytics_txt_setup_status_' . $aRow['status']),
+                'last' => $aRow['last'] ? $this->formatValue('datetime', $aRow['last']) : _t('_bx_analytics_txt_setup_never'),
+                'hits' => $this->formatValue('number', $aRow['hits']),
+                // the server's own hits keep no visits
+                'visitors' => $sPlatform == BX_ANALYTICS_PLATFORM_CORE ? _t('_bx_analytics_txt_not_available') : $this->formatValue('number', $aRow['visitors']),
+                'sessions' => $sPlatform == BX_ANALYTICS_PLATFORM_CORE ? _t('_bx_analytics_txt_not_available') : $this->formatValue('number', $aRow['sessions']),
+                'version' => $bApp && $aRow['app_version'] !== '' ? $this->_esc($aRow['app_version']) : ($bApp ? _t('_bx_analytics_txt_setup_none') : _t('_bx_analytics_txt_not_available')),
+            ];
+        }
+
+        return $this->parseHtmlByName('setup_status.html', [
+            'caption' => $this->_esc(_t('_bx_analytics_txt_setup_status')),
+            'col_platform' => _t('_bx_analytics_txt_platform'),
+            'col_status' => _t('_bx_analytics_txt_setup_col_status'),
+            'col_last' => _t('_bx_analytics_txt_setup_last_hit'),
+            'col_hits' => _t('_bx_analytics_txt_setup_hits_24h'),
+            'col_visitors' => _t('_bx_analytics_txt_setup_visitors_24h'),
+            'col_sessions' => _t('_bx_analytics_txt_setup_sessions_7d'),
+            'col_version' => _t('_bx_analytics_txt_setup_app_version'),
+            'bx_repeat:rows' => $aRows,
+        ]);
+    }
+
+    /**
+     * The result of "Check this browser": tracked or not and why, and what the server sees.
+     *
+     * @param array $aCheck see BxAnalyticsCollector::check
+     */
+    public function getSetupCheckHtml($aCheck)
+    {
+        $bTracked = $aCheck['reasons'] == ['ok'];
+
+        $aReasons = [];
+        foreach($aCheck['reasons'] as $sReason)
+            if($sReason != 'ok')
+                $aReasons[] = [
+                    'reason_kind' => 'no',
+                    'reason_icon' => $this->_getIconHtml('x'),
+                    'reason' => _t('_bx_analytics_txt_check_' . $sReason),
+                ];
+        foreach($aCheck['notes'] as $sNote)
+            $aReasons[] = [
+                'reason_kind' => 'note',
+                'reason_icon' => $this->_getIconHtml('info'),
+                'reason' => _t('_bx_analytics_txt_check_' . $sNote),
+            ];
+
+        $sGeo = _t('_bx_analytics_txt_check_geo_none');
+        if($aCheck['geo_source'] == BX_ANALYTICS_GEO_NONE)
+            $sGeo = _t('_bx_analytics_txt_check_geo_off');
+        elseif($aCheck['country_header'] !== '')
+            $sGeo = _t('_bx_analytics_txt_check_geo_header', $this->_esc(str_replace('_', '-', preg_replace('/^HTTP_/', '', $aCheck['country_header']))), $this->_esc($aCheck['country']));
+
+        return $this->parseHtmlByName('setup_check.html', [
+            'verdict_state' => $bTracked ? 'yes' : 'no',
+            'verdict_icon' => $this->_getIconHtml($bTracked ? 'check' : 'x'),
+            'verdict' => _t($bTracked ? '_bx_analytics_txt_check_tracked' : '_bx_analytics_txt_check_not_tracked'),
+            'bx_if:reasons' => [
+                'condition' => !empty($aReasons),
+                'content' => ['bx_repeat:reasons' => $aReasons],
+            ],
+            'ip_label' => _t('_bx_analytics_txt_check_ip'),
+            'ip' => $aCheck['ip'] !== '' ? $this->_esc($aCheck['ip']) : _t('_bx_analytics_txt_not_available'),
+            'remote_label' => _t('_bx_analytics_txt_check_remote_addr'),
+            'remote' => $aCheck['remote_addr'] !== '' ? $this->_esc($aCheck['remote_addr']) : _t('_bx_analytics_txt_not_available'),
+            'geo_label' => _t('_bx_analytics_txt_check_geo'),
+            'geo' => $sGeo,
+            'identity_label' => _t('_bx_analytics_adm_stg_cpt_identity'),
+            'identity' => _t('_bx_analytics_identity_' . $aCheck['identity']),
+        ]);
+    }
+
+    /**
+     * A code snippet with a copy button. The code is text: it is escaped here.
+     */
+    protected function _getCodeHtml($sId, $sLabel, $sCode)
+    {
+        return $this->parseHtmlByName('code.html', [
+            'code_id' => $sId,
+            'code_label' => $this->_esc($sLabel),
+            'code' => $this->_esc($sCode),
+            'copy' => _t('_bx_analytics_txt_copy'),
+            'copied' => $this->_esc(_t('_bx_analytics_txt_copied')),
+        ]);
+    }
+
+    /**
+     * A small Lucide icon next to a status text, hidden from screen readers.
+     *
+     * @param string $sName receiving, quiet, nodata, off, check, x, info
+     */
+    protected function _getIconHtml($sName)
+    {
+        $aIcons = ['receiving' => 'check', 'quiet' => 'clock', 'nodata' => 'minus', 'off' => 'power', 'check' => 'check', 'x' => 'x', 'info' => 'info'];
+        $sIcon = isset($aIcons[$sName]) ? $aIcons[$sName] : 'info';
+
+        $aVars = [];
+        foreach(array_unique(array_values($aIcons)) as $sGlyph)
+            $aVars['bx_if:' . $sGlyph] = ['condition' => $sGlyph == $sIcon, 'content' => []];
+
+        return $this->parseHtmlByName('icon.html', $aVars);
+    }
+
+    protected function _addToolsAssets()
+    {
+        $this->_addAssets(['studio_tools.js'], ['dashboard.css']);
+    }
+
+    protected function _getToolsJsInit()
+    {
+        return $this->getJsCode('tools', [
+            'aStrings' => [
+                'error' => _t('_bx_analytics_txt_error_loading'),
+                'running' => _t('_bx_analytics_txt_api_running'),
+                'meta' => _t('_bx_analytics_txt_api_meta', '{status}', '{ms}', '{size}'),
+                'truncated' => _t('_bx_analytics_txt_api_truncated'),
+                'bad_json' => _t('_bx_analytics_txt_api_bad_params'),
+            ],
+        ], [
+            'mask' => "if(typeof window.{object} == 'undefined') window.{object} = new {class}({params});",
+            'wrap' => true,
+        ]);
+    }
+
+    /**
+     * Creates the dashboard script object once per page, whatever block comes first.
+     */
+    protected function _getJsInit()
+    {
+        return $this->getJsCode('dashboard', [
+            'sActionUrl' => BX_DOL_URL_ROOT . $this->_oConfig->getBaseUri(),
+            'aStrings' => [
+                'duration_h' => _t('_bx_analytics_txt_duration_h'),
+                'duration_m' => _t('_bx_analytics_txt_duration_m'),
+                'duration_s' => _t('_bx_analytics_txt_duration_s'),
+                'ms' => _t('_bx_analytics_txt_ms'),
+                'error' => _t('_bx_analytics_txt_error_loading'),
+            ],
+        ], [
+            'mask' => "if(typeof window.{object} == 'undefined') window.{object} = new {class}({params});",
+            'wrap' => true,
+        ]);
+    }
+
+    protected function _addDashboardAssets($sReport = '')
+    {
+        $aJs = ['chart.umd.min.js', 'dashboard.js'];
+        $aCss = ['dashboard.css'];
+
+        // the map uses the Leaflet copy and tile provider the site's location maps use
+        if($sReport == 'map') {
+            $aJs = array_merge(['leaflet/leaflet.js', 'leaflet-providers/leaflet-providers.js'], $aJs);
+            $aCss[] = BX_DIRECTORY_PATH_PLUGINS_PUBLIC . 'leaflet/|leaflet.css';
+        }
+
+        $this->_addAssets($aJs, $aCss);
+    }
+
+    protected function _addAssets($aJs, $aCss)
+    {
+        if($this->_bStudio) {
+            $this->addStudioJs($aJs);
+            $this->addStudioCss($aCss);
+            return;
+        }
+
+        $this->addJs($aJs);
+        $this->addCss($aCss);
+    }
+
+    /**
+     * 950, 9.5K, 95K, 1.2M
+     */
+    protected function _formatNumber($iValue)
+    {
+        $iValue = (int)$iValue;
+        foreach([1000000000 => 'B', 1000000 => 'M', 1000 => 'K'] as $iSize => $sSuffix)
+            if($iValue >= $iSize) {
+                $fValue = $iValue / $iSize;
+                return ($fValue < 10 ? rtrim(rtrim(number_format($fValue, 1), '0'), '.') : number_format(floor($fValue))) . $sSuffix;
+            }
+
+        return (string)$iValue;
+    }
+
+    protected function _formatChange($mixedChange)
+    {
+        if($mixedChange === null)
+            return '';
+
+        return ($mixedChange > 0 ? '+' : '') . number_format(abs($mixedChange) < 10 ? $mixedChange : round($mixedChange), abs($mixedChange) < 10 ? 1 : 0) . '%';
+    }
+
+    /**
+     * good, bad or flat, where down is good for bounce rate.
+     */
+    protected function _trend($sMetric, $mixedChange)
+    {
+        if($mixedChange === null || $mixedChange == 0)
+            return 'flat';
+
+        $bUp = $mixedChange > 0;
+        if(in_array($sMetric, ['bounce_rate', 'errors', 'refunds', 'cancelled', 'churn'], true))
+            return $bUp ? 'bad' : 'good';
+
+        return $bUp ? 'good' : 'bad';
+    }
+
+    /**
+     * Visitor-supplied text (paths, titles, referrers) for HTML text and attributes.
+     */
+    protected function _esc($s)
+    {
+        return htmlspecialchars((string)$s, ENT_QUOTES | ENT_SUBSTITUTE | ENT_HTML5, 'UTF-8');
+    }
+
+    /**
+     * JSON safe to put inside a script element.
+     */
+    protected function _json($a)
+    {
+        return json_encode($a, JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_UNESCAPED_UNICODE);
+    }
 }
 
 /** @} */

+ 311 - 0
modules/boonex/analytics/classes/BxAnalyticsUserAgent.php

@@ -0,0 +1,311 @@
+<?php defined('BX_DOL') or die('hack attempt');
+/**
+ * Copyright (c) UNA, Inc - https://una.io
+ * MIT License - https://opensource.org/licenses/MIT
+ *
+ * @defgroup    Analytics Analytics
+ * @ingroup     UnaModules
+ *
+ * @{
+ */
+
+/**
+ * User agent parser for web analytics: browser, operating system, device type and bots.
+ *
+ * Covers the browsers, systems and devices that make up nearly all real traffic. Rules are
+ * checked in order, so a token shared by several browsers (Chrome, Safari) comes last.
+ * Native app hits carry their own system and device fields and don't depend on this class.
+ */
+class BxAnalyticsUserAgent
+{
+    const DEVICE_DESKTOP = 'desktop';
+    const DEVICE_MOBILE = 'mobile';
+    const DEVICE_TABLET = 'tablet';
+    const DEVICE_TV = 'tv';
+    const DEVICE_CONSOLE = 'console';
+    const DEVICE_BOT = 'bot';
+
+    /**
+     * Crawlers, link previews, monitoring and HTTP libraries. A browser never sends these tokens.
+     */
+    protected static $_sBotsPattern = '/(?<!cu)bot\b|(?<!cu)bot\/|crawl|spider|slurp|mediapartners|adsbot|facebookexternalhit|facebookcatalog|meta-externalagent|embedly|quora link preview|outbrain|pinterest\/|vkshare|w3c_validator|whatsapp|telegrambot|discordbot|slackbot|skypeuripreview|bitlybot|headlesschrome|phantomjs|puppeteer|playwright|selenium|lighthouse|pagespeed|gtmetrix|pingdom|uptimerobot|statuscake|site24x7|datadog|newrelic|python-requests|python-urllib|aiohttp|httpx|curl\/|wget\/|libwww|httpclient|go-http-client|java\/|axios\/|node-fetch|undici|guzzlehttp|okhttp\/[0-9.]+$|scrapy|feedfetcher|rss|preview|archive\.org|ia_archiver|gptbot|chatgpt-user|oai-searchbot|claudebot|claude-web|anthropic-ai|perplexitybot|ccbot|bytespider|amazonbot|applebot|petalbot|semrush|ahrefs|mj12bot|dotbot|yandex(bot|images|metrika)|baiduspider|sogou|exabot|seznambot|bingpreview|google-inspectiontool|googleother|storebot/i';
+
+    const BOT_SEARCH = 'search';
+    const BOT_AI_CRAWLER = 'ai_crawler';
+    const BOT_AI_AGENT = 'ai_agent';
+    const BOT_SEO = 'seo';
+    const BOT_SOCIAL = 'social';
+    const BOT_MONITOR = 'monitor';
+    const BOT_FEED = 'feed';
+    const BOT_SCRIPT = 'script';
+    const BOT_OTHER = 'other';
+
+    /**
+     * Known bots as [pattern, name, category]. AI agents fetch a page because a person asked
+     * an assistant about it; AI crawlers collect pages for training or search indexes.
+     */
+    protected static $_aBots = [
+        ['/ChatGPT-User/i', 'ChatGPT', self::BOT_AI_AGENT],
+        ['/Claude-User/i', 'Claude', self::BOT_AI_AGENT],
+        ['/Perplexity-User/i', 'Perplexity', self::BOT_AI_AGENT],
+        ['/MistralAI-User/i', 'Mistral', self::BOT_AI_AGENT],
+        ['/DuckAssistBot/i', 'DuckAssist', self::BOT_AI_AGENT],
+        ['/Google-NotebookLM/i', 'NotebookLM', self::BOT_AI_AGENT],
+        ['/OAI-SearchBot/i', 'OAI-SearchBot', self::BOT_AI_CRAWLER],
+        ['/GPTBot/i', 'GPTBot', self::BOT_AI_CRAWLER],
+        ['/Claude-SearchBot/i', 'Claude-SearchBot', self::BOT_AI_CRAWLER],
+        ['/ClaudeBot|anthropic-ai|Claude-Web/i', 'ClaudeBot', self::BOT_AI_CRAWLER],
+        ['/PerplexityBot/i', 'PerplexityBot', self::BOT_AI_CRAWLER],
+        ['/CCBot/i', 'CCBot', self::BOT_AI_CRAWLER],
+        ['/Bytespider/i', 'Bytespider', self::BOT_AI_CRAWLER],
+        ['/meta-externalagent|meta-externalfetcher/i', 'Meta AI', self::BOT_AI_CRAWLER],
+        ['/Amazonbot/i', 'Amazonbot', self::BOT_AI_CRAWLER],
+        ['/cohere-ai|cohere-training/i', 'Cohere', self::BOT_AI_CRAWLER],
+        ['/Diffbot/i', 'Diffbot', self::BOT_AI_CRAWLER],
+        ['/YouBot/i', 'YouBot', self::BOT_AI_CRAWLER],
+        ['/AI2Bot/i', 'AI2Bot', self::BOT_AI_CRAWLER],
+        ['/Timpibot|ImagesiftBot|Omgilibot|PetalBot.*AI/i', 'Other AI crawler', self::BOT_AI_CRAWLER],
+        ['/Googlebot|Google-InspectionTool|GoogleOther|Storebot-Google|AdsBot-Google|Mediapartners-Google/i', 'Googlebot', self::BOT_SEARCH],
+        ['/bingbot|BingPreview|msnbot|adidxbot/i', 'Bingbot', self::BOT_SEARCH],
+        ['/YandexBot|YandexImages|YandexMetrika|YandexMobileBot/i', 'YandexBot', self::BOT_SEARCH],
+        ['/Baiduspider/i', 'Baiduspider', self::BOT_SEARCH],
+        ['/DuckDuckBot/i', 'DuckDuckBot', self::BOT_SEARCH],
+        ['/Applebot/i', 'Applebot', self::BOT_SEARCH],
+        ['/Sogou/i', 'Sogou', self::BOT_SEARCH],
+        ['/SeznamBot/i', 'SeznamBot', self::BOT_SEARCH],
+        ['/PetalBot/i', 'PetalBot', self::BOT_SEARCH],
+        ['/Yeti\//i', 'Naver', self::BOT_SEARCH],
+        ['/Qwantbot|Qwantify/i', 'Qwant', self::BOT_SEARCH],
+        ['/MojeekBot/i', 'Mojeek', self::BOT_SEARCH],
+        ['/coccocbot/i', 'Coc Coc', self::BOT_SEARCH],
+        ['/Exabot/i', 'Exabot', self::BOT_SEARCH],
+        ['/AhrefsBot|AhrefsSiteAudit/i', 'Ahrefs', self::BOT_SEO],
+        ['/SemrushBot|SiteAuditBot/i', 'Semrush', self::BOT_SEO],
+        ['/MJ12bot/i', 'Majestic', self::BOT_SEO],
+        ['/DotBot/i', 'Moz', self::BOT_SEO],
+        ['/rogerbot/i', 'Moz', self::BOT_SEO],
+        ['/Screaming Frog/i', 'Screaming Frog', self::BOT_SEO],
+        ['/BLEXBot/i', 'BLEXBot', self::BOT_SEO],
+        ['/DataForSeoBot/i', 'DataForSEO', self::BOT_SEO],
+        ['/serpstatbot/i', 'Serpstat', self::BOT_SEO],
+        ['/facebookexternalhit|facebookcatalog|Facebot/i', 'Facebook', self::BOT_SOCIAL],
+        ['/Twitterbot/i', 'X (Twitter)', self::BOT_SOCIAL],
+        ['/LinkedInBot/i', 'LinkedIn', self::BOT_SOCIAL],
+        ['/Slackbot|Slack-ImgProxy/i', 'Slack', self::BOT_SOCIAL],
+        ['/Discordbot/i', 'Discord', self::BOT_SOCIAL],
+        ['/TelegramBot/i', 'Telegram', self::BOT_SOCIAL],
+        ['/WhatsApp/i', 'WhatsApp', self::BOT_SOCIAL],
+        ['/Pinterest/i', 'Pinterest', self::BOT_SOCIAL],
+        ['/redditbot/i', 'Reddit', self::BOT_SOCIAL],
+        ['/vkShare/i', 'VK', self::BOT_SOCIAL],
+        ['/SkypeUriPreview/i', 'Skype', self::BOT_SOCIAL],
+        ['/Embedly|Iframely/i', 'Embed service', self::BOT_SOCIAL],
+        ['/Mastodon|Bluesky|Cardyb/i', 'Fediverse', self::BOT_SOCIAL],
+        ['/UptimeRobot/i', 'UptimeRobot', self::BOT_MONITOR],
+        ['/Pingdom/i', 'Pingdom', self::BOT_MONITOR],
+        ['/StatusCake/i', 'StatusCake', self::BOT_MONITOR],
+        ['/Site24x7/i', 'Site24x7', self::BOT_MONITOR],
+        ['/Datadog/i', 'Datadog', self::BOT_MONITOR],
+        ['/NewRelic/i', 'New Relic', self::BOT_MONITOR],
+        ['/Better Uptime|BetterStack/i', 'Better Stack', self::BOT_MONITOR],
+        ['/Chrome-Lighthouse|PageSpeed|GTmetrix/i', 'Speed test', self::BOT_MONITOR],
+        ['/Feedly|Inoreader|NewsBlur|Feedfetcher|Feedbin|FreshRSS|\brss/i', 'Feed reader', self::BOT_FEED],
+        ['/HeadlessChrome|PhantomJS|puppeteer|playwright|selenium/i', 'Headless browser', self::BOT_SCRIPT],
+        ['/curl\//i', 'curl', self::BOT_SCRIPT],
+        ['/Wget\//i', 'Wget', self::BOT_SCRIPT],
+        ['/python-requests|python-urllib|aiohttp|httpx|Scrapy/i', 'Python', self::BOT_SCRIPT],
+        ['/Go-http-client/i', 'Go', self::BOT_SCRIPT],
+        ['/axios|node-fetch|undici|Node\.js/i', 'Node.js', self::BOT_SCRIPT],
+        ['/GuzzleHttp/i', 'PHP', self::BOT_SCRIPT],
+        ['/Java\/|Apache-HttpClient|okhttp/i', 'Java', self::BOT_SCRIPT],
+        ['/libwww-perl/i', 'Perl', self::BOT_SCRIPT],
+    ];
+
+    /**
+     * Browsers as [name, pattern with the version in the first group]. Order matters.
+     */
+    protected static $_aBrowsers = [
+        ['Edge', '/\bEdg(?:e|A|iOS)?\/([\d.]+)/'],
+        ['Opera', '/\b(?:OPR|OPiOS|OPT)\/([\d.]+)/'],
+        ['Opera', '/\bOpera(?:.*Version)?[\/ ]([\d.]+)/'],
+        ['Samsung Internet', '/\bSamsungBrowser\/([\d.]+)/'],
+        ['Yandex Browser', '/\bYaBrowser\/([\d.]+)/'],
+        ['UC Browser', '/\bUC ?Browser\/([\d.]+)/'],
+        ['Vivaldi', '/\bVivaldi\/([\d.]+)/'],
+        ['DuckDuckGo', '/\b(?:DuckDuckGo|Ddg)\/([\d.]+)/'],
+        ['Huawei Browser', '/\bHuaweiBrowser\/([\d.]+)/'],
+        ['MIUI Browser', '/\bMiuiBrowser\/([\d.]+)/'],
+        ['Facebook', '/\bFBAV\/([\d.]+)/'],
+        ['Instagram', '/\bInstagram ([\d.]+)/'],
+        ['TikTok', '/\b(?:musical_ly|BytedanceWebview|TikTok)[_\/ ]([\d.]+)/'],
+        ['Firefox', '/\b(?:Firefox|FxiOS)\/([\d.]+)/'],
+        ['Chrome', '/\bCriOS\/([\d.]+)/'],
+        ['Android WebView', '/; wv\).*Chrome\/([\d.]+)/'],
+        ['Chrome', '/\b(?:Chrome|Chromium)\/([\d.]+)/'],
+        ['Safari', '/\bVersion\/([\d.]+).*Safari\//'],
+        ['iOS WebView', '/\b(?:iPhone|iPad|iPod).*AppleWebKit\/[\d.]+()/'],
+        ['Internet Explorer', '/\b(?:MSIE |Trident\/.*rv:)([\d.]+)/'],
+    ];
+
+    /**
+     * Operating systems as [name, pattern with an optional version in the first group]. Order matters.
+     */
+    protected static $_aSystems = [
+        ['iPadOS', '/\biPad.*?OS ([\d_]+)/'],
+        ['iOS', '/\b(?:iPhone|iPod).*?OS ([\d_]+)/'],
+        ['iOS', '/\b(?:iPhone|iPad|iPod)\b()/'],
+        ['HarmonyOS', '/\bHarmonyOS ?([\d.]*)/'],
+        ['Android', '/\bAndroid[ \/]?([\d.]*)/'],
+        ['Windows Phone', '/\bWindows Phone(?: OS)? ([\d.]+)/'],
+        ['Windows', '/\bWindows NT ([\d.]+)/'],
+        ['Windows', '/\bWindows\b()/'],
+        ['Chrome OS', '/\bCrOS \S+ ([\d.]+)/'],
+        ['macOS', '/\bMac OS X ([\d_.]+)/'],
+        ['macOS', '/\bMacintosh\b()/'],
+        ['KaiOS', '/\bKAIOS\/([\d.]+)/i'],
+        ['Tizen', '/\bTizen ?([\d.]*)/'],
+        ['webOS', '/\b(?:web0s|webos)\b()/i'],
+        ['PlayStation', '/\bPlayStation ?([\d.]*)/'],
+        ['Xbox', '/\bXbox\b()/'],
+        ['Ubuntu', '/\bUbuntu\b()/'],
+        ['Linux', '/\bLinux\b()/'],
+        ['FreeBSD', '/\bFreeBSD\b()/'],
+    ];
+
+    /**
+     * Windows NT kernel versions; 10.0 covers both Windows 10 and 11.
+     */
+    protected static $_aWindowsVersions = [
+        '10.0' => '10',
+        '6.3' => '8.1',
+        '6.2' => '8',
+        '6.1' => '7',
+        '6.0' => 'Vista',
+        '5.1' => 'XP',
+    ];
+
+    /**
+     * Parse a user agent string.
+     *
+     * @param string $sUserAgent the User-Agent header
+     * @return array with keys browser, browser_version, os, os_version, device and bot
+     */
+    public static function parse($sUserAgent)
+    {
+        $sUserAgent = trim((string)$sUserAgent);
+
+        $aResult = [
+            'browser' => '',
+            'browser_version' => '',
+            'os' => '',
+            'os_version' => '',
+            'device' => self::DEVICE_DESKTOP,
+            'bot' => false,
+        ];
+
+        if($sUserAgent === '' || self::isBot($sUserAgent)) {
+            $aResult['device'] = self::DEVICE_BOT;
+            $aResult['bot'] = true;
+            return $aResult;
+        }
+
+        foreach(self::$_aBrowsers as list($sName, $sPattern))
+            if(preg_match($sPattern, $sUserAgent, $aMatch)) {
+                $aResult['browser'] = $sName;
+                $aResult['browser_version'] = self::_majorMinor($aMatch[1]);
+                break;
+            }
+
+        foreach(self::$_aSystems as list($sName, $sPattern))
+            if(preg_match($sPattern, $sUserAgent, $aMatch)) {
+                $aResult['os'] = $sName;
+                $aResult['os_version'] = self::_systemVersion($sName, isset($aMatch[1]) ? $aMatch[1] : '');
+                break;
+            }
+
+        $aResult['device'] = self::_device($sUserAgent, $aResult['os']);
+
+        return $aResult;
+    }
+
+    /**
+     * Whether a user agent belongs to a crawler, a link preview, a monitor or an HTTP library.
+     */
+    public static function isBot($sUserAgent)
+    {
+        $sUserAgent = trim((string)$sUserAgent);
+        if($sUserAgent === '')
+            return true;
+
+        return (bool)preg_match(self::$_sBotsPattern, $sUserAgent);
+    }
+
+    /**
+     * Name and category of a bot's user agent, or null for a browser.
+     *
+     * @return array|null ['name' => ..., 'category' => ...]
+     */
+    public static function getBot($sUserAgent)
+    {
+        $sUserAgent = trim((string)$sUserAgent);
+        if($sUserAgent === '')
+            return ['name' => 'Unknown', 'category' => self::BOT_SCRIPT];
+
+        foreach(self::$_aBots as list($sPattern, $sName, $sCategory))
+            if(preg_match($sPattern, $sUserAgent))
+                return ['name' => $sName, 'category' => $sCategory];
+
+        if(!self::isBot($sUserAgent))
+            return null;
+
+        // an unlisted bot: its own name when the user agent carries one ("ExampleBot/1.0")
+        if(preg_match('/([A-Za-z][A-Za-z0-9._-]{1,40}(?:bot|crawler|spider))\b/i', $sUserAgent, $aMatch))
+            return ['name' => get_mb_substr($aMatch[1], 0, 64), 'category' => self::BOT_OTHER];
+
+        return ['name' => 'Other', 'category' => self::BOT_OTHER];
+    }
+
+    protected static function _device($sUserAgent, $sOs)
+    {
+        if(preg_match('/\b(?:SmartTV|SMART-TV|Smart TV|AppleTV|Apple TV|GoogleTV|Android TV|BRAVIA|HbbTV|NetCast|Web0S|webOS TV|Tizen.*TV|CrKey|AFT[A-Z]+|Roku)\b/i', $sUserAgent))
+            return self::DEVICE_TV;
+
+        if(preg_match('/\b(?:PlayStation|Xbox|Nintendo)\b/i', $sUserAgent))
+            return self::DEVICE_CONSOLE;
+
+        if(preg_match('/\b(?:iPad|Tablet|Kindle|Silk|PlayBook)\b/i', $sUserAgent) || $sOs === 'iPadOS')
+            return self::DEVICE_TABLET;
+
+        if($sOs === 'Android' && stripos($sUserAgent, 'Mobile') === false)
+            return self::DEVICE_TABLET;
+
+        if(preg_match('/Mobi|iPhone|iPod|Windows Phone|Opera Mini|BlackBerry|BB10|KAIOS/i', $sUserAgent) || in_array($sOs, ['iOS', 'Android', 'HarmonyOS'], true))
+            return self::DEVICE_MOBILE;
+
+        return self::DEVICE_DESKTOP;
+    }
+
+    protected static function _systemVersion($sOs, $sVersion)
+    {
+        $sVersion = str_replace('_', '.', $sVersion);
+        if($sOs === 'Windows')
+            return isset(self::$_aWindowsVersions[$sVersion]) ? self::$_aWindowsVersions[$sVersion] : '';
+
+        // Safari has reported macOS as 10.15.7 since Big Sur, so the minor version means nothing.
+        if($sOs === 'macOS' && bx_mb_strpos($sVersion, '10.15') === 0)
+            return '';
+
+        return self::_majorMinor($sVersion);
+    }
+
+    protected static function _majorMinor($sVersion)
+    {
+        $aParts = explode('.', (string)$sVersion);
+        if($aParts[0] === '')
+            return '';
+
+        return isset($aParts[1]) && $aParts[1] !== '' && $aParts[1] !== '0' ? $aParts[0] . '.' . $aParts[1] : $aParts[0];
+    }
+}
+
+/** @} */

+ 176 - 0
modules/boonex/analytics/classes/BxAnalyticsVitals.php

@@ -0,0 +1,176 @@
+<?php defined('BX_DOL') or die('hack attempt');
+/**
+ * Copyright (c) UNA, Inc - https://una.io
+ * MIT License - https://opensource.org/licenses/MIT
+ *
+ * @defgroup    Analytics Analytics
+ * @ingroup     UnaModules
+ *
+ * @{
+ */
+
+/**
+ * Web vitals and app timings: which metrics there are, what a client may send, and the 75th
+ * percentile from a log-scale histogram the database builds with GROUP BY (no window
+ * functions, so it runs on every MySQL and MariaDB UNA supports).
+ *
+ * Timings are milliseconds. CLS is a unitless score; it is stored in thousandths.
+ */
+class BxAnalyticsVitals
+{
+    /**
+     * Every metric, in the order reports show them. LCP, INP, CLS, FCP and TTFB are web vitals
+     * of a page load; screen_load is a navigation inside a running app or a soft navigation
+     * on the web; app_start is a native app's cold start.
+     */
+    const METRICS = ['lcp', 'inp', 'cls', 'fcp', 'ttfb', 'screen_load', 'app_start'];
+
+    const WEB_METRICS = ['lcp', 'inp', 'cls', 'fcp', 'ttfb', 'screen_load'];
+    const APP_METRICS = ['screen_load', 'app_start'];
+
+    /**
+     * How the page was reached: the Navigation Timing type, `soft` for a navigation inside a
+     * single-page app or an htmx swap, `restore` for the back-forward cache.
+     */
+    const NAVS = ['navigate', 'reload', 'back_forward', 'prerender', 'soft', 'restore'];
+
+    const MAX_MS = 120000;
+    const MAX_CLS = 10;
+
+    /**
+     * Histogram buckets per e-fold: 20 makes a bucket 5% wide, so the interpolated
+     * percentile is within a bucket of the exact one, and usually within 2.5%.
+     */
+    const BUCKETS = 20;
+
+    /**
+     * A sent value as stored, or null when it is not a number in range: whole milliseconds
+     * up to 2 minutes, CLS from 0 to 10 in thousandths.
+     */
+    public static function getValue($sMetric, $mixedValue)
+    {
+        if(!in_array($sMetric, self::METRICS, true) || is_bool($mixedValue) || !is_numeric($mixedValue))
+            return null;
+
+        $fValue = (float)$mixedValue;
+        if(!is_finite($fValue) || $fValue < 0)
+            return null;
+
+        if($sMetric == 'cls')
+            return $fValue <= self::MAX_CLS ? (int)round($fValue * 1000) : null;
+
+        return $fValue <= self::MAX_MS ? (int)round($fValue) : null;
+    }
+
+    /**
+     * A stored value in the metric's own unit: CLS back from thousandths.
+     */
+    public static function toDisplay($sMetric, $mixedValue)
+    {
+        if($mixedValue === null)
+            return null;
+
+        return $sMetric == 'cls' ? round($mixedValue / 1000, 3) : (int)round($mixedValue);
+    }
+
+    /**
+     * The format a metric's values are shown in: ms or cls.
+     */
+    public static function getUnit($sMetric)
+    {
+        return $sMetric == 'cls' ? 'cls' : 'ms';
+    }
+
+    /**
+     * The histogram bucket of a stored value: -1 for exactly 0, else floor(ln(v) * 20). The
+     * SQL in BxAnalyticsDb::_getVitalsBucketSql is the same expression.
+     */
+    public static function getBucket($iValue)
+    {
+        return $iValue <= 0 ? -1 : (int)floor(log($iValue) * self::BUCKETS);
+    }
+
+    /**
+     * A percentile from histogram counts, interpolated geometrically inside the bucket it
+     * falls in (at the middle of the ranked sample's share of the bucket); null with fewer
+     * samples than `$iMinSamples`.
+     *
+     * A bucket can't tell where its values sit, so with the exact counts on each side of the
+     * thresholds the estimate is kept on the side the exact percentile is on: a page whose
+     * values are all 2500 ms has an LCP p75 of 2500, which is good, not 2534.
+     *
+     * @param array $aHistogram bucket => count
+     * @param float $fPercentile 0.75 for p75
+     * @param array $aSides counts of values up to the good threshold, between the two, and
+     *      above the poor threshold, or [] when unknown
+     * @param array $aThresholds [good up to, poor above], with `$aSides`
+     */
+    public static function getPercentile($aHistogram, $fPercentile = 0.75, $iMinSamples = 20, $aSides = [], $aThresholds = [])
+    {
+        $mixedValue = self::_getPercentile($aHistogram, $fPercentile, $iMinSamples);
+        if($mixedValue === null || count($aSides) != 3 || count($aThresholds) != 2)
+            return $mixedValue;
+
+        $fRank = $fPercentile * array_sum($aSides);
+        if($aSides[0] >= $fRank)
+            return min($mixedValue, (float)$aThresholds[0]);
+
+        if($aSides[0] + $aSides[1] >= $fRank)
+            return min(max($mixedValue, (float)$aThresholds[0] + 1), (float)$aThresholds[1]);
+
+        return max($mixedValue, (float)$aThresholds[1] + 1);
+    }
+
+    protected static function _getPercentile($aHistogram, $fPercentile, $iMinSamples)
+    {
+        $iTotal = 0;
+        foreach($aHistogram as $iCount)
+            $iTotal += (int)$iCount;
+        if(!$iTotal || $iTotal < $iMinSamples)
+            return null;
+
+        ksort($aHistogram, SORT_NUMERIC);
+
+        $fRank = $fPercentile * $iTotal;
+        $iBefore = 0;
+        foreach($aHistogram as $iBucket => $iCount) {
+            $iCount = (int)$iCount;
+            if(!$iCount)
+                continue;
+
+            if($iBefore + $iCount >= $fRank) {
+                if((int)$iBucket < 0)
+                    return 0.0;
+
+                $fShare = min(1, max(0, ($fRank - $iBefore - 0.5) / $iCount));
+                return exp(((int)$iBucket + $fShare) / self::BUCKETS);
+            }
+
+            $iBefore += $iCount;
+        }
+
+        // rounding left the rank past the last bucket
+        end($aHistogram);
+        $iLast = (int)key($aHistogram);
+
+        return $iLast < 0 ? 0.0 : exp(($iLast + 1) / self::BUCKETS);
+    }
+
+    /**
+     * good, needs_improvement or poor for a stored value.
+     *
+     * @param array $aThresholds [good up to, poor above]
+     */
+    public static function getRating($mixedValue, $aThresholds)
+    {
+        if($mixedValue === null)
+            return '';
+
+        if($mixedValue <= $aThresholds[0])
+            return 'good';
+
+        return $mixedValue > $aThresholds[1] ? 'poor' : 'needs_improvement';
+    }
+}
+
+/** @} */

+ 13 - 0
modules/boonex/analytics/install/installer.php

@@ -15,6 +15,19 @@ class BxAnalyticsInstaller extends BxBaseModTextInstaller
     {
         parent::__construct($aConfig);
     }
+
+    /**
+     * Subscribe to the actions of the modules installed now; later ones are added when they
+     * are enabled (the system/enable alert).
+     */
+    protected function _onEnableAfter()
+    {
+        $iResult = parent::_onEnableAfter();
+        if($iResult == BX_DOL_STUDIO_INSTALLER_SUCCESS)
+            BxDolService::call($this->_aConfig['name'], 'sync_actions');
+
+        return $iResult;
+    }
 }
 
 /** @} */

+ 685 - 0
modules/boonex/analytics/install/langs/en.xml

@@ -2,18 +2,320 @@
 <resources name="en" flag="gb" title="English">
 	<string name="_bx_analytics"><![CDATA[Analytics]]></string>
 	<string name="_bx_analytics_adm_stg_cpt_type"><![CDATA[Analytics]]></string>
+	<string name="_bx_analytics_adm_stg_cpt_vitals_retention_days"><![CDATA[Keep performance data, days]]></string>
+	<string name="_bx_analytics_adm_stg_cpt_vitals_sample"><![CDATA[Page loads measured, %]]></string>
+	<string name="_bx_analytics_adm_stg_cpt_category_content"><![CDATA[Content reports]]></string>
 	<string name="_bx_analytics_adm_stg_cpt_category_general"><![CDATA[Analytics]]></string>
+	<string name="_bx_analytics_adm_stg_cpt_category_health"><![CDATA[Health]]></string>
+	<string name="_bx_analytics_adm_stg_cpt_category_privacy"><![CDATA[Privacy and data]]></string>
+	<string name="_bx_analytics_adm_stg_cpt_category_system"><![CDATA[System]]></string>
+	<string name="_bx_analytics_adm_stg_cpt_category_tracking"><![CDATA[Tracking]]></string>
+	<string name="_bx_analytics_adm_stg_inf_errors_ignore"><![CDATA[Error messages not to record, one per line. * stands for any characters, and case does not matter.]]></string>
+	<string name="_bx_analytics_adm_stg_inf_exclude_ips"><![CDATA[Addresses or CIDR ranges, separated by commas or spaces.]]></string>
+	<string name="_bx_analytics_adm_stg_inf_exclude_levels"><![CDATA[For example, administrators, so your own visits don't count.]]></string>
+	<string name="_bx_analytics_adm_stg_inf_exclude_paths"><![CDATA[Path prefixes, or patterns with * (for example /page/*-admin), separated by commas or spaces.]]></string>
+	<string name="_bx_analytics_adm_stg_inf_geo_source"><![CDATA[The country, region and city that Cloudflare, Vercel or CloudFront add to each request. A CDN sets its own headers and passes the others through, so a visitor could send another CDN's: choose the CDN your site and NEO run behind.]]></string>
+	<string name="_bx_analytics_adm_stg_inf_identity"><![CDATA[Cookieless: a hash of the address and browser that changes daily, with nothing stored on the device. A guest who comes back another day counts as a new visitor. Persistent: a random id kept in the browser or app, which allows guest retention but may need consent.]]></string>
+	<string name="_bx_analytics_adm_stg_inf_ignore_params"><![CDATA[Left out of stored page paths, so one page is not counted as many. Separate with commas; * matches any characters. Campaign tags and ad click ids are always left out.]]></string>
+	<string name="_bx_analytics_adm_stg_inf_limit_hits"><![CDATA[How many pageviews, events, errors and timings the trackers may send from one address in a minute (an IPv6 address counts by its /64 network). More are dropped until the next minute, and the tracker keeps sending. Raise it when many visitors share one address, as in an office or a school. 0: no limit. Site actions and payments are never limited.]]></string>
+	<string name="_bx_analytics_adm_stg_inf_limit_visits"><![CDATA[How many visits may start from one address in an hour (an IPv6 address counts by its /64 network). Hits that would start another visit are dropped, so a script cannot fill the reports with visits. Visits already running go on. 0: no limit.]]></string>
+	<string name="_bx_analytics_adm_stg_inf_link_members"><![CDATA[Record the profile of signed-in members, for member sessions and retention. Deleting a profile unlinks its visits.]]></string>
+	<string name="_bx_analytics_adm_stg_inf_online_window"><![CDATA[Visitors active within this time count as online.]]></string>
+	<string name="_bx_analytics_adm_stg_inf_platforms"><![CDATA[Clients whose visits are recorded.]]></string>
+	<string name="_bx_analytics_adm_stg_inf_respect_dnt"><![CDATA[Skip browsers that send Do Not Track or Global Privacy Control.]]></string>
+	<string name="_bx_analytics_adm_stg_inf_retention_days"><![CDATA[Older visits and events are deleted every hour.]]></string>
+	<string name="_bx_analytics_adm_stg_inf_session_timeout"><![CDATA[A visit ends after this long without activity.]]></string>
+	<string name="_bx_analytics_adm_stg_inf_track_actions"><![CDATA[Sign-ups, logins, new profiles and content, comments, reactions and connections become events, with the client they came from.]]></string>
+	<string name="_bx_analytics_adm_stg_inf_track_bots"><![CDATA[Search engines, AI crawlers, AI assistants fetching pages, link previews, monitors and scripts. They are counted per day and page, apart from visitors.]]></string>
+	<string name="_bx_analytics_adm_stg_inf_track_errors"><![CDATA[JavaScript errors, unhandled promise rejections, failed requests and app crashes from every client. Query strings, tokens and emails are removed before anything is stored.]]></string>
+	<string name="_bx_analytics_adm_stg_inf_track_vitals"><![CDATA[Web vitals of page loads (LCP, INP, CLS, FCP, TTFB), and screen loads and starts in the apps.]]></string>
+	<string name="_bx_analytics_adm_stg_inf_tracking"><![CDATA[Off stops collecting. Reports keep the data already collected.]]></string>
+	<string name="_bx_analytics_adm_stg_inf_trusted_proxies"><![CDATA[Addresses or CIDR ranges of proxies and load balancers whose X-Forwarded-For header holds the visitor's address. Private network addresses and the NEO server are always trusted.]]></string>
+	<string name="_bx_analytics_adm_stg_inf_vitals_retention_days"><![CDATA[Measurements older than this are deleted. It can be shorter than the retention of other data, never longer.]]></string>
+	<string name="_bx_analytics_adm_stg_inf_vitals_sample"><![CDATA[From 0 to 100. Every measured page view adds a row, so a busy site can measure a part of them.]]></string>
+	<string name="_bx_analytics_bot_ai_agent"><![CDATA[AI assistant]]></string>
+	<string name="_bx_analytics_bot_ai_crawler"><![CDATA[AI crawler]]></string>
+	<string name="_bx_analytics_bot_feed"><![CDATA[Feed reader]]></string>
+	<string name="_bx_analytics_bot_monitor"><![CDATA[Monitoring]]></string>
+	<string name="_bx_analytics_bot_other"><![CDATA[Other bot]]></string>
+	<string name="_bx_analytics_bot_script"><![CDATA[Script]]></string>
+	<string name="_bx_analytics_bot_search"><![CDATA[Search engine]]></string>
+	<string name="_bx_analytics_bot_seo"><![CDATA[SEO tool]]></string>
+	<string name="_bx_analytics_bot_social"><![CDATA[Link preview]]></string>
+	<string name="_bx_analytics_channel_affiliate"><![CDATA[Affiliates]]></string>
+	<string name="_bx_analytics_channel_ai"><![CDATA[AI assistants]]></string>
+	<string name="_bx_analytics_channel_campaign"><![CDATA[Other campaigns]]></string>
+	<string name="_bx_analytics_channel_direct"><![CDATA[Direct]]></string>
+	<string name="_bx_analytics_channel_display"><![CDATA[Display ads]]></string>
+	<string name="_bx_analytics_channel_email"><![CDATA[Email]]></string>
+	<string name="_bx_analytics_channel_internal"><![CDATA[Internal]]></string>
+	<string name="_bx_analytics_channel_paid_search"><![CDATA[Paid search]]></string>
+	<string name="_bx_analytics_channel_paid_social"><![CDATA[Paid social]]></string>
+	<string name="_bx_analytics_channel_referral"><![CDATA[Referral]]></string>
+	<string name="_bx_analytics_channel_search"><![CDATA[Organic search]]></string>
+	<string name="_bx_analytics_channel_social"><![CDATA[Organic social]]></string>
+	<string name="_bx_analytics_device_bot"><![CDATA[Bot]]></string>
+	<string name="_bx_analytics_device_console"><![CDATA[Game console]]></string>
+	<string name="_bx_analytics_device_desktop"><![CDATA[Desktop]]></string>
+	<string name="_bx_analytics_device_mobile"><![CDATA[Mobile]]></string>
+	<string name="_bx_analytics_device_tablet"><![CDATA[Tablet]]></string>
+	<string name="_bx_analytics_device_tv"><![CDATA[TV]]></string>
+	<string name="_bx_analytics_dim_app"><![CDATA[App]]></string>
+	<string name="_bx_analytics_dim_app_version"><![CDATA[App version]]></string>
+	<string name="_bx_analytics_dim_browser"><![CDATA[Browser]]></string>
+	<string name="_bx_analytics_dim_channel"><![CDATA[Channel]]></string>
+	<string name="_bx_analytics_dim_city"><![CDATA[City]]></string>
+	<string name="_bx_analytics_dim_country"><![CDATA[Country]]></string>
+	<string name="_bx_analytics_dim_device"><![CDATA[Device]]></string>
+	<string name="_bx_analytics_dim_entry_path"><![CDATA[Entry page]]></string>
+	<string name="_bx_analytics_dim_exit_path"><![CDATA[Exit page]]></string>
+	<string name="_bx_analytics_dim_hostname"><![CDATA[Hostname]]></string>
+	<string name="_bx_analytics_dim_language"><![CDATA[Language]]></string>
+	<string name="_bx_analytics_dim_os"><![CDATA[Operating system]]></string>
+	<string name="_bx_analytics_dim_path"><![CDATA[Page]]></string>
+	<string name="_bx_analytics_dim_platform"><![CDATA[Platform]]></string>
+	<string name="_bx_analytics_dim_referrer"><![CDATA[Referrer URL]]></string>
+	<string name="_bx_analytics_dim_referrer_host"><![CDATA[Referrer]]></string>
+	<string name="_bx_analytics_dim_region"><![CDATA[Region]]></string>
+	<string name="_bx_analytics_dim_screen"><![CDATA[Screen size]]></string>
+	<string name="_bx_analytics_dim_title"><![CDATA[Page title]]></string>
+	<string name="_bx_analytics_dim_utm_campaign"><![CDATA[UTM campaign]]></string>
+	<string name="_bx_analytics_dim_utm_content"><![CDATA[UTM content]]></string>
+	<string name="_bx_analytics_dim_utm_medium"><![CDATA[UTM medium]]></string>
+	<string name="_bx_analytics_dim_utm_source"><![CDATA[UTM source]]></string>
+	<string name="_bx_analytics_dim_utm_term"><![CDATA[UTM term]]></string>
+	<string name="_bx_analytics_error_kind_http"><![CDATA[Server error]]></string>
+	<string name="_bx_analytics_error_kind_js"><![CDATA[JavaScript error]]></string>
+	<string name="_bx_analytics_error_kind_native"><![CDATA[App error]]></string>
+	<string name="_bx_analytics_error_kind_overflow"><![CDATA[Other]]></string>
+	<string name="_bx_analytics_error_kind_promise"><![CDATA[Unhandled rejection]]></string>
+	<string name="_bx_analytics_error_kind_react"><![CDATA[Render error]]></string>
+	<string name="_bx_analytics_error_kind_resource"><![CDATA[Failed to load]]></string>
+	<string name="_bx_analytics_error_status_ignored"><![CDATA[Ignored]]></string>
+	<string name="_bx_analytics_error_status_open"><![CDATA[Open]]></string>
+	<string name="_bx_analytics_error_status_resolved"><![CDATA[Resolved]]></string>
+	<string name="_bx_analytics_event_type_event"><![CDATA[Custom event]]></string>
+	<string name="_bx_analytics_event_type_pageview"><![CDATA[Pageview]]></string>
+	<string name="_bx_analytics_event_type_una"><![CDATA[Site action]]></string>
+	<string name="_bx_analytics_geo_cloudflare"><![CDATA[Cloudflare headers only]]></string>
+	<string name="_bx_analytics_geo_cloudfront"><![CDATA[CloudFront headers only]]></string>
+	<string name="_bx_analytics_geo_headers"><![CDATA[Any CDN's headers, the first one found]]></string>
+	<string name="_bx_analytics_geo_none"><![CDATA[Off]]></string>
+	<string name="_bx_analytics_geo_vercel"><![CDATA[Vercel headers only]]></string>
+	<string name="_bx_analytics_group_bots"><![CDATA[Bot categories]]></string>
+	<string name="_bx_analytics_group_devices"><![CDATA[Devices]]></string>
+	<string name="_bx_analytics_group_errors"><![CDATA[Errors by status]]></string>
+	<string name="_bx_analytics_group_events"><![CDATA[Event types]]></string>
+	<string name="_bx_analytics_group_locations"><![CDATA[Locations]]></string>
+	<string name="_bx_analytics_group_map"><![CDATA[Map]]></string>
+	<string name="_bx_analytics_group_pages"><![CDATA[Pages]]></string>
+	<string name="_bx_analytics_group_performance"><![CDATA[Group pages by]]></string>
+	<string name="_bx_analytics_group_platforms"><![CDATA[Platforms]]></string>
+	<string name="_bx_analytics_group_retention"><![CDATA[Who to count]]></string>
+	<string name="_bx_analytics_group_revenue"><![CDATA[Revenue by]]></string>
+	<string name="_bx_analytics_group_sources"><![CDATA[Sources]]></string>
+	<string name="_bx_analytics_identity_cookieless"><![CDATA[Cookieless (recommended)]]></string>
+	<string name="_bx_analytics_identity_persistent"><![CDATA[Persistent id on the device]]></string>
+	<string name="_bx_analytics_page_block_title_bots"><![CDATA[Bots and agents]]></string>
 	<string name="_bx_analytics_page_block_title_canvas"><![CDATA[Analytics]]></string>
+	<string name="_bx_analytics_page_block_title_credits"><![CDATA[Credits]]></string>
+	<string name="_bx_analytics_page_block_title_errors"><![CDATA[Errors]]></string>
+	<string name="_bx_analytics_page_block_title_events"><![CDATA[Events]]></string>
+	<string name="_bx_analytics_page_block_title_filters"><![CDATA[Filters]]></string>
+	<string name="_bx_analytics_page_block_title_funnels"><![CDATA[Funnels]]></string>
+	<string name="_bx_analytics_page_block_title_goals"><![CDATA[Goals]]></string>
+	<string name="_bx_analytics_page_block_title_journeys"><![CDATA[Journeys]]></string>
+	<string name="_bx_analytics_page_block_title_map"><![CDATA[Visitors by location]]></string>
+	<string name="_bx_analytics_page_block_title_overview"><![CDATA[Overview]]></string>
+	<string name="_bx_analytics_page_block_title_performance"><![CDATA[Performance]]></string>
+	<string name="_bx_analytics_page_block_title_query"><![CDATA[Query]]></string>
+	<string name="_bx_analytics_page_block_title_realtime"><![CDATA[Right now]]></string>
+	<string name="_bx_analytics_page_block_title_retention"><![CDATA[Retention]]></string>
+	<string name="_bx_analytics_page_block_title_revenue"><![CDATA[Revenue]]></string>
+	<string name="_bx_analytics_page_block_title_sales"><![CDATA[Sales and sellers]]></string>
+	<string name="_bx_analytics_page_block_title_sessions"><![CDATA[Sessions]]></string>
+	<string name="_bx_analytics_page_block_title_std_dash_traffic"><![CDATA[Traffic]]></string>
+	<string name="_bx_analytics_page_block_title_subscriptions"><![CDATA[Subscriptions]]></string>
+	<string name="_bx_analytics_page_block_title_system_bots"><![CDATA[Analytics: bots and agents]]></string>
 	<string name="_bx_analytics_page_block_title_system_canvas"><![CDATA[Analytics]]></string>
+	<string name="_bx_analytics_page_block_title_system_credits"><![CDATA[Analytics: credits]]></string>
+	<string name="_bx_analytics_page_block_title_system_errors"><![CDATA[Analytics: errors]]></string>
+	<string name="_bx_analytics_page_block_title_system_events"><![CDATA[Analytics: events]]></string>
+	<string name="_bx_analytics_page_block_title_system_filters"><![CDATA[Analytics: filters]]></string>
+	<string name="_bx_analytics_page_block_title_system_funnels"><![CDATA[Analytics: funnels]]></string>
+	<string name="_bx_analytics_page_block_title_system_goals"><![CDATA[Analytics: goals]]></string>
+	<string name="_bx_analytics_page_block_title_system_journeys"><![CDATA[Analytics: journeys]]></string>
+	<string name="_bx_analytics_page_block_title_system_map"><![CDATA[Analytics: map]]></string>
+	<string name="_bx_analytics_page_block_title_system_overview"><![CDATA[Analytics: overview]]></string>
+	<string name="_bx_analytics_page_block_title_system_performance"><![CDATA[Analytics: performance]]></string>
+	<string name="_bx_analytics_page_block_title_system_query"><![CDATA[Analytics: query]]></string>
+	<string name="_bx_analytics_page_block_title_system_realtime"><![CDATA[Analytics: realtime]]></string>
+	<string name="_bx_analytics_page_block_title_system_retention"><![CDATA[Analytics: retention]]></string>
+	<string name="_bx_analytics_page_block_title_system_revenue"><![CDATA[Analytics: revenue]]></string>
+	<string name="_bx_analytics_page_block_title_system_sales"><![CDATA[Analytics: sales and sellers]]></string>
+	<string name="_bx_analytics_page_block_title_system_sessions"><![CDATA[Analytics: sessions]]></string>
+	<string name="_bx_analytics_page_block_title_system_subscriptions"><![CDATA[Analytics: subscriptions]]></string>
+	<string name="_bx_analytics_page_block_title_system_table_pages"><![CDATA[Analytics: pages table]]></string>
+	<string name="_bx_analytics_page_block_title_system_table_sources"><![CDATA[Analytics: sources table]]></string>
+	<string name="_bx_analytics_page_block_title_system_top_devices"><![CDATA[Analytics: devices]]></string>
+	<string name="_bx_analytics_page_block_title_system_top_locations"><![CDATA[Analytics: locations]]></string>
+	<string name="_bx_analytics_page_block_title_system_top_pages"><![CDATA[Analytics: pages]]></string>
+	<string name="_bx_analytics_page_block_title_system_top_platforms"><![CDATA[Analytics: platforms]]></string>
+	<string name="_bx_analytics_page_block_title_system_top_sources"><![CDATA[Analytics: sources]]></string>
+	<string name="_bx_analytics_page_block_title_system_users"><![CDATA[Analytics: users]]></string>
+	<string name="_bx_analytics_page_block_title_table_pages"><![CDATA[Pages]]></string>
+	<string name="_bx_analytics_page_block_title_table_sources"><![CDATA[Sources]]></string>
+	<string name="_bx_analytics_page_block_title_top_devices"><![CDATA[Devices]]></string>
+	<string name="_bx_analytics_page_block_title_top_locations"><![CDATA[Locations]]></string>
+	<string name="_bx_analytics_page_block_title_top_pages"><![CDATA[Pages]]></string>
+	<string name="_bx_analytics_page_block_title_top_platforms"><![CDATA[Platforms]]></string>
+	<string name="_bx_analytics_page_block_title_top_sources"><![CDATA[Sources]]></string>
+	<string name="_bx_analytics_page_block_title_users"><![CDATA[Users]]></string>
 	<string name="_bx_analytics_adm_stg_cpt_modules_enabled"><![CDATA[Enabled for modules]]></string>
+	<string name="_bx_analytics_adm_stg_cpt_online_window"><![CDATA[Online window, minutes]]></string>
+	<string name="_bx_analytics_adm_stg_cpt_platforms"><![CDATA[Collect from]]></string>
+	<string name="_bx_analytics_adm_stg_cpt_respect_dnt"><![CDATA[Respect Do Not Track]]></string>
+	<string name="_bx_analytics_adm_stg_cpt_retention_days"><![CDATA[Keep data, days]]></string>
+	<string name="_bx_analytics_adm_stg_cpt_session_timeout"><![CDATA[Session timeout, minutes]]></string>
+	<string name="_bx_analytics_adm_stg_cpt_track_actions"><![CDATA[Record site actions]]></string>
+	<string name="_bx_analytics_adm_stg_cpt_track_bots"><![CDATA[Count bots and agents]]></string>
+	<string name="_bx_analytics_adm_stg_cpt_track_errors"><![CDATA[Record errors]]></string>
+	<string name="_bx_analytics_adm_stg_cpt_track_vitals"><![CDATA[Measure performance]]></string>
+	<string name="_bx_analytics_adm_stg_cpt_tracking"><![CDATA[Collect visits and events]]></string>
+	<string name="_bx_analytics_adm_stg_cpt_trusted_proxies"><![CDATA[Trusted proxies]]></string>
 	<string name="_bx_analytics_adm_stg_cpt_items_count"><![CDATA[Max number of items]]></string>
+	<string name="_bx_analytics_adm_stg_cpt_limit_hits"><![CDATA[Hits per minute from one address]]></string>
+	<string name="_bx_analytics_adm_stg_cpt_limit_visits"><![CDATA[New visits per hour from one address]]></string>
+	<string name="_bx_analytics_adm_stg_cpt_link_members"><![CDATA[Link visits to members]]></string>
 	<string name="_bx_analytics_adm_stg_cpt_default_interval_day"><![CDATA[Default interval in days]]></string>
+	<string name="_bx_analytics_adm_stg_cpt_errors_ignore"><![CDATA[Ignored errors]]></string>
+	<string name="_bx_analytics_adm_stg_cpt_exclude_ips"><![CDATA[Excluded IP addresses]]></string>
+	<string name="_bx_analytics_adm_stg_cpt_exclude_levels"><![CDATA[Don't track these levels]]></string>
+	<string name="_bx_analytics_adm_stg_cpt_exclude_paths"><![CDATA[Excluded paths]]></string>
+	<string name="_bx_analytics_adm_stg_cpt_geo_source"><![CDATA[Visitor location]]></string>
+	<string name="_bx_analytics_adm_stg_cpt_identity"><![CDATA[Guest visitor ids]]></string>
+	<string name="_bx_analytics_adm_stg_cpt_ignore_params"><![CDATA[Query parameters to ignore]]></string>
 	<string name="_bx_analytics_page_title_sys_page"><![CDATA[Analytics]]></string>
+	<string name="_bx_analytics_page_title_sys_revenue"><![CDATA[Analytics: revenue]]></string>
 	<string name="_bx_analytics_page_title_page"><![CDATA[Analytics]]></string>
+	<string name="_bx_analytics_page_title_revenue"><![CDATA[Revenue]]></string>
 	<string name="_bx_analytics_page_block_title_system_canvas"><![CDATA[Analytics]]></string>
 	<string name="_bx_analytics_page_block_title_canvas"><![CDATA[Analytics]]></string>
+	<string name="_bx_analytics_platform_api"><![CDATA[API]]></string>
+	<string name="_bx_analytics_platform_core"><![CDATA[Server]]></string>
+	<string name="_bx_analytics_platform_core_web"><![CDATA[Web (classic)]]></string>
+	<string name="_bx_analytics_platform_next_web"><![CDATA[Web (NEO)]]></string>
+	<string name="_bx_analytics_platform_rn_android"><![CDATA[Android app]]></string>
+	<string name="_bx_analytics_platform_rn_ios"><![CDATA[iOS app]]></string>
+	<string name="_bx_analytics_range_12m"><![CDATA[Last 12 months]]></string>
+	<string name="_bx_analytics_range_24h"><![CDATA[Last 24 hours]]></string>
+	<string name="_bx_analytics_range_30d"><![CDATA[Last 30 days]]></string>
+	<string name="_bx_analytics_range_7d"><![CDATA[Last 7 days]]></string>
+	<string name="_bx_analytics_range_90d"><![CDATA[Last 90 days]]></string>
+	<string name="_bx_analytics_range_today"><![CDATA[Today]]></string>
+	<string name="_bx_analytics_range_yesterday"><![CDATA[Yesterday]]></string>
+	<string name="_bx_analytics_rating_good"><![CDATA[Good]]></string>
+	<string name="_bx_analytics_rating_needs_improvement"><![CDATA[Needs improvement]]></string>
+	<string name="_bx_analytics_rating_poor"><![CDATA[Poor]]></string>
+	<string name="_bx_analytics_report_bots"><![CDATA[Bots and agents]]></string>
+	<string name="_bx_analytics_report_credits"><![CDATA[Credits]]></string>
+	<string name="_bx_analytics_report_errors"><![CDATA[Errors]]></string>
+	<string name="_bx_analytics_report_events"><![CDATA[Events]]></string>
+	<string name="_bx_analytics_report_filters"><![CDATA[Filter bar]]></string>
+	<string name="_bx_analytics_report_funnels"><![CDATA[Funnels]]></string>
+	<string name="_bx_analytics_report_goals"><![CDATA[Goals]]></string>
+	<string name="_bx_analytics_report_journeys"><![CDATA[Journeys]]></string>
+	<string name="_bx_analytics_report_map"><![CDATA[Visitors by location]]></string>
+	<string name="_bx_analytics_report_overview"><![CDATA[Overview]]></string>
+	<string name="_bx_analytics_report_performance"><![CDATA[Performance]]></string>
+	<string name="_bx_analytics_report_query"><![CDATA[Query]]></string>
+	<string name="_bx_analytics_report_realtime"><![CDATA[Right now]]></string>
+	<string name="_bx_analytics_report_retention"><![CDATA[Retention]]></string>
+	<string name="_bx_analytics_report_revenue"><![CDATA[Revenue]]></string>
+	<string name="_bx_analytics_report_sales"><![CDATA[Sales and sellers]]></string>
+	<string name="_bx_analytics_report_sessions"><![CDATA[Sessions]]></string>
+	<string name="_bx_analytics_report_subscriptions"><![CDATA[Subscriptions]]></string>
+	<string name="_bx_analytics_report_table"><![CDATA[Table]]></string>
+	<string name="_bx_analytics_report_top"><![CDATA[Top list]]></string>
+	<string name="_bx_analytics_report_users"><![CDATA[Users]]></string>
+	<string name="_bx_analytics_section_behavior"><![CDATA[Behavior]]></string>
+	<string name="_bx_analytics_section_community"><![CDATA[Community]]></string>
+	<string name="_bx_analytics_section_configure"><![CDATA[Configure]]></string>
+	<string name="_bx_analytics_section_conversion"><![CDATA[Conversion]]></string>
+	<string name="_bx_analytics_section_explore"><![CDATA[Explore]]></string>
+	<string name="_bx_analytics_section_health"><![CDATA[Health]]></string>
+	<string name="_bx_analytics_section_revenue"><![CDATA[Revenue]]></string>
+	<string name="_bx_analytics_section_traffic"><![CDATA[Traffic]]></string>
 	<string name="_bx_analytics_item_title_system_analytics"><![CDATA[Analytics]]></string>
+	<string name="_bx_analytics_item_title_system_revenue"><![CDATA[Revenue]]></string>
 	<string name="_bx_analytics_item_title_analytics"><![CDATA[Analytics]]></string>
+	<string name="_bx_analytics_item_title_revenue"><![CDATA[Revenue]]></string>
+	<string name="_bx_analytics_lmi_cpt_api"><![CDATA[API]]></string>
+	<string name="_bx_analytics_lmi_cpt_audience"><![CDATA[Audience]]></string>
+	<string name="_bx_analytics_lmi_cpt_bots"><![CDATA[Bots and agents]]></string>
+	<string name="_bx_analytics_lmi_cpt_content"><![CDATA[Content reports]]></string>
+	<string name="_bx_analytics_lmi_cpt_credits"><![CDATA[Credits]]></string>
+	<string name="_bx_analytics_lmi_cpt_dashboards"><![CDATA[Dashboards]]></string>
+	<string name="_bx_analytics_lmi_cpt_errors"><![CDATA[Errors]]></string>
+	<string name="_bx_analytics_lmi_cpt_events"><![CDATA[Events]]></string>
+	<string name="_bx_analytics_lmi_cpt_funnels"><![CDATA[Funnels]]></string>
+	<string name="_bx_analytics_lmi_cpt_goals"><![CDATA[Goals]]></string>
+	<string name="_bx_analytics_lmi_cpt_journeys"><![CDATA[Journeys]]></string>
+	<string name="_bx_analytics_lmi_cpt_map"><![CDATA[Map]]></string>
+	<string name="_bx_analytics_lmi_cpt_overview"><![CDATA[Overview]]></string>
+	<string name="_bx_analytics_lmi_cpt_pages"><![CDATA[Pages]]></string>
+	<string name="_bx_analytics_lmi_cpt_performance"><![CDATA[Performance]]></string>
+	<string name="_bx_analytics_lmi_cpt_query"><![CDATA[Query]]></string>
+	<string name="_bx_analytics_lmi_cpt_realtime"><![CDATA[Realtime]]></string>
+	<string name="_bx_analytics_lmi_cpt_retention"><![CDATA[Retention]]></string>
+	<string name="_bx_analytics_lmi_cpt_revenue"><![CDATA[Revenue]]></string>
+	<string name="_bx_analytics_lmi_cpt_sales"><![CDATA[Sales and sellers]]></string>
+	<string name="_bx_analytics_lmi_cpt_sessions"><![CDATA[Sessions]]></string>
+	<string name="_bx_analytics_lmi_cpt_setup"><![CDATA[Setup]]></string>
+	<string name="_bx_analytics_lmi_cpt_sources"><![CDATA[Sources]]></string>
+	<string name="_bx_analytics_lmi_cpt_subscriptions"><![CDATA[Subscriptions]]></string>
+	<string name="_bx_analytics_lmi_cpt_users"><![CDATA[Users]]></string>
+	<string name="_bx_analytics_metric_ai_hits"><![CDATA[AI requests]]></string>
+	<string name="_bx_analytics_metric_bot_hits"><![CDATA[Requests]]></string>
+	<string name="_bx_analytics_metric_bot_share"><![CDATA[Share of all requests]]></string>
+	<string name="_bx_analytics_metric_bots"><![CDATA[Bots]]></string>
+	<string name="_bx_analytics_metric_bounce_rate"><![CDATA[Bounce rate]]></string>
+	<string name="_bx_analytics_metric_count"><![CDATA[Count]]></string>
+	<string name="_bx_analytics_metric_crash_free"><![CDATA[Crash-free app visits]]></string>
+	<string name="_bx_analytics_metric_credits_bundles"><![CDATA[Bundles sold for money]]></string>
+	<string name="_bx_analytics_metric_duration"><![CDATA[Session duration]]></string>
+	<string name="_bx_analytics_metric_engaged"><![CDATA[Time on page]]></string>
+	<string name="_bx_analytics_metric_entries"><![CDATA[Entries]]></string>
+	<string name="_bx_analytics_metric_error_sessions"><![CDATA[Visits with errors]]></string>
+	<string name="_bx_analytics_metric_errors"><![CDATA[Errors]]></string>
+	<string name="_bx_analytics_metric_exits"><![CDATA[Exits]]></string>
+	<string name="_bx_analytics_metric_new_errors"><![CDATA[New errors]]></string>
+	<string name="_bx_analytics_metric_occurrences"><![CDATA[Occurrences]]></string>
+	<string name="_bx_analytics_metric_pages_per_session"><![CDATA[Pages per session]]></string>
+	<string name="_bx_analytics_metric_pageviews"><![CDATA[Pageviews]]></string>
+	<string name="_bx_analytics_metric_per_visitor"><![CDATA[Per visitor]]></string>
+	<string name="_bx_analytics_metric_revenue_arppu"><![CDATA[Revenue per customer]]></string>
+	<string name="_bx_analytics_metric_revenue_customers"><![CDATA[Paying customers]]></string>
+	<string name="_bx_analytics_metric_revenue_gross"><![CDATA[Gross revenue]]></string>
+	<string name="_bx_analytics_metric_revenue_net"><![CDATA[Net revenue]]></string>
+	<string name="_bx_analytics_metric_revenue_orders"><![CDATA[Orders]]></string>
+	<string name="_bx_analytics_metric_revenue_refunds"><![CDATA[Refunds]]></string>
+	<string name="_bx_analytics_metric_sales_commissions"><![CDATA[Commissions paid]]></string>
+	<string name="_bx_analytics_metric_sales_site"><![CDATA[Sold by the site]]></string>
+	<string name="_bx_analytics_metric_sales_units"><![CDATA[Units]]></string>
+	<string name="_bx_analytics_metric_sales_vendors"><![CDATA[Sold by vendors]]></string>
+	<string name="_bx_analytics_metric_sbs_active"><![CDATA[Paying subscriptions]]></string>
+	<string name="_bx_analytics_metric_sbs_cancelled"><![CDATA[Cancelled]]></string>
+	<string name="_bx_analytics_metric_sbs_churn"><![CDATA[Churn]]></string>
+	<string name="_bx_analytics_metric_sbs_mrr"><![CDATA[MRR]]></string>
+	<string name="_bx_analytics_metric_sbs_new"><![CDATA[New]]></string>
+	<string name="_bx_analytics_metric_sbs_renewed"><![CDATA[Renewed]]></string>
+	<string name="_bx_analytics_metric_sbs_trials"><![CDATA[Trials]]></string>
+	<string name="_bx_analytics_metric_sessions"><![CDATA[Sessions]]></string>
+	<string name="_bx_analytics_metric_visitors"><![CDATA[Unique visitors]]></string>
+	<string name="_bx_analytics_object_sys_profiles_friends"><![CDATA[Friends]]></string>
+	<string name="_bx_analytics_object_sys_profiles_relations"><![CDATA[Relations]]></string>
+	<string name="_bx_analytics_object_sys_profiles_subscriptions"><![CDATA[Follows]]></string>
 	<string name="_bx_analytics_type_top_contents_by_likes"><![CDATA[Top contents by likes]]></string>
 	<string name="_bx_analytics_type_content_growth"><![CDATA[Total numbers]]></string>
 	<string name="_bx_analytics_type_content_speed"><![CDATA[Growth speed]]></string>
@@ -22,19 +324,402 @@
 	<string name="_bx_analytics_type_top_by_views"><![CDATA[Top by views]]></string>
 	<string name="_bx_analytics_type_most_followed_profiles"><![CDATA[Most followed profiles]]></string>
 	<string name="_bx_analytics_type_most_active_profiles"><![CDATA[Most active profiles]]></string>
+	<string name="_bx_analytics_vital_app_start"><![CDATA[App start]]></string>
+	<string name="_bx_analytics_vital_cls"><![CDATA[Layout shift (CLS)]]></string>
+	<string name="_bx_analytics_vital_fcp"><![CDATA[First paint (FCP)]]></string>
+	<string name="_bx_analytics_vital_inp"><![CDATA[Response to input (INP)]]></string>
+	<string name="_bx_analytics_vital_lcp"><![CDATA[Largest paint (LCP)]]></string>
+	<string name="_bx_analytics_vital_screen_load"><![CDATA[Screen load]]></string>
+	<string name="_bx_analytics_vital_ttfb"><![CDATA[Server response (TTFB)]]></string>
 	<string name="_bx_analytics_system_text"><![CDATA[System]]></string>
+	<string name="_bx_analytics_tab_app_versions"><![CDATA[App versions]]></string>
+	<string name="_bx_analytics_tab_apps"><![CDATA[Apps]]></string>
+	<string name="_bx_analytics_tab_bots_ai"><![CDATA[AI]]></string>
+	<string name="_bx_analytics_tab_bots_all"><![CDATA[All]]></string>
+	<string name="_bx_analytics_tab_bots_monitor"><![CDATA[Monitoring]]></string>
+	<string name="_bx_analytics_tab_bots_script"><![CDATA[Scripts and other]]></string>
+	<string name="_bx_analytics_tab_bots_search"><![CDATA[Search engines]]></string>
+	<string name="_bx_analytics_tab_bots_seo"><![CDATA[SEO tools]]></string>
+	<string name="_bx_analytics_tab_bots_social"><![CDATA[Link previews]]></string>
+	<string name="_bx_analytics_tab_browsers"><![CDATA[Browsers]]></string>
+	<string name="_bx_analytics_tab_channels"><![CDATA[Channels]]></string>
+	<string name="_bx_analytics_tab_cities"><![CDATA[Cities]]></string>
+	<string name="_bx_analytics_tab_countries"><![CDATA[Countries]]></string>
+	<string name="_bx_analytics_tab_devices"><![CDATA[Devices]]></string>
+	<string name="_bx_analytics_tab_entry"><![CDATA[Entry pages]]></string>
+	<string name="_bx_analytics_tab_errors_all"><![CDATA[All]]></string>
+	<string name="_bx_analytics_tab_errors_ignored"><![CDATA[Ignored]]></string>
+	<string name="_bx_analytics_tab_errors_new"><![CDATA[New]]></string>
+	<string name="_bx_analytics_tab_errors_open"><![CDATA[Open]]></string>
+	<string name="_bx_analytics_tab_errors_resolved"><![CDATA[Resolved]]></string>
+	<string name="_bx_analytics_tab_events_all"><![CDATA[All]]></string>
+	<string name="_bx_analytics_tab_events_custom"><![CDATA[Custom events]]></string>
+	<string name="_bx_analytics_tab_events_site"><![CDATA[Site actions]]></string>
+	<string name="_bx_analytics_tab_exit"><![CDATA[Exit pages]]></string>
+	<string name="_bx_analytics_tab_hostnames"><![CDATA[Hostnames]]></string>
+	<string name="_bx_analytics_tab_languages"><![CDATA[Languages]]></string>
+	<string name="_bx_analytics_tab_map_live"><![CDATA[Live]]></string>
+	<string name="_bx_analytics_tab_map_period"><![CDATA[This period]]></string>
+	<string name="_bx_analytics_tab_os"><![CDATA[Operating systems]]></string>
+	<string name="_bx_analytics_tab_pages"><![CDATA[Pages]]></string>
+	<string name="_bx_analytics_tab_platforms"><![CDATA[Platforms]]></string>
+	<string name="_bx_analytics_tab_referrer_urls"><![CDATA[Referrer URLs]]></string>
+	<string name="_bx_analytics_tab_referrers"><![CDATA[Referrers]]></string>
+	<string name="_bx_analytics_tab_regions"><![CDATA[Regions]]></string>
+	<string name="_bx_analytics_tab_retention_members"><![CDATA[Members]]></string>
+	<string name="_bx_analytics_tab_retention_visitors"><![CDATA[All visitors]]></string>
+	<string name="_bx_analytics_tab_revenue_campaigns"><![CDATA[Campaign]]></string>
+	<string name="_bx_analytics_tab_revenue_first_touch"><![CDATA[First visit from]]></string>
+	<string name="_bx_analytics_tab_revenue_last_touch"><![CDATA[Last visit from]]></string>
+	<string name="_bx_analytics_tab_revenue_platforms"><![CDATA[Platform]]></string>
+	<string name="_bx_analytics_tab_revenue_providers"><![CDATA[Payment provider]]></string>
+	<string name="_bx_analytics_tab_revenue_sources"><![CDATA[Source]]></string>
+	<string name="_bx_analytics_tab_screens"><![CDATA[Screen sizes]]></string>
+	<string name="_bx_analytics_tab_titles"><![CDATA[Page titles]]></string>
+	<string name="_bx_analytics_tab_utm_campaign"><![CDATA[UTM campaign]]></string>
+	<string name="_bx_analytics_tab_utm_content"><![CDATA[UTM content]]></string>
+	<string name="_bx_analytics_tab_utm_medium"><![CDATA[UTM medium]]></string>
+	<string name="_bx_analytics_tab_utm_source"><![CDATA[UTM source]]></string>
+	<string name="_bx_analytics_tab_utm_term"><![CDATA[UTM term]]></string>
+	<string name="_bx_analytics_tab_vitals_path"><![CDATA[Paths]]></string>
+	<string name="_bx_analytics_tab_vitals_route"><![CDATA[Routes]]></string>
 	<string name="_bx_analytics_txt_empty_title_item"><![CDATA[Empty title]]></string>
+	<string name="_bx_analytics_txt_error"><![CDATA[Error]]></string>
+	<string name="_bx_analytics_txt_error_actions"><![CDATA[Change status]]></string>
+	<string name="_bx_analytics_txt_error_all_time"><![CDATA[All occurrences]]></string>
+	<string name="_bx_analytics_txt_error_http_status"><![CDATA[HTTP status]]></string>
+	<string name="_bx_analytics_txt_error_kind"><![CDATA[Kind]]></string>
+	<string name="_bx_analytics_txt_error_latest"><![CDATA[Latest occurrences]]></string>
+	<string name="_bx_analytics_txt_error_loading"><![CDATA[Couldn't load this report. Reload the page to try again.]]></string>
+	<string name="_bx_analytics_txt_error_mark_ignored"><![CDATA[Ignore]]></string>
+	<string name="_bx_analytics_txt_error_mark_open"><![CDATA[Reopen]]></string>
+	<string name="_bx_analytics_txt_error_mark_resolved"><![CDATA[Mark resolved]]></string>
+	<string name="_bx_analytics_txt_error_missing"><![CDATA[This error is no longer stored.]]></string>
+	<string name="_bx_analytics_txt_error_no_stack"><![CDATA[No stack trace came with this error.]]></string>
+	<string name="_bx_analytics_txt_error_overflow"><![CDATA[Other errors: more new kinds in an hour than the limit]]></string>
+	<string name="_bx_analytics_txt_error_report"><![CDATA[Couldn't load this report.]]></string>
+	<string name="_bx_analytics_txt_error_source"><![CDATA[Where]]></string>
+	<string name="_bx_analytics_txt_error_stack"><![CDATA[Stack trace]]></string>
+	<string name="_bx_analytics_txt_error_stack_hidden"><![CDATA[A stack trace can show what a visitor typed or opened, so only those who may see visitor details can read it.]]></string>
+	<string name="_bx_analytics_txt_error_status"><![CDATA[Status]]></string>
+	<string name="_bx_analytics_txt_event"><![CDATA[Event]]></string>
 	<string name="_bx_analytics_txt_item"><![CDATA[Item]]></string>
+	<string name="_bx_analytics_txt_item_n"><![CDATA[Item #{0}]]></string>
+	<string name="_bx_analytics_txt_journey_from"><![CDATA[Where visitors went from {0}]]></string>
+	<string name="_bx_analytics_txt_journeys_truncated"><![CDATA[This period has too many pageviews to follow them all; the latest visits are shown.]]></string>
+	<string name="_bx_analytics_txt_key_figures"><![CDATA[Key figures]]></string>
+	<string name="_bx_analytics_txt_last_seen"><![CDATA[Last seen]]></string>
+	<string name="_bx_analytics_txt_latest_pageviews"><![CDATA[Latest pageviews]]></string>
+	<string name="_bx_analytics_txt_left_site"><![CDATA[Left]]></string>
+	<string name="_bx_analytics_txt_lifetime_value"><![CDATA[Lifetime value]]></string>
+	<string name="_bx_analytics_txt_location"><![CDATA[Location]]></string>
+	<string name="_bx_analytics_txt_map_label"><![CDATA[Map of visitors by location]]></string>
+	<string name="_bx_analytics_txt_map_unknown"><![CDATA[{0} visitors from unknown locations are not on the map.]]></string>
+	<string name="_bx_analytics_txt_match_hint"><![CDATA[A page path starts with / and can use * for any characters, for example /blogs/*. An action is an event name such as signup, or name@module for one module, for example added@bx_posts.]]></string>
+	<string name="_bx_analytics_txt_member"><![CDATA[member]]></string>
+	<string name="_bx_analytics_txt_member_name"><![CDATA[Member]]></string>
+	<string name="_bx_analytics_txt_metric"><![CDATA[Metric]]></string>
+	<string name="_bx_analytics_txt_minutes_ago"><![CDATA[{0} min ago]]></string>
+	<string name="_bx_analytics_txt_ms"><![CDATA[ms]]></string>
+	<string name="_bx_analytics_txt_n_samples"><![CDATA[{0} measurements]]></string>
+	<string name="_bx_analytics_txt_n_sessions"><![CDATA[{0} visits]]></string>
+	<string name="_bx_analytics_txt_name"><![CDATA[Name]]></string>
+	<string name="_bx_analytics_txt_next"><![CDATA[Next]]></string>
+	<string name="_bx_analytics_txt_no_bots"><![CDATA[No bots in this period]]></string>
+	<string name="_bx_analytics_txt_no_cancellations"><![CDATA[No cancellations in this period.]]></string>
+	<string name="_bx_analytics_txt_no_credit_orders"><![CDATA[No orders paid with credits in this period.]]></string>
+	<string name="_bx_analytics_txt_no_credits"><![CDATA[No credits spent in this period.]]></string>
+	<string name="_bx_analytics_txt_no_data"><![CDATA[No data for this period]]></string>
+	<string name="_bx_analytics_txt_no_data_yet"><![CDATA[No visits recorded yet]]></string>
+	<string name="_bx_analytics_txt_no_errors"><![CDATA[No errors in this period.]]></string>
+	<string name="_bx_analytics_txt_no_events"><![CDATA[No events in this period]]></string>
+	<string name="_bx_analytics_txt_no_funnels"><![CDATA[No funnels yet. A funnel shows how many visits go through a series of steps and where they drop off.]]></string>
+	<string name="_bx_analytics_txt_no_goals"><![CDATA[No goals yet. A goal counts the visits that reach a page or an action, such as a sign-up or a new post.]]></string>
+	<string name="_bx_analytics_txt_no_members"><![CDATA[No members visited in this period]]></string>
+	<string name="_bx_analytics_txt_no_properties"><![CDATA[This event has no properties]]></string>
+	<string name="_bx_analytics_txt_no_recent"><![CDATA[No pageviews in the last 30 minutes]]></string>
+	<string name="_bx_analytics_txt_no_revenue"><![CDATA[No revenue in this period.]]></string>
+	<string name="_bx_analytics_txt_no_subscriptions"><![CDATA[No paying subscriptions.]]></string>
+	<string name="_bx_analytics_txt_no_vitals"><![CDATA[No measurements in this period.]]></string>
+	<string name="_bx_analytics_txt_not_available"><![CDATA[n/a]]></string>
+	<string name="_bx_analytics_txt_not_enough_data"><![CDATA[Not enough data]]></string>
+	<string name="_bx_analytics_txt_now"><![CDATA[now]]></string>
+	<string name="_bx_analytics_txt_online_now"><![CDATA[online in the last {0} minutes]]></string>
+	<string name="_bx_analytics_txt_open_analytics"><![CDATA[Open analytics]]></string>
+	<string name="_bx_analytics_txt_p75"><![CDATA[75th percentile]]></string>
+	<string name="_bx_analytics_txt_page_of"><![CDATA[Page {0} of {1}]]></string>
+	<string name="_bx_analytics_txt_pageviews_30m"><![CDATA[Pageviews per minute over the last 30 minutes]]></string>
+	<string name="_bx_analytics_txt_pageviews_at"><![CDATA[{0} pageviews at {1}]]></string>
+	<string name="_bx_analytics_txt_path"><![CDATA[Path]]></string>
+	<string name="_bx_analytics_txt_pick_next"><![CDATA[Pick a page in step 2 to see where visitors went after it.]]></string>
+	<string name="_bx_analytics_txt_platform"><![CDATA[Platform]]></string>
+	<string name="_bx_analytics_txt_prev"><![CDATA[Previous]]></string>
+	<string name="_bx_analytics_txt_previous_period"><![CDATA[Previous period: {0}]]></string>
+	<string name="_bx_analytics_txt_properties"><![CDATA[Properties]]></string>
+	<string name="_bx_analytics_txt_range"><![CDATA[Period]]></string>
+	<string name="_bx_analytics_txt_rating"><![CDATA[Rating]]></string>
+	<string name="_bx_analytics_txt_raw_value"><![CDATA[Stored value]]></string>
+	<string name="_bx_analytics_txt_remove_filter"><![CDATA[Remove filter: {0}]]></string>
+	<string name="_bx_analytics_txt_retention_cookieless"><![CDATA[Guests get a new id every day in cookieless mode, so they all look like new visitors here. Switch guest ids to persistent in Settings to follow them.]]></string>
+	<string name="_bx_analytics_txt_revenue_as_recorded"><![CDATA[Amounts in {0} as recorded by the payment module, taxes included where the provider charged them. Orders paid with credits are counted under Credits.]]></string>
+	<string name="_bx_analytics_txt_revenue_commissions"><![CDATA[Commission invoices]]></string>
+	<string name="_bx_analytics_txt_revenue_credits_only"><![CDATA[The site takes payments in credits only, so revenue here is empty. See Credits for what credits paid for.]]></string>
+	<string name="_bx_analytics_txt_revenue_imported"><![CDATA[Imported (platform unknown)]]></string>
+	<string name="_bx_analytics_txt_revenue_no_campaign"><![CDATA[No campaign]]></string>
+	<string name="_bx_analytics_txt_revenue_no_payment"><![CDATA[No payment module is active, so no new payments are recorded.]]></string>
+	<string name="_bx_analytics_txt_revenue_no_rate"><![CDATA[Payments in another currency with no exchange rate, left out of the totals: {0}.]]></string>
+	<string name="_bx_analytics_txt_revenue_split"><![CDATA[Revenue split]]></string>
+	<string name="_bx_analytics_txt_revenue_untracked"><![CDATA[No tracked visit]]></string>
+	<string name="_bx_analytics_txt_route"><![CDATA[Route]]></string>
+	<string name="_bx_analytics_txt_sales_invoices"><![CDATA[Commission invoices issued in this period]]></string>
+	<string name="_bx_analytics_txt_sales_items"><![CDATA[Top items]]></string>
+	<string name="_bx_analytics_txt_sales_seller"><![CDATA[Seller]]></string>
+	<string name="_bx_analytics_txt_sales_sellers"><![CDATA[Top sellers]]></string>
+	<string name="_bx_analytics_txt_sales_vendor_count"><![CDATA[{0} vendors]]></string>
+	<string name="_bx_analytics_txt_samples"><![CDATA[Measurements]]></string>
+	<string name="_bx_analytics_txt_save"><![CDATA[Save]]></string>
+	<string name="_bx_analytics_txt_sbs_churn_no_base"><![CDATA[No paying subscriptions at the start of the period]]></string>
+	<string name="_bx_analytics_txt_sbs_movement"><![CDATA[New, renewed and cancelled]]></string>
+	<string name="_bx_analytics_txt_sbs_no_reason"><![CDATA[No reason given]]></string>
+	<string name="_bx_analytics_txt_sbs_plan"><![CDATA[Plan]]></string>
+	<string name="_bx_analytics_txt_sbs_plans"><![CDATA[Plans]]></string>
+	<string name="_bx_analytics_txt_sbs_reason"><![CDATA[Reason]]></string>
+	<string name="_bx_analytics_txt_sbs_reasons"><![CDATA[Why members cancel]]></string>
+	<string name="_bx_analytics_txt_sbs_trial_mrr"><![CDATA[{0} a month once paid]]></string>
+	<string name="_bx_analytics_txt_setup_actions_off"><![CDATA[Site actions are not recorded (Settings > Track site actions)]]></string>
+	<string name="_bx_analytics_txt_setup_actions_on"><![CDATA[Site actions are recorded (Settings > Track site actions)]]></string>
+	<string name="_bx_analytics_txt_setup_alerts"><![CDATA[Recorded from these alerts and from new content of every content module:]]></string>
+	<string name="_bx_analytics_txt_setup_api"><![CDATA[Any other client sends hits as JSON to the collect service with an API key (Studio > API). The payload is described in BxAnalyticsCollector.]]></string>
+	<string name="_bx_analytics_txt_setup_app_version"><![CDATA[Latest app version]]></string>
+	<string name="_bx_analytics_txt_setup_check"><![CDATA[Check this browser]]></string>
+	<string name="_bx_analytics_txt_setup_check_hint"><![CDATA[See whether your own visits to the site are recorded, and what the server sees of this browser. The check stores nothing.]]></string>
+	<string name="_bx_analytics_txt_setup_check_title"><![CDATA[Check this browser]]></string>
+	<string name="_bx_analytics_txt_setup_col_status"><![CDATA[Status]]></string>
+	<string name="_bx_analytics_txt_setup_core"><![CDATA[Site actions are recorded on the server, on the platform of the request that did them. Core is for cron and other jobs without a request.]]></string>
+	<string name="_bx_analytics_txt_setup_core_web"><![CDATA[Automatic: the tracker script is added to every page of the classic site.]]></string>
+	<string name="_bx_analytics_txt_setup_core_web_exclusions"><![CDATA[Not recorded: bots, members of excluded levels, browsers that ask not to be tracked (when the site respects it), excluded addresses and paths. Studio pages have no tracker.]]></string>
+	<string name="_bx_analytics_txt_setup_curl"><![CDATA[Example request]]></string>
+	<string name="_bx_analytics_txt_setup_events"><![CDATA[Custom events]]></string>
+	<string name="_bx_analytics_txt_setup_events_classic"><![CDATA[Classic site (JavaScript)]]></string>
+	<string name="_bx_analytics_txt_setup_events_hint"><![CDATA[Names are up to 64 letters, digits, spaces and _ . : / -. Properties are short text, numbers or true and false, up to 2 KB in all.]]></string>
+	<string name="_bx_analytics_txt_setup_events_neo"><![CDATA[NEO web and apps]]></string>
+	<string name="_bx_analytics_txt_setup_events_server"><![CDATA[Server (PHP)]]></string>
+	<string name="_bx_analytics_txt_setup_events_warning"><![CDATA[In server code call track, not collect: collect records the server's own address and user agent as an API client.]]></string>
+	<string name="_bx_analytics_txt_setup_hits_24h"><![CDATA[Hits, 24 h]]></string>
+	<string name="_bx_analytics_txt_setup_injection_off"><![CDATA[The tracker is not added to pages: its injection (bx_analytics_tracker) is off]]></string>
+	<string name="_bx_analytics_txt_setup_injection_on"><![CDATA[The tracker is added to pages]]></string>
+	<string name="_bx_analytics_txt_setup_install"><![CDATA[What each client needs]]></string>
+	<string name="_bx_analytics_txt_setup_last_hit"><![CDATA[Last hit]]></string>
+	<string name="_bx_analytics_txt_setup_neo"><![CDATA[Built in: NEO reads its settings from get_tracker_config and sends hits to the collect service. Their last hits are in the table above.]]></string>
+	<string name="_bx_analytics_txt_setup_neo_access"><![CDATA[API access by key or by origin is on]]></string>
+	<string name="_bx_analytics_txt_setup_neo_api"><![CDATA[The API is on]]></string>
+	<string name="_bx_analytics_txt_setup_neo_title"><![CDATA[NEO web and apps]]></string>
+	<string name="_bx_analytics_txt_setup_never"><![CDATA[Never]]></string>
+	<string name="_bx_analytics_txt_setup_none"><![CDATA[None]]></string>
+	<string name="_bx_analytics_txt_setup_open_settings"><![CDATA[Open settings]]></string>
+	<string name="_bx_analytics_txt_setup_platform_off"><![CDATA[{0} is not tracked (Settings > Platforms)]]></string>
+	<string name="_bx_analytics_txt_setup_platform_on"><![CDATA[{0} is tracked]]></string>
+	<string name="_bx_analytics_txt_setup_sessions_7d"><![CDATA[Visits, 7 days]]></string>
+	<string name="_bx_analytics_txt_setup_status"><![CDATA[Tracker status]]></string>
+	<string name="_bx_analytics_txt_setup_status_nodata"><![CDATA[No data]]></string>
+	<string name="_bx_analytics_txt_setup_status_off"><![CDATA[Off]]></string>
+	<string name="_bx_analytics_txt_setup_status_quiet"><![CDATA[Quiet]]></string>
+	<string name="_bx_analytics_txt_setup_status_receiving"><![CDATA[Receiving]]></string>
+	<string name="_bx_analytics_txt_setup_test"><![CDATA[Send test event]]></string>
+	<string name="_bx_analytics_txt_setup_test_dropped"><![CDATA[The test event was not stored: this browser isn't tracked. Check this browser to see why.]]></string>
+	<string name="_bx_analytics_txt_setup_test_stored"><![CDATA[The test event was stored: it is in Events as analytics_setup_test, and the status table is updated.]]></string>
+	<string name="_bx_analytics_txt_setup_test_title"><![CDATA[Analytics setup test]]></string>
+	<string name="_bx_analytics_txt_setup_test_warning"><![CDATA[The test event goes through the real collector, so it adds a visit from you to the reports.]]></string>
+	<string name="_bx_analytics_txt_setup_tracking_off"><![CDATA[Tracking is off: nothing is recorded on any platform.]]></string>
+	<string name="_bx_analytics_txt_setup_visitors_24h"><![CDATA[Visitors, 24 h]]></string>
+	<string name="_bx_analytics_txt_share"><![CDATA[Share]]></string>
+	<string name="_bx_analytics_txt_sort"><![CDATA[Order]]></string>
+	<string name="_bx_analytics_txt_sort_asc"><![CDATA[Lowest first]]></string>
+	<string name="_bx_analytics_txt_sort_desc"><![CDATA[Highest first]]></string>
+	<string name="_bx_analytics_txt_source"><![CDATA[Source]]></string>
+	<string name="_bx_analytics_txt_start_page"><![CDATA[Start page]]></string>
+	<string name="_bx_analytics_txt_started"><![CDATA[Started]]></string>
+	<string name="_bx_analytics_txt_step"><![CDATA[Step {0}]]></string>
+	<string name="_bx_analytics_txt_step_match"><![CDATA[Step {0}: page path or action name]]></string>
+	<string name="_bx_analytics_txt_step_type"><![CDATA[Step {0}: what the visitor does]]></string>
+	<string name="_bx_analytics_txt_threshold_good"><![CDATA[Good up to]]></string>
+	<string name="_bx_analytics_txt_threshold_poor"><![CDATA[Poor above]]></string>
+	<string name="_bx_analytics_txt_time"><![CDATA[Time]]></string>
+	<string name="_bx_analytics_txt_time_spent"><![CDATA[Time spent]]></string>
+	<string name="_bx_analytics_txt_timeline"><![CDATA[Timeline]]></string>
+	<string name="_bx_analytics_txt_top_paths"><![CDATA[Most common paths]]></string>
 	<string name="_bx_analytics_txt_date"><![CDATA[Date]]></string>
+	<string name="_bx_analytics_txt_delete"><![CDATA[Delete]]></string>
+	<string name="_bx_analytics_txt_deleted_member"><![CDATA[Deleted member]]></string>
+	<string name="_bx_analytics_txt_device"><![CDATA[Devices]]></string>
+	<string name="_bx_analytics_txt_device_all"><![CDATA[All devices]]></string>
+	<string name="_bx_analytics_txt_device_desktop"><![CDATA[Computers]]></string>
+	<string name="_bx_analytics_txt_device_mobile"><![CDATA[Phones and tablets]]></string>
+	<string name="_bx_analytics_txt_dimension"><![CDATA[Break down by]]></string>
+	<string name="_bx_analytics_txt_dims_event"><![CDATA[Pages]]></string>
+	<string name="_bx_analytics_txt_dims_session"><![CDATA[Sessions]]></string>
+	<string name="_bx_analytics_txt_direct"><![CDATA[Direct / none]]></string>
+	<string name="_bx_analytics_txt_download_csv"><![CDATA[Download CSV]]></string>
+	<string name="_bx_analytics_txt_dropped"><![CDATA[{0} dropped off]]></string>
+	<string name="_bx_analytics_txt_duration"><![CDATA[Duration]]></string>
+	<string name="_bx_analytics_txt_duration_h"><![CDATA[h]]></string>
+	<string name="_bx_analytics_txt_duration_m"><![CDATA[m]]></string>
+	<string name="_bx_analytics_txt_duration_s"><![CDATA[s]]></string>
+	<string name="_bx_analytics_txt_edit"><![CDATA[Edit]]></string>
+	<string name="_bx_analytics_txt_edit_funnel"><![CDATA[Edit funnel]]></string>
+	<string name="_bx_analytics_txt_edit_goal"><![CDATA[Edit goal]]></string>
 	<string name="_bx_analytics_txt_value"><![CDATA[Value]]></string>
+	<string name="_bx_analytics_txt_visitor"><![CDATA[Visitor]]></string>
+	<string name="_bx_analytics_txt_vital_distribution"><![CDATA[Good {0} · needs improvement {1} · poor {2}]]></string>
+	<string name="_bx_analytics_txt_vital_thresholds"><![CDATA[Good up to {0}, poor above {1}]]></string>
+	<string name="_bx_analytics_txt_vitals_metrics"><![CDATA[Metrics: pick one for the chart and the tables]]></string>
+	<string name="_bx_analytics_txt_vitals_pages"><![CDATA[Pages]]></string>
+	<string name="_bx_analytics_txt_week_n"><![CDATA[Week {0}]]></string>
 	<string name="_bx_analytics_txt_total_count"><![CDATA[Total]]></string>
+	<string name="_bx_analytics_txt_trend"><![CDATA[Trend]]></string>
+	<string name="_bx_analytics_txt_unknown"><![CDATA[(none)]]></string>
 	<string name="_bx_analytics_txt_count"><![CDATA[{0}]]></string>
+	<string name="_bx_analytics_txt_crash_free_apps"><![CDATA[iOS and Android apps]]></string>
+	<string name="_bx_analytics_txt_credits_all_platforms"><![CDATA[Credits figures are for all platforms: the modules that keep credits don't record where a member was.]]></string>
+	<string name="_bx_analytics_txt_credits_liability"><![CDATA[Worth {0}]]></string>
+	<string name="_bx_analytics_txt_credits_none"><![CDATA[No installed module keeps credits.]]></string>
+	<string name="_bx_analytics_txt_credits_not_in_ledger"><![CDATA[Credits were spent on checkouts the revenue ledger doesn't have yet, so they aren't listed by what they paid for. Importing past payments adds them.]]></string>
+	<string name="_bx_analytics_txt_credits_spenders"><![CDATA[Top spenders]]></string>
+	<string name="_bx_analytics_txt_credits_spent"><![CDATA[Credits spent]]></string>
+	<string name="_bx_analytics_txt_credits_spent_on"><![CDATA[What credits paid for]]></string>
+	<string name="_bx_analytics_txt_credits_value"><![CDATA[Value]]></string>
+	<string name="_bx_analytics_txt_csv_truncated"><![CDATA[The download has the first {0} of {1} rows.]]></string>
+	<string name="_bx_analytics_txt_dash_blocks"><![CDATA[Blocks]]></string>
+	<string name="_bx_analytics_txt_dash_col_actions"><![CDATA[Actions]]></string>
+	<string name="_bx_analytics_txt_dash_col_page"><![CDATA[Page]]></string>
+	<string name="_bx_analytics_txt_dash_create"><![CDATA[New analytics page]]></string>
+	<string name="_bx_analytics_txt_dash_create_btn"><![CDATA[Create page]]></string>
+	<string name="_bx_analytics_txt_dash_create_failed"><![CDATA[The page could not be created. Enter a title of up to 100 characters and try again.]]></string>
+	<string name="_bx_analytics_txt_dash_create_hint"><![CDATA[The page gets the filter bar, key figures and top lists, shown on the site and in NEO, and opens in the page builder.]]></string>
+	<string name="_bx_analytics_txt_dash_disable_warning"><![CDATA[Pages created here belong to Analytics: disabling the module removes them with their blocks.]]></string>
+	<string name="_bx_analytics_txt_dash_edit"><![CDATA[Edit]]></string>
+	<string name="_bx_analytics_txt_dash_edit_label"><![CDATA[Edit {0} in the page builder]]></string>
+	<string name="_bx_analytics_txt_dash_empty"><![CDATA[No site page has analytics blocks.]]></string>
+	<string name="_bx_analytics_txt_dash_intro"><![CDATA[Site pages with analytics blocks. Add blocks to any page in the page builder, or start a new analytics page here.]]></string>
+	<string name="_bx_analytics_txt_dash_neo_blocks"><![CDATA[In NEO]]></string>
+	<string name="_bx_analytics_txt_dash_neo_note"><![CDATA[In NEO counts the blocks NEO and the apps show. The others show on the classic site only.]]></string>
+	<string name="_bx_analytics_txt_dash_pages"><![CDATA[Pages with analytics blocks]]></string>
+	<string name="_bx_analytics_txt_dash_title"><![CDATA[Title]]></string>
 	<string name="_bx_analytics_txt_accounts"><![CDATA[Accounts]]></string>
+	<string name="_bx_analytics_txt_actions"><![CDATA[Events and actions]]></string>
+	<string name="_bx_analytics_txt_add_funnel"><![CDATA[Add funnel]]></string>
+	<string name="_bx_analytics_txt_add_goal"><![CDATA[Add goal]]></string>
+	<string name="_bx_analytics_txt_all_platforms"><![CDATA[All platforms]]></string>
+	<string name="_bx_analytics_txt_api_bad_params"><![CDATA[Parameters must be a JSON object, such as {"range": "7d"}.]]></string>
+	<string name="_bx_analytics_txt_api_col_accepts"><![CDATA[Accepts]]></string>
+	<string name="_bx_analytics_txt_api_col_param"><![CDATA[Parameter]]></string>
+	<string name="_bx_analytics_txt_api_common_params"><![CDATA[Filter parameters of every report]]></string>
+	<string name="_bx_analytics_txt_api_default"><![CDATA[Default: {0}.]]></string>
+	<string name="_bx_analytics_txt_api_disabled"><![CDATA[The API is off (Studio > API), so other clients can't call get_report. The playground still runs reports here.]]></string>
+	<string name="_bx_analytics_txt_api_docs"><![CDATA[What the parameters accept]]></string>
+	<string name="_bx_analytics_txt_api_intro"><![CDATA[Try get_report, the service NEO and other clients read reports from. It runs here as you, with your permissions, and answers with the same JSON as api.php.]]></string>
+	<string name="_bx_analytics_txt_api_meta"><![CDATA[Status {0}, {1} ms, {2}]]></string>
+	<string name="_bx_analytics_txt_api_neo"><![CDATA[Request of a NEO element]]></string>
+	<string name="_bx_analytics_txt_api_no_access"><![CDATA[API access by key and by origin are both off, so api.php refuses every client. The playground still runs reports here.]]></string>
+	<string name="_bx_analytics_txt_api_no_params"><![CDATA[This report has no parameters of its own.]]></string>
+	<string name="_bx_analytics_txt_api_params"><![CDATA[Parameters (JSON)]]></string>
+	<string name="_bx_analytics_txt_api_params_hint"><![CDATA[Filters and the report's own parameters, for example {"range": "30d", "platform": "next-web", "f": {"country": "US"}}.]]></string>
+	<string name="_bx_analytics_txt_api_report"><![CDATA[Report]]></string>
+	<string name="_bx_analytics_txt_api_report_params"><![CDATA[Parameters of the {0} report]]></string>
+	<string name="_bx_analytics_txt_api_response"><![CDATA[Response]]></string>
+	<string name="_bx_analytics_txt_api_run"><![CDATA[Run]]></string>
+	<string name="_bx_analytics_txt_api_running"><![CDATA[Running...]]></string>
+	<string name="_bx_analytics_txt_api_srv"><![CDATA[PHP]]></string>
+	<string name="_bx_analytics_txt_api_truncated"><![CDATA[Only the first 1 MB is shown.]]></string>
+	<string name="_bx_analytics_txt_api_type_date"><![CDATA[A date, YYYY-MM-DD, with the custom range.]]></string>
+	<string name="_bx_analytics_txt_api_type_dims"><![CDATA[Filters as an object of dimension and value. Dimensions: {0}.]]></string>
+	<string name="_bx_analytics_txt_api_type_enum"><![CDATA[One of: {0}.]]></string>
+	<string name="_bx_analytics_txt_api_type_enum_by"><![CDATA[Depends on {0}: {1}.]]></string>
+	<string name="_bx_analytics_txt_api_type_int"><![CDATA[A number, {0} or more.]]></string>
+	<string name="_bx_analytics_txt_api_type_int_range"><![CDATA[A number from {0} to {1}.]]></string>
+	<string name="_bx_analytics_txt_api_type_string"><![CDATA[Text.]]></string>
+	<string name="_bx_analytics_txt_api_url"><![CDATA[api.php URL (needs an API key or a registered origin)]]></string>
+	<string name="_bx_analytics_txt_back_errors"><![CDATA[All errors]]></string>
+	<string name="_bx_analytics_txt_back_events"><![CDATA[All events]]></string>
+	<string name="_bx_analytics_txt_back_members"><![CDATA[All members]]></string>
+	<string name="_bx_analytics_txt_back_sessions"><![CDATA[All sessions]]></string>
+	<string name="_bx_analytics_txt_bot"><![CDATA[Bot]]></string>
+	<string name="_bx_analytics_txt_bot_pages"><![CDATA[Most requested pages]]></string>
+	<string name="_bx_analytics_txt_cancel"><![CDATA[Cancel]]></string>
+	<string name="_bx_analytics_txt_chart_bots"><![CDATA[Bot requests per day by category]]></string>
+	<string name="_bx_analytics_txt_chart_credits"><![CDATA[Credits over time]]></string>
+	<string name="_bx_analytics_txt_chart_error"><![CDATA[Occurrences of {0} over time]]></string>
+	<string name="_bx_analytics_txt_chart_event"><![CDATA[{0} over time]]></string>
+	<string name="_bx_analytics_txt_chart_label"><![CDATA[Chart of the selected figure over the period]]></string>
+	<string name="_bx_analytics_txt_chart_mrr"><![CDATA[Monthly recurring revenue over time]]></string>
+	<string name="_bx_analytics_txt_chart_query"><![CDATA[{0} over time for the first {1} rows of the table]]></string>
+	<string name="_bx_analytics_txt_chart_revenue"><![CDATA[Revenue over time]]></string>
+	<string name="_bx_analytics_txt_chart_visitors_7d"><![CDATA[Unique visitors over the last 7 days]]></string>
+	<string name="_bx_analytics_txt_chart_vital"><![CDATA[{0}: 75th percentile over time]]></string>
+	<string name="_bx_analytics_txt_check_bot"><![CDATA[This browser identifies as a bot, so it is counted with the bots.]]></string>
+	<string name="_bx_analytics_txt_check_cross_site"><![CDATA[The request came from another site.]]></string>
+	<string name="_bx_analytics_txt_check_dnt"><![CDATA[This browser asks not to be tracked (Do Not Track or Global Privacy Control), and the site respects it.]]></string>
+	<string name="_bx_analytics_txt_check_excluded_ip"><![CDATA[Your address is excluded (Settings > Excluded addresses).]]></string>
+	<string name="_bx_analytics_txt_check_excluded_level"><![CDATA[Your membership level is excluded (Settings > Excluded levels).]]></string>
+	<string name="_bx_analytics_txt_check_excluded_path"><![CDATA[The home page is on an excluded path (Settings > Excluded paths).]]></string>
+	<string name="_bx_analytics_txt_check_geo"><![CDATA[Country]]></string>
+	<string name="_bx_analytics_txt_check_geo_header"><![CDATA[{1}, from the {0} header]]></string>
+	<string name="_bx_analytics_txt_check_geo_none"><![CDATA[No country header in this request]]></string>
+	<string name="_bx_analytics_txt_check_geo_off"><![CDATA[Not read: the location source is off]]></string>
+	<string name="_bx_analytics_txt_check_ip"><![CDATA[Address recorded]]></string>
+	<string name="_bx_analytics_txt_check_link_members_off"><![CDATA[Visits are recorded but not tied to your account: linking members to visits is off.]]></string>
+	<string name="_bx_analytics_txt_check_no_hits"><![CDATA[The page address is not one of the site's own.]]></string>
+	<string name="_bx_analytics_txt_check_not_tracked"><![CDATA[Visits from this browser are not recorded]]></string>
+	<string name="_bx_analytics_txt_check_platform_off"><![CDATA[The classic site is not among the tracked platforms (Settings > Platforms).]]></string>
+	<string name="_bx_analytics_txt_check_rate_limited"><![CDATA[Your address has sent more than the limits allow (Settings > Hits per minute, New visits per hour). Its hits are dropped until the next minute, or new visits until the next hour.]]></string>
+	<string name="_bx_analytics_txt_check_remote_addr"><![CDATA[Connection from (REMOTE_ADDR)]]></string>
+	<string name="_bx_analytics_txt_check_tracked"><![CDATA[Visits from this browser are recorded]]></string>
+	<string name="_bx_analytics_txt_check_tracking_off"><![CDATA[Tracking is off.]]></string>
+	<string name="_bx_analytics_txt_cohort"><![CDATA[First visit, week of]]></string>
+	<string name="_bx_analytics_txt_conversions"><![CDATA[{0} visits, {1} visitors]]></string>
+	<string name="_bx_analytics_txt_copied"><![CDATA[Copied]]></string>
+	<string name="_bx_analytics_txt_copy"><![CDATA[Copy]]></string>
 	<string name="_bx_analytics_txt_export_to_csv_title"><![CDATA[Export to CSV file]]></string>
+	<string name="_bx_analytics_txt_fatal"><![CDATA[Fatal]]></string>
+	<string name="_bx_analytics_txt_filter_by"><![CDATA[Show only {0}]]></string>
+	<string name="_bx_analytics_txt_first_seen"><![CDATA[First seen]]></string>
+	<string name="_bx_analytics_txt_funnel_delete_confirm"><![CDATA[Delete the funnel "{0}"?]]></string>
+	<string name="_bx_analytics_txt_funnel_hint"><![CDATA[Fill in 2 to 6 steps in the order visitors take them. A visit counts for a step when it reaches that step after the previous one. Each step is a page path (/blogs/*) or an action (signup, added@bx_posts).]]></string>
+	<string name="_bx_analytics_txt_funnel_invalid"><![CDATA[Enter a name and 2 to 6 steps. Page paths start with / (or *); action names use letters, digits and _ . : / -]]></string>
+	<string name="_bx_analytics_txt_goal_badge_event"><![CDATA[Action]]></string>
+	<string name="_bx_analytics_txt_goal_badge_page"><![CDATA[Page]]></string>
+	<string name="_bx_analytics_txt_goal_delete_confirm"><![CDATA[Delete the goal "{0}"?]]></string>
+	<string name="_bx_analytics_txt_goal_invalid"><![CDATA[Enter a name and a page path that starts with / (or *), or an action name of letters, digits and _ . : / -]]></string>
+	<string name="_bx_analytics_txt_goal_limit"><![CDATA[There can be up to {0} goals. Delete one to add another.]]></string>
+	<string name="_bx_analytics_txt_goal_match"><![CDATA[Page path or action name]]></string>
+	<string name="_bx_analytics_txt_goal_type"><![CDATA[Completed when a visitor]]></string>
+	<string name="_bx_analytics_txt_goal_type_event"><![CDATA[Does an action]]></string>
+	<string name="_bx_analytics_txt_goal_type_page"><![CDATA[Views a page]]></string>
+	<string name="_bx_analytics_txt_guest"><![CDATA[Guest]]></string>
+	<string name="_bx_analytics_txt_import_again"><![CDATA[Import again]]></string>
+	<string name="_bx_analytics_txt_import_confirm"><![CDATA[Read every past payment again? Payments that are already here are skipped.]]></string>
+	<string name="_bx_analytics_txt_import_continue"><![CDATA[Continue now]]></string>
+	<string name="_bx_analytics_txt_import_done"><![CDATA[Past payments are imported: {0} in all.]]></string>
+	<string name="_bx_analytics_txt_import_hint"><![CDATA[The import runs every hour until it is done. Refunded orders can't be imported, because the payment module deletes them, and imported payments have no platform or visit.]]></string>
+	<string name="_bx_analytics_txt_import_running"><![CDATA[Importing past payments: {0} imported so far.]]></string>
+	<string name="_bx_analytics_txt_import_title"><![CDATA[Past payments]]></string>
 	<string name="_bx_analytics_txt_total_content"><![CDATA[All content]]></string>
 	<string name="_bx_analytics_type_top_by_views_label"><![CDATA[Views]]></string>
 	<string name="_bx_analytics_type_top_by_likes_label"><![CDATA[Likes]]></string>
 	<string name="_bx_analytics_type_top_by_followers_label"><![CDATA[Followed]]></string>
 	<string name="_bx_analytics_type_top_by_activity_label"><![CDATA[Content]]></string>
+	<string name="_bx_analytics_acl_action_manage"><![CDATA[Manage analytics goals and funnels]]></string>
 	<string name="_bx_analytics_acl_action_use_analytics"><![CDATA[Use Analytics]]></string>	
+	<string name="_bx_analytics_acl_action_view_people"><![CDATA[View members in analytics]]></string>
+	<string name="_bx_analytics_acl_action_view_revenue"><![CDATA[View analytics revenue]]></string>
+	<string name="_bx_analytics_action_comment"><![CDATA[Comment]]></string>
+	<string name="_bx_analytics_action_connection"><![CDATA[Connection]]></string>
+	<string name="_bx_analytics_action_content_add"><![CDATA[Content created]]></string>
+	<string name="_bx_analytics_action_login"><![CDATA[Login]]></string>
+	<string name="_bx_analytics_action_profile_add"><![CDATA[Profile created]]></string>
+	<string name="_bx_analytics_action_purchase"><![CDATA[Purchase]]></string>
+	<string name="_bx_analytics_action_renewal"><![CDATA[Renewal]]></string>
+	<string name="_bx_analytics_action_signup"><![CDATA[Sign-up]]></string>
+	<string name="_bx_analytics_action_trial_start"><![CDATA[Trial started]]></string>
+	<string name="_bx_analytics_action_vote"><![CDATA[Reaction]]></string>
 </resources>

+ 12 - 1
modules/boonex/analytics/install/sql/disable.sql

@@ -15,4 +15,15 @@ DELETE FROM `sys_menu_items` WHERE `module` = @sName ;
 
 -- ACL
 DELETE `sys_acl_actions`, `sys_acl_matrix` FROM `sys_acl_actions`, `sys_acl_matrix` WHERE `sys_acl_matrix`.`IDAction` = `sys_acl_actions`.`ID` AND `sys_acl_actions`.`Module` = @sName;
-DELETE FROM `sys_acl_actions` WHERE `Module` = @sName;
+DELETE FROM `sys_acl_actions` WHERE `Module` = @sName;
+
+-- INJECTIONS
+DELETE FROM `sys_injections` WHERE `name` = 'bx_analytics_tracker';
+
+-- ALERTS
+SET @iHandler := (SELECT `id` FROM `sys_alerts_handlers` WHERE `name` = @sName LIMIT 1);
+DELETE FROM `sys_alerts` WHERE `handler_id` = @iHandler;
+DELETE FROM `sys_alerts_handlers` WHERE `id` = @iHandler;
+
+-- CRON
+DELETE FROM `sys_cron_jobs` WHERE `name` = 'bx_analytics_pruning';

+ 32 - 0
modules/boonex/analytics/install/sql/enable-app.sql

@@ -0,0 +1,32 @@
+-- PAGES: config_api
+
+-- PAGES: active_api
+UPDATE `sys_pages_blocks` SET `active_api`=1 WHERE `object`='bx_analytics_page' AND `module`='bx_analytics' AND `title_system`='_bx_analytics_page_block_title_system_filters' AND `title`='_bx_analytics_page_block_title_filters';
+UPDATE `sys_pages_blocks` SET `active_api`=1 WHERE `object`='bx_analytics_page' AND `module`='bx_analytics' AND `title_system`='_bx_analytics_page_block_title_system_overview' AND `title`='_bx_analytics_page_block_title_overview';
+UPDATE `sys_pages_blocks` SET `active_api`=1 WHERE `object`='bx_analytics_page' AND `module`='bx_analytics' AND `title_system`='_bx_analytics_page_block_title_system_top_pages' AND `title`='_bx_analytics_page_block_title_top_pages';
+UPDATE `sys_pages_blocks` SET `active_api`=1 WHERE `object`='bx_analytics_page' AND `module`='bx_analytics' AND `title_system`='_bx_analytics_page_block_title_system_top_sources' AND `title`='_bx_analytics_page_block_title_top_sources';
+UPDATE `sys_pages_blocks` SET `active_api`=1 WHERE `object`='bx_analytics_page' AND `module`='bx_analytics' AND `title_system`='_bx_analytics_page_block_title_system_top_locations' AND `title`='_bx_analytics_page_block_title_top_locations';
+UPDATE `sys_pages_blocks` SET `active_api`=1 WHERE `object`='bx_analytics_page' AND `module`='bx_analytics' AND `title_system`='_bx_analytics_page_block_title_system_top_devices' AND `title`='_bx_analytics_page_block_title_top_devices';
+UPDATE `sys_pages_blocks` SET `active_api`=1 WHERE `object`='bx_analytics_page' AND `module`='bx_analytics' AND `title_system`='_bx_analytics_page_block_title_system_top_platforms' AND `title`='_bx_analytics_page_block_title_top_platforms';
+-- library blocks (object ''), copies the page builder places on other pages; only reports NEO has an element for
+UPDATE `sys_pages_blocks` SET `active_api`=1 WHERE `object`='' AND `module`='bx_analytics' AND `title_system`='_bx_analytics_page_block_title_system_filters' AND `title`='_bx_analytics_page_block_title_filters';
+UPDATE `sys_pages_blocks` SET `active_api`=1 WHERE `object`='' AND `module`='bx_analytics' AND `title_system`='_bx_analytics_page_block_title_system_overview' AND `title`='_bx_analytics_page_block_title_overview';
+UPDATE `sys_pages_blocks` SET `active_api`=1 WHERE `object`='' AND `module`='bx_analytics' AND `title_system`='_bx_analytics_page_block_title_system_top_pages' AND `title`='_bx_analytics_page_block_title_top_pages';
+UPDATE `sys_pages_blocks` SET `active_api`=1 WHERE `object`='' AND `module`='bx_analytics' AND `title_system`='_bx_analytics_page_block_title_system_top_sources' AND `title`='_bx_analytics_page_block_title_top_sources';
+UPDATE `sys_pages_blocks` SET `active_api`=1 WHERE `object`='' AND `module`='bx_analytics' AND `title_system`='_bx_analytics_page_block_title_system_top_locations' AND `title`='_bx_analytics_page_block_title_top_locations';
+UPDATE `sys_pages_blocks` SET `active_api`=1 WHERE `object`='' AND `module`='bx_analytics' AND `title_system`='_bx_analytics_page_block_title_system_top_devices' AND `title`='_bx_analytics_page_block_title_top_devices';
+UPDATE `sys_pages_blocks` SET `active_api`=1 WHERE `object`='' AND `module`='bx_analytics' AND `title_system`='_bx_analytics_page_block_title_system_top_platforms' AND `title`='_bx_analytics_page_block_title_top_platforms';
+UPDATE `sys_pages_blocks` SET `active_api`=1 WHERE `object`='' AND `module`='bx_analytics' AND `title_system`='_bx_analytics_page_block_title_system_revenue' AND `title`='_bx_analytics_page_block_title_revenue';
+UPDATE `sys_pages_blocks` SET `active_api`=1 WHERE `object`='' AND `module`='bx_analytics' AND `title_system`='_bx_analytics_page_block_title_system_subscriptions' AND `title`='_bx_analytics_page_block_title_subscriptions';
+UPDATE `sys_pages_blocks` SET `active_api`=1 WHERE `object`='' AND `module`='bx_analytics' AND `title_system`='_bx_analytics_page_block_title_system_sales' AND `title`='_bx_analytics_page_block_title_sales';
+UPDATE `sys_pages_blocks` SET `active_api`=1 WHERE `object`='' AND `module`='bx_analytics' AND `title_system`='_bx_analytics_page_block_title_system_credits' AND `title`='_bx_analytics_page_block_title_credits';
+-- the revenue page (account dashboard > Revenue); its blocks return nothing without "view analytics revenue"
+UPDATE `sys_pages_blocks` SET `active_api`=1 WHERE `object`='bx_analytics_revenue' AND `module`='bx_analytics';
+
+-- MENUS:
+
+-- MENUS: config_api
+
+-- MENUS: active_api
+UPDATE `sys_menu_items` SET `active_api`=1 WHERE `set_name`='sys_account_dashboard' AND `module`='bx_analytics' AND `name`='dashboard-analytics';
+UPDATE `sys_menu_items` SET `active_api`=1 WHERE `set_name`='sys_account_dashboard' AND `module`='bx_analytics' AND `name`='analytics-revenue';

+ 152 - 6
modules/boonex/analytics/install/sql/enable.sql

@@ -3,12 +3,50 @@ SET @sName = 'bx_analytics';
 -- SETTINGS
 SET @iTypeOrder = (SELECT IFNULL(MAX(`order`), 0) + 1 FROM `sys_options_types` WHERE `group` = 'modules');
 
-INSERT INTO `sys_options_types` (`group`, `name`, `caption`, `icon`, `order`) 
+INSERT INTO `sys_options_types` (`group`, `name`, `caption`, `icon`, `order`)
 VALUES('modules', @sName, '_bx_analytics_adm_stg_cpt_type', 'bx_analytics@modules/boonex/analytics/|std-icon.svg', IF(ISNULL(@iTypeOrder), 1, @iTypeOrder + 1));
 
 SET @iTypeId = LAST_INSERT_ID();
 
-INSERT INTO `sys_options_categories` (`type_id`, `name`, `caption`, `hidden`, `order`) VALUES(@iTypeId,  'bx_analytics_general', '_bx_analytics_adm_stg_cpt_category_general', 0, 1);
+INSERT INTO `sys_options_categories` (`type_id`, `name`, `caption`, `hidden`, `order`) VALUES(@iTypeId, 'bx_analytics_tracking', '_bx_analytics_adm_stg_cpt_category_tracking', 0, 1);
+SET @iCategoryId = LAST_INSERT_ID();
+
+INSERT INTO `sys_options` (`category_id`, `name`, `caption`, `info`, `value`, `type`, `extra`, `check`, `check_params`, `check_error`, `order`) VALUES
+(@iCategoryId, 'bx_analytics_tracking', '_bx_analytics_adm_stg_cpt_tracking', '_bx_analytics_adm_stg_inf_tracking', 'on', 'checkbox', '', '', '', '', 1),
+(@iCategoryId, 'bx_analytics_platforms', '_bx_analytics_adm_stg_cpt_platforms', '_bx_analytics_adm_stg_inf_platforms', 'core-web,next-web,rn-ios,rn-android,api,core', 'list', 'a:2:{s:6:"module";s:12:"bx_analytics";s:6:"method";s:21:"get_options_platforms";}', '', '', '', 2),
+(@iCategoryId, 'bx_analytics_session_timeout', '_bx_analytics_adm_stg_cpt_session_timeout', '_bx_analytics_adm_stg_inf_session_timeout', '30', 'digit', '', '', '', '', 3),
+(@iCategoryId, 'bx_analytics_online_window', '_bx_analytics_adm_stg_cpt_online_window', '_bx_analytics_adm_stg_inf_online_window', '5', 'digit', '', '', '', '', 4),
+(@iCategoryId, 'bx_analytics_ignore_params', '_bx_analytics_adm_stg_cpt_ignore_params', '_bx_analytics_adm_stg_inf_ignore_params', 'range, tz, platform, metric, bucket, from, to, jfrom, f[*], tab_*, page_*, q_*', 'text', '', '', '', '', 5),
+(@iCategoryId, 'bx_analytics_track_bots', '_bx_analytics_adm_stg_cpt_track_bots', '_bx_analytics_adm_stg_inf_track_bots', 'on', 'checkbox', '', '', '', '', 6),
+(@iCategoryId, 'bx_analytics_track_actions', '_bx_analytics_adm_stg_cpt_track_actions', '_bx_analytics_adm_stg_inf_track_actions', 'on', 'checkbox', '', '', '', '', 7),
+(@iCategoryId, 'bx_analytics_limit_hits', '_bx_analytics_adm_stg_cpt_limit_hits', '_bx_analytics_adm_stg_inf_limit_hits', '300', 'digit', '', '', '', '', 8),
+(@iCategoryId, 'bx_analytics_limit_visits', '_bx_analytics_adm_stg_cpt_limit_visits', '_bx_analytics_adm_stg_inf_limit_visits', '100', 'digit', '', '', '', '', 9);
+
+INSERT INTO `sys_options_categories` (`type_id`, `name`, `caption`, `hidden`, `order`) VALUES(@iTypeId, 'bx_analytics_privacy', '_bx_analytics_adm_stg_cpt_category_privacy', 0, 2);
+SET @iCategoryId = LAST_INSERT_ID();
+
+INSERT INTO `sys_options` (`category_id`, `name`, `caption`, `info`, `value`, `type`, `extra`, `check`, `check_params`, `check_error`, `order`) VALUES
+(@iCategoryId, 'bx_analytics_identity', '_bx_analytics_adm_stg_cpt_identity', '_bx_analytics_adm_stg_inf_identity', 'cookieless', 'select', 'a:2:{s:6:"module";s:12:"bx_analytics";s:6:"method";s:20:"get_options_identity";}', '', '', '', 1),
+(@iCategoryId, 'bx_analytics_link_members', '_bx_analytics_adm_stg_cpt_link_members', '_bx_analytics_adm_stg_inf_link_members', 'on', 'checkbox', '', '', '', '', 2),
+(@iCategoryId, 'bx_analytics_respect_dnt', '_bx_analytics_adm_stg_cpt_respect_dnt', '_bx_analytics_adm_stg_inf_respect_dnt', 'on', 'checkbox', '', '', '', '', 3),
+(@iCategoryId, 'bx_analytics_exclude_levels', '_bx_analytics_adm_stg_cpt_exclude_levels', '_bx_analytics_adm_stg_inf_exclude_levels', '', 'list', 'a:4:{s:6:"module";s:6:"system";s:6:"method";s:15:"get_memberships";s:6:"params";a:0:{}s:5:"class";s:18:"TemplAuditServices";}', '', '', '', 4),
+(@iCategoryId, 'bx_analytics_exclude_ips', '_bx_analytics_adm_stg_cpt_exclude_ips', '_bx_analytics_adm_stg_inf_exclude_ips', '', 'text', '', '', '', '', 5),
+(@iCategoryId, 'bx_analytics_exclude_paths', '_bx_analytics_adm_stg_cpt_exclude_paths', '_bx_analytics_adm_stg_inf_exclude_paths', '', 'text', '', '', '', '', 6),
+(@iCategoryId, 'bx_analytics_trusted_proxies', '_bx_analytics_adm_stg_cpt_trusted_proxies', '_bx_analytics_adm_stg_inf_trusted_proxies', '', 'text', '', '', '', '', 7),
+(@iCategoryId, 'bx_analytics_geo_source', '_bx_analytics_adm_stg_cpt_geo_source', '_bx_analytics_adm_stg_inf_geo_source', 'headers', 'select', 'a:2:{s:6:"module";s:12:"bx_analytics";s:6:"method";s:22:"get_options_geo_source";}', '', '', '', 8),
+(@iCategoryId, 'bx_analytics_retention_days', '_bx_analytics_adm_stg_cpt_retention_days', '_bx_analytics_adm_stg_inf_retention_days', '400', 'digit', '', '', '', '', 9);
+
+INSERT INTO `sys_options_categories` (`type_id`, `name`, `caption`, `hidden`, `order`) VALUES(@iTypeId, 'bx_analytics_health', '_bx_analytics_adm_stg_cpt_category_health', 0, 3);
+SET @iCategoryId = LAST_INSERT_ID();
+
+INSERT INTO `sys_options` (`category_id`, `name`, `caption`, `info`, `value`, `type`, `extra`, `check`, `check_params`, `check_error`, `order`) VALUES
+(@iCategoryId, 'bx_analytics_track_errors', '_bx_analytics_adm_stg_cpt_track_errors', '_bx_analytics_adm_stg_inf_track_errors', 'on', 'checkbox', '', '', '', '', 1),
+(@iCategoryId, 'bx_analytics_errors_ignore', '_bx_analytics_adm_stg_cpt_errors_ignore', '_bx_analytics_adm_stg_inf_errors_ignore', 'ResizeObserver loop*\nScript error.*\n*Non-Error promise rejection*', 'text', '', '', '', '', 2),
+(@iCategoryId, 'bx_analytics_track_vitals', '_bx_analytics_adm_stg_cpt_track_vitals', '_bx_analytics_adm_stg_inf_track_vitals', 'on', 'checkbox', '', '', '', '', 3),
+(@iCategoryId, 'bx_analytics_vitals_sample', '_bx_analytics_adm_stg_cpt_vitals_sample', '_bx_analytics_adm_stg_inf_vitals_sample', '100', 'digit', '', '', '', '', 4),
+(@iCategoryId, 'bx_analytics_vitals_retention_days', '_bx_analytics_adm_stg_cpt_vitals_retention_days', '_bx_analytics_adm_stg_inf_vitals_retention_days', '90', 'digit', '', '', '', '', 5);
+
+INSERT INTO `sys_options_categories` (`type_id`, `name`, `caption`, `hidden`, `order`) VALUES(@iTypeId,  'bx_analytics_general', '_bx_analytics_adm_stg_cpt_category_content', 0, 4);
 SET @iCategoryId = LAST_INSERT_ID();
 
 INSERT INTO `sys_options` (`category_id`, `name`, `caption`, `value`, `type`, `extra`, `check`, `check_params`, `check_error`, `order`) VALUES
@@ -16,25 +54,133 @@ INSERT INTO `sys_options` (`category_id`, `name`, `caption`, `value`, `type`, `e
 (@iCategoryId, 'bx_analytics_items_count', '_bx_analytics_adm_stg_cpt_items_count', '20', 'digit', '', '', '', '', 2),
 (@iCategoryId, 'bx_analytics_default_interval_day', '_bx_analytics_adm_stg_cpt_default_interval_day', '90', 'digit', '', '', '', '', 3);
 
+-- hidden: where the import of past payments stands; the transactions cursor is the last
+-- imported transaction id (-1 when done), the subscriptions cursor is "active:<pending id>",
+-- "deleted:<pending id>" or -1
+INSERT INTO `sys_options_categories` (`type_id`, `name`, `caption`, `hidden`, `order`) VALUES(@iTypeId, 'bx_analytics_system', '_bx_analytics_adm_stg_cpt_category_system', 1, 5);
+SET @iCategoryId = LAST_INSERT_ID();
+
+INSERT INTO `sys_options` (`category_id`, `name`, `caption`, `value`, `type`, `extra`, `check`, `check_params`, `check_error`, `order`) VALUES
+(@iCategoryId, 'bx_analytics_revenue_backfill', '', '0', 'digit', '', '', '', '', 1),
+(@iCategoryId, 'bx_analytics_revenue_backfill_sbs', '', '', 'text', '', '', '', '', 2);
+
 -- PAGES & BLOCKS
 INSERT INTO `sys_objects_page`(`object`, `title_system`, `title`, `module`, `layout_id`, `visible_for_levels`, `visible_for_levels_editable`, `uri`, `url`, `meta_description`, `meta_keywords`, `meta_robots`, `cache_lifetime`, `cache_editable`, `deletable`, `override_class_name`, `override_class_file`) VALUES
-('bx_analytics_page', '_bx_analytics_page_title_sys_page', '_bx_analytics_page_title_page', @sName, 5, 2147483647, 1, 'analytics', '', '', '', '', 0, 1, 0, 'BxAnalyticsPage', 'modules/boonex/analytics/classes/BxAnalyticsPage.php');
+('bx_analytics_page', '_bx_analytics_page_title_sys_page', '_bx_analytics_page_title_page', @sName, 12, 2147483647, 1, 'analytics', '', '', '', 'noindex', 0, 1, 0, 'BxAnalyticsPage', 'modules/boonex/analytics/classes/BxAnalyticsPage.php'),
+('bx_analytics_revenue', '_bx_analytics_page_title_sys_revenue', '_bx_analytics_page_title_revenue', @sName, 5, 2147483647, 1, 'analytics-revenue', '', '', '', 'noindex', 0, 1, 0, 'BxAnalyticsPage', 'modules/boonex/analytics/classes/BxAnalyticsPage.php');
 
 INSERT INTO `sys_pages_blocks` (`object`, `cell_id`, `module`, `title_system`, `title`, `designbox_id`, `visible_for_levels`, `type`, `content`, `deletable`, `copyable`, `active`, `order`) VALUES
-('bx_analytics_page', 1,  @sName, '_bx_analytics_page_block_title_system_canvas', '_bx_analytics_page_block_title_canvas', 11, 2147483647, 'service', 'a:2:{s:6:"module";s:12:"bx_analytics";s:6:"method";s:10:"get_canvas";}', 0, 1, 1, 0);
+('bx_analytics_page', 2, @sName, '_bx_analytics_page_block_title_system_filters', '_bx_analytics_page_block_title_filters', 11, 2147483647, 'service', 'a:2:{s:6:"module";s:12:"bx_analytics";s:6:"method";s:17:"get_block_filters";}', 0, 1, 1, 1),
+('bx_analytics_page', 2, @sName, '_bx_analytics_page_block_title_system_overview', '_bx_analytics_page_block_title_overview', 11, 2147483647, 'service', 'a:2:{s:6:"module";s:12:"bx_analytics";s:6:"method";s:18:"get_block_overview";}', 0, 1, 1, 2),
+('bx_analytics_page', 2, @sName, '_bx_analytics_page_block_title_system_top_pages', '_bx_analytics_page_block_title_top_pages', 11, 2147483647, 'service', 'a:3:{s:6:"module";s:12:"bx_analytics";s:6:"method";s:13:"get_block_top";s:6:"params";a:1:{i:0;s:5:"pages";}}', 0, 1, 1, 3),
+('bx_analytics_page', 2, @sName, '_bx_analytics_page_block_title_system_top_sources', '_bx_analytics_page_block_title_top_sources', 11, 2147483647, 'service', 'a:3:{s:6:"module";s:12:"bx_analytics";s:6:"method";s:13:"get_block_top";s:6:"params";a:1:{i:0;s:7:"sources";}}', 0, 1, 1, 4),
+('bx_analytics_page', 3, @sName, '_bx_analytics_page_block_title_system_top_locations', '_bx_analytics_page_block_title_top_locations', 11, 2147483647, 'service', 'a:3:{s:6:"module";s:12:"bx_analytics";s:6:"method";s:13:"get_block_top";s:6:"params";a:1:{i:0;s:9:"locations";}}', 0, 1, 1, 1),
+('bx_analytics_page', 3, @sName, '_bx_analytics_page_block_title_system_top_devices', '_bx_analytics_page_block_title_top_devices', 11, 2147483647, 'service', 'a:3:{s:6:"module";s:12:"bx_analytics";s:6:"method";s:13:"get_block_top";s:6:"params";a:1:{i:0;s:7:"devices";}}', 0, 1, 1, 2),
+('bx_analytics_page', 3, @sName, '_bx_analytics_page_block_title_system_top_platforms', '_bx_analytics_page_block_title_top_platforms', 11, 2147483647, 'service', 'a:3:{s:6:"module";s:12:"bx_analytics";s:6:"method";s:13:"get_block_top";s:6:"params";a:1:{i:0;s:9:"platforms";}}', 0, 1, 1, 3),
+('bx_analytics_page', 4, @sName, '_bx_analytics_page_block_title_system_canvas', '_bx_analytics_page_block_title_canvas', 11, 2147483647, 'service', 'a:2:{s:6:"module";s:12:"bx_analytics";s:6:"method";s:10:"get_canvas";}', 0, 1, 1, 1),
+('bx_analytics_revenue', 1, @sName, '_bx_analytics_page_block_title_system_filters', '_bx_analytics_page_block_title_filters', 11, 2147483647, 'service', 'a:2:{s:6:"module";s:12:"bx_analytics";s:6:"method";s:17:"get_block_filters";}', 0, 1, 1, 1),
+('bx_analytics_revenue', 1, @sName, '_bx_analytics_page_block_title_system_revenue', '_bx_analytics_page_block_title_revenue', 11, 2147483647, 'service', 'a:3:{s:6:"module";s:12:"bx_analytics";s:6:"method";s:16:"get_block_report";s:6:"params";a:1:{i:0;s:7:"revenue";}}', 0, 1, 1, 2),
+('bx_analytics_revenue', 1, @sName, '_bx_analytics_page_block_title_system_subscriptions', '_bx_analytics_page_block_title_subscriptions', 11, 2147483647, 'service', 'a:3:{s:6:"module";s:12:"bx_analytics";s:6:"method";s:16:"get_block_report";s:6:"params";a:1:{i:0;s:13:"subscriptions";}}', 0, 1, 1, 3),
+('bx_analytics_revenue', 1, @sName, '_bx_analytics_page_block_title_system_sales', '_bx_analytics_page_block_title_sales', 11, 2147483647, 'service', 'a:3:{s:6:"module";s:12:"bx_analytics";s:6:"method";s:16:"get_block_report";s:6:"params";a:1:{i:0;s:5:"sales";}}', 0, 1, 1, 4),
+('bx_analytics_revenue', 1, @sName, '_bx_analytics_page_block_title_system_credits', '_bx_analytics_page_block_title_credits', 11, 2147483647, 'service', 'a:3:{s:6:"module";s:12:"bx_analytics";s:6:"method";s:16:"get_block_report";s:6:"params";a:1:{i:0;s:7:"credits";}}', 0, 1, 1, 5);
+
+-- BLOCKS: every report for the page builder
+INSERT INTO `sys_pages_blocks` (`object`, `cell_id`, `module`, `title_system`, `title`, `designbox_id`, `visible_for_levels`, `type`, `content`, `deletable`, `copyable`, `active`, `order`) VALUES
+('', 0, @sName, '_bx_analytics_page_block_title_system_filters', '_bx_analytics_page_block_title_filters', 11, 2147483647, 'service', 'a:2:{s:6:"module";s:12:"bx_analytics";s:6:"method";s:17:"get_block_filters";}', 0, 1, 1, 1),
+('', 0, @sName, '_bx_analytics_page_block_title_system_overview', '_bx_analytics_page_block_title_overview', 11, 2147483647, 'service', 'a:2:{s:6:"module";s:12:"bx_analytics";s:6:"method";s:18:"get_block_overview";}', 0, 1, 1, 2),
+('', 0, @sName, '_bx_analytics_page_block_title_system_top_pages', '_bx_analytics_page_block_title_top_pages', 11, 2147483647, 'service', 'a:3:{s:6:"module";s:12:"bx_analytics";s:6:"method";s:13:"get_block_top";s:6:"params";a:1:{i:0;s:5:"pages";}}', 0, 1, 1, 3),
+('', 0, @sName, '_bx_analytics_page_block_title_system_top_sources', '_bx_analytics_page_block_title_top_sources', 11, 2147483647, 'service', 'a:3:{s:6:"module";s:12:"bx_analytics";s:6:"method";s:13:"get_block_top";s:6:"params";a:1:{i:0;s:7:"sources";}}', 0, 1, 1, 4),
+('', 0, @sName, '_bx_analytics_page_block_title_system_top_locations', '_bx_analytics_page_block_title_top_locations', 11, 2147483647, 'service', 'a:3:{s:6:"module";s:12:"bx_analytics";s:6:"method";s:13:"get_block_top";s:6:"params";a:1:{i:0;s:9:"locations";}}', 0, 1, 1, 5),
+('', 0, @sName, '_bx_analytics_page_block_title_system_top_devices', '_bx_analytics_page_block_title_top_devices', 11, 2147483647, 'service', 'a:3:{s:6:"module";s:12:"bx_analytics";s:6:"method";s:13:"get_block_top";s:6:"params";a:1:{i:0;s:7:"devices";}}', 0, 1, 1, 6),
+('', 0, @sName, '_bx_analytics_page_block_title_system_top_platforms', '_bx_analytics_page_block_title_top_platforms', 11, 2147483647, 'service', 'a:3:{s:6:"module";s:12:"bx_analytics";s:6:"method";s:13:"get_block_top";s:6:"params";a:1:{i:0;s:9:"platforms";}}', 0, 1, 1, 7),
+('', 0, @sName, '_bx_analytics_page_block_title_system_table_pages', '_bx_analytics_page_block_title_table_pages', 11, 2147483647, 'service', 'a:3:{s:6:"module";s:12:"bx_analytics";s:6:"method";s:15:"get_block_table";s:6:"params";a:1:{i:0;s:5:"pages";}}', 0, 1, 1, 8),
+('', 0, @sName, '_bx_analytics_page_block_title_system_table_sources', '_bx_analytics_page_block_title_table_sources', 11, 2147483647, 'service', 'a:3:{s:6:"module";s:12:"bx_analytics";s:6:"method";s:15:"get_block_table";s:6:"params";a:1:{i:0;s:7:"sources";}}', 0, 1, 1, 9),
+('', 0, @sName, '_bx_analytics_page_block_title_system_realtime', '_bx_analytics_page_block_title_realtime', 11, 2147483647, 'service', 'a:3:{s:6:"module";s:12:"bx_analytics";s:6:"method";s:16:"get_block_report";s:6:"params";a:1:{i:0;s:8:"realtime";}}', 0, 1, 1, 10),
+('', 0, @sName, '_bx_analytics_page_block_title_system_map', '_bx_analytics_page_block_title_map', 11, 2147483647, 'service', 'a:3:{s:6:"module";s:12:"bx_analytics";s:6:"method";s:16:"get_block_report";s:6:"params";a:1:{i:0;s:3:"map";}}', 0, 1, 1, 11),
+('', 0, @sName, '_bx_analytics_page_block_title_system_bots', '_bx_analytics_page_block_title_bots', 11, 2147483647, 'service', 'a:3:{s:6:"module";s:12:"bx_analytics";s:6:"method";s:16:"get_block_report";s:6:"params";a:1:{i:0;s:4:"bots";}}', 0, 1, 1, 12),
+('', 0, @sName, '_bx_analytics_page_block_title_system_events', '_bx_analytics_page_block_title_events', 11, 2147483647, 'service', 'a:3:{s:6:"module";s:12:"bx_analytics";s:6:"method";s:16:"get_block_report";s:6:"params";a:1:{i:0;s:6:"events";}}', 0, 1, 1, 13),
+('', 0, @sName, '_bx_analytics_page_block_title_system_sessions', '_bx_analytics_page_block_title_sessions', 11, 2147483647, 'service', 'a:3:{s:6:"module";s:12:"bx_analytics";s:6:"method";s:16:"get_block_report";s:6:"params";a:1:{i:0;s:8:"sessions";}}', 0, 1, 1, 14),
+('', 0, @sName, '_bx_analytics_page_block_title_system_users', '_bx_analytics_page_block_title_users', 11, 2147483647, 'service', 'a:3:{s:6:"module";s:12:"bx_analytics";s:6:"method";s:16:"get_block_report";s:6:"params";a:1:{i:0;s:5:"users";}}', 0, 1, 1, 15),
+('', 0, @sName, '_bx_analytics_page_block_title_system_journeys', '_bx_analytics_page_block_title_journeys', 11, 2147483647, 'service', 'a:3:{s:6:"module";s:12:"bx_analytics";s:6:"method";s:16:"get_block_report";s:6:"params";a:1:{i:0;s:8:"journeys";}}', 0, 1, 1, 16),
+('', 0, @sName, '_bx_analytics_page_block_title_system_retention', '_bx_analytics_page_block_title_retention', 11, 2147483647, 'service', 'a:3:{s:6:"module";s:12:"bx_analytics";s:6:"method";s:16:"get_block_report";s:6:"params";a:1:{i:0;s:9:"retention";}}', 0, 1, 1, 17),
+('', 0, @sName, '_bx_analytics_page_block_title_system_goals', '_bx_analytics_page_block_title_goals', 11, 2147483647, 'service', 'a:3:{s:6:"module";s:12:"bx_analytics";s:6:"method";s:16:"get_block_report";s:6:"params";a:1:{i:0;s:5:"goals";}}', 0, 1, 1, 18),
+('', 0, @sName, '_bx_analytics_page_block_title_system_funnels', '_bx_analytics_page_block_title_funnels', 11, 2147483647, 'service', 'a:3:{s:6:"module";s:12:"bx_analytics";s:6:"method";s:16:"get_block_report";s:6:"params";a:1:{i:0;s:7:"funnels";}}', 0, 1, 1, 19),
+('', 0, @sName, '_bx_analytics_page_block_title_system_revenue', '_bx_analytics_page_block_title_revenue', 11, 2147483647, 'service', 'a:3:{s:6:"module";s:12:"bx_analytics";s:6:"method";s:16:"get_block_report";s:6:"params";a:1:{i:0;s:7:"revenue";}}', 0, 1, 1, 20),
+('', 0, @sName, '_bx_analytics_page_block_title_system_subscriptions', '_bx_analytics_page_block_title_subscriptions', 11, 2147483647, 'service', 'a:3:{s:6:"module";s:12:"bx_analytics";s:6:"method";s:16:"get_block_report";s:6:"params";a:1:{i:0;s:13:"subscriptions";}}', 0, 1, 1, 21),
+('', 0, @sName, '_bx_analytics_page_block_title_system_sales', '_bx_analytics_page_block_title_sales', 11, 2147483647, 'service', 'a:3:{s:6:"module";s:12:"bx_analytics";s:6:"method";s:16:"get_block_report";s:6:"params";a:1:{i:0;s:5:"sales";}}', 0, 1, 1, 22),
+('', 0, @sName, '_bx_analytics_page_block_title_system_credits', '_bx_analytics_page_block_title_credits', 11, 2147483647, 'service', 'a:3:{s:6:"module";s:12:"bx_analytics";s:6:"method";s:16:"get_block_report";s:6:"params";a:1:{i:0;s:7:"credits";}}', 0, 1, 1, 23),
+('', 0, @sName, '_bx_analytics_page_block_title_system_errors', '_bx_analytics_page_block_title_errors', 11, 2147483647, 'service', 'a:3:{s:6:"module";s:12:"bx_analytics";s:6:"method";s:16:"get_block_report";s:6:"params";a:1:{i:0;s:6:"errors";}}', 0, 1, 1, 24),
+('', 0, @sName, '_bx_analytics_page_block_title_system_performance', '_bx_analytics_page_block_title_performance', 11, 2147483647, 'service', 'a:3:{s:6:"module";s:12:"bx_analytics";s:6:"method";s:16:"get_block_report";s:6:"params";a:1:{i:0;s:11:"performance";}}', 0, 1, 1, 25),
+('', 0, @sName, '_bx_analytics_page_block_title_system_query', '_bx_analytics_page_block_title_query', 11, 2147483647, 'service', 'a:3:{s:6:"module";s:12:"bx_analytics";s:6:"method";s:16:"get_block_report";s:6:"params";a:1:{i:0;s:5:"query";}}', 0, 1, 1, 26),
+('', 0, @sName, '_bx_analytics_page_block_title_system_canvas', '_bx_analytics_page_block_title_canvas', 11, 2147483647, 'service', 'a:2:{s:6:"module";s:12:"bx_analytics";s:6:"method";s:10:"get_canvas";}', 0, 1, 1, 27);
+
+-- STUDIO DASHBOARD: traffic next to the core blocks
+SET @iStdDashboardOrder = (SELECT IFNULL(MAX(`order`), 0) + 1 FROM `sys_pages_blocks` WHERE `object` = 'sys_std_dashboard' AND `cell_id` = 1);
+INSERT INTO `sys_pages_blocks` (`object`, `cell_id`, `module`, `title_system`, `title`, `icon`, `designbox_id`, `class`, `tabs`, `async`, `visible_for_levels`, `type`, `content`, `deletable`, `copyable`, `active`, `order`) VALUES
+('sys_std_dashboard', 1, @sName, '', '_bx_analytics_page_block_title_std_dash_traffic', 'chart-line', 11, 'bx-dbd-block-medium', 0, 0, 2147483647, 'service', 'a:2:{s:6:"module";s:12:"bx_analytics";s:6:"method";s:28:"get_studio_dashboard_traffic";}', 0, 0, 1, @iStdDashboardOrder);
 
 -- MENU: account dashboard
 SET @iMoAccountDashboard = (SELECT IFNULL(MAX(`order`), 0) FROM `sys_menu_items` WHERE `set_name`='sys_account_dashboard' LIMIT 1);
 INSERT INTO `sys_menu_items` (`set_name`, `module`, `name`, `title_system`, `title`, `link`, `onclick`, `target`, `icon`, `addon`, `submenu_object`, `visible_for_levels`, `visibility_custom`, `active`, `copyable`, `editable`, `order`) VALUES
-('sys_account_dashboard', @sName, 'dashboard-analytics', '_bx_analytics_item_title_system_analytics', '_bx_analytics_item_title_analytics', 'page.php?i=analytics', '', '', 'certificate col-red', '', '', 2147483647, 'a:2:{s:6:"module";s:12:"bx_analytics";s:6:"method";s:12:"is_avaliable";}', 1, 0, 1, @iMoAccountDashboard + 1);
+('sys_account_dashboard', @sName, 'dashboard-analytics', '_bx_analytics_item_title_system_analytics', '_bx_analytics_item_title_analytics', 'page.php?i=analytics', '', '', 'chart-line', '', '', 2147483647, 'a:2:{s:6:"module";s:12:"bx_analytics";s:6:"method";s:12:"is_avaliable";}', 1, 0, 1, @iMoAccountDashboard + 1),
+('sys_account_dashboard', @sName, 'analytics-revenue', '_bx_analytics_item_title_system_revenue', '_bx_analytics_item_title_revenue', 'page.php?i=analytics-revenue', '', '', 'circle-dollar-sign', '', '', 2147483647, 'a:2:{s:6:"module";s:12:"bx_analytics";s:6:"method";s:20:"is_revenue_avaliable";}', 1, 0, 1, @iMoAccountDashboard + 2);
 
 -- ACL
 INSERT INTO `sys_acl_actions` (`Module`, `Name`, `AdditionalParamName`, `Title`, `Desc`, `Countable`, `DisabledForLevels`) VALUES
 (@sName, 'use analytics', NULL, '_bx_analytics_acl_action_use_analytics', '', 1, 3);
 SET @iIdActionUseAnalytics = LAST_INSERT_ID();
 
+INSERT INTO `sys_acl_actions` (`Module`, `Name`, `AdditionalParamName`, `Title`, `Desc`, `Countable`, `DisabledForLevels`) VALUES
+(@sName, 'view analytics people', NULL, '_bx_analytics_acl_action_view_people', '', 0, 3);
+SET @iIdActionViewPeople = LAST_INSERT_ID();
+
+INSERT INTO `sys_acl_actions` (`Module`, `Name`, `AdditionalParamName`, `Title`, `Desc`, `Countable`, `DisabledForLevels`) VALUES
+(@sName, 'manage analytics', NULL, '_bx_analytics_acl_action_manage', '', 0, 3);
+SET @iIdActionManage = LAST_INSERT_ID();
+
+INSERT INTO `sys_acl_actions` (`Module`, `Name`, `AdditionalParamName`, `Title`, `Desc`, `Countable`, `DisabledForLevels`) VALUES
+(@sName, 'view analytics revenue', NULL, '_bx_analytics_acl_action_view_revenue', '', 0, 3);
+SET @iIdActionViewRevenue = LAST_INSERT_ID();
+
 SET @iModerator = 7;
 SET @iAdministrator = 8;
 INSERT INTO `sys_acl_matrix` (`IDLevel`, `IDAction`) VALUES
 (@iModerator, @iIdActionUseAnalytics),
-(@iAdministrator, @iIdActionUseAnalytics);
+(@iAdministrator, @iIdActionUseAnalytics),
+(@iAdministrator, @iIdActionViewPeople),
+(@iAdministrator, @iIdActionManage),
+(@iAdministrator, @iIdActionViewRevenue);
+
+-- INJECTIONS: the tracker on every page
+INSERT INTO `sys_injections` (`name`, `page_index`, `key`, `type`, `data`, `replace`, `active`) VALUES
+('bx_analytics_tracker', 0, 'injection_footer', 'service', 'a:2:{s:6:"module";s:12:"bx_analytics";s:6:"method";s:16:"get_tracker_code";}', 0, 1);
+
+-- ALERTS: forget deleted profiles, record site actions (new content is subscribed per
+-- module by the sync_actions service), and keep the revenue ledger from the payment alerts
+-- every payment module fires on the `system` unit
+INSERT INTO `sys_alerts_handlers` (`name`, `class`, `file`, `service_call`) VALUES
+(@sName, 'BxAnalyticsAlertsResponse', 'modules/boonex/analytics/classes/BxAnalyticsAlertsResponse.php', '');
+SET @iHandler := LAST_INSERT_ID();
+
+INSERT INTO `sys_alerts` (`unit`, `action`, `handler_id`) VALUES
+('profile', 'delete', @iHandler),
+('system', 'enable', @iHandler),
+('system', 'disable', @iHandler),
+('account', 'add', @iHandler),
+('account', 'login', @iHandler),
+('profile', 'add', @iHandler),
+('system', 'connection_added', @iHandler),
+('comment', 'added', @iHandler),
+('vote', 'do', @iHandler),
+('system', 'register_payment', @iHandler),
+('system', 'refund_payment', @iHandler),
+('system', 'create_subscription', @iHandler),
+('system', 'prolong_subscription', @iHandler),
+('system', 'overdue_subscription', @iHandler),
+('system', 'cancel_subscription', @iHandler),
+('system', 'cancel_order', @iHandler),
+('system', 'finalize_checkout', @iHandler);
+
+-- CRON: daily salts and data retention
+INSERT INTO `sys_cron_jobs` (`name`, `time`, `class`, `file`, `service_call`) VALUES
+('bx_analytics_pruning', '15 * * * *', '', '', 'a:2:{s:6:"module";s:12:"bx_analytics";s:6:"method";s:7:"pruning";}');

+ 303 - 0
modules/boonex/analytics/install/sql/install.sql

@@ -1,5 +1,308 @@
 SET @sName = 'bx_analytics';
 
+-- TABLES
+
+-- One row per visit: the visitor, the client it came from and how it arrived
+CREATE TABLE IF NOT EXISTS `bx_analytics_sessions` (
+  `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
+  `visitor` binary(16) NOT NULL,
+  `profile_id` int(11) unsigned NOT NULL DEFAULT '0',
+  `platform` varchar(16) NOT NULL DEFAULT '',
+  `app` varchar(64) NOT NULL DEFAULT '',
+  `app_version` varchar(32) NOT NULL DEFAULT '',
+  `started` int(11) unsigned NOT NULL DEFAULT '0',
+  `last_seen` int(11) unsigned NOT NULL DEFAULT '0',
+  `duration` int(11) unsigned NOT NULL DEFAULT '0',
+  `pageviews` smallint(5) unsigned NOT NULL DEFAULT '0',
+  `events` smallint(5) unsigned NOT NULL DEFAULT '0',
+  `errors` smallint(5) unsigned NOT NULL DEFAULT '0',
+  `bounce` tinyint(4) unsigned NOT NULL DEFAULT '1',
+  `hostname` varchar(128) NOT NULL DEFAULT '',
+  `entry_path` varchar(255) NOT NULL DEFAULT '',
+  `exit_path` varchar(255) NOT NULL DEFAULT '',
+  `referrer` varchar(255) NOT NULL DEFAULT '',
+  `referrer_host` varchar(128) NOT NULL DEFAULT '',
+  `channel` varchar(16) NOT NULL DEFAULT '',
+  `utm_source` varchar(128) NOT NULL DEFAULT '',
+  `utm_medium` varchar(128) NOT NULL DEFAULT '',
+  `utm_campaign` varchar(128) NOT NULL DEFAULT '',
+  `utm_term` varchar(128) NOT NULL DEFAULT '',
+  `utm_content` varchar(128) NOT NULL DEFAULT '',
+  `country` char(2) NOT NULL DEFAULT '',
+  `region` varchar(64) NOT NULL DEFAULT '',
+  `city` varchar(64) NOT NULL DEFAULT '',
+  `lat` decimal(4,1) DEFAULT NULL,
+  `lng` decimal(4,1) DEFAULT NULL,
+  `language` varchar(16) NOT NULL DEFAULT '',
+  `browser` varchar(32) NOT NULL DEFAULT '',
+  `browser_version` varchar(16) NOT NULL DEFAULT '',
+  `os` varchar(32) NOT NULL DEFAULT '',
+  `os_version` varchar(16) NOT NULL DEFAULT '',
+  `device` varchar(16) NOT NULL DEFAULT '',
+  `screen` varchar(16) NOT NULL DEFAULT '',
+  PRIMARY KEY (`id`),
+  -- the visitor's open visit; read in this order, retention cohorts need no sort
+  KEY `visitor` (`visitor`, `last_seen`, `started`, `platform`, `profile_id`),
+  -- covers the key figures and their chart (no row is read for them)
+  KEY `started` (`started`, `platform`, `visitor`, `profile_id`, `pageviews`, `bounce`, `duration`),
+  KEY `last_seen` (`last_seen`),
+  KEY `platform_seen` (`platform`, `last_seen`),
+  -- a member's visits; with the last hit and the platform it pages the members list alone
+  KEY `profile_id` (`profile_id`, `started`, `last_seen`, `platform`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+-- One row per pageview or event; visitor, profile and platform are copied from the session
+CREATE TABLE IF NOT EXISTS `bx_analytics_events` (
+  `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
+  `session_id` bigint(20) unsigned NOT NULL,
+  `visitor` binary(16) NOT NULL,
+  `profile_id` int(11) unsigned NOT NULL DEFAULT '0',
+  `platform` varchar(16) NOT NULL DEFAULT '',
+  `ts` int(11) unsigned NOT NULL DEFAULT '0',
+  `type` varchar(16) NOT NULL DEFAULT 'pageview',
+  `name` varchar(64) NOT NULL DEFAULT '',
+  `hostname` varchar(128) NOT NULL DEFAULT '',
+  `path` varchar(255) NOT NULL DEFAULT '',
+  `title` varchar(255) NOT NULL DEFAULT '',
+  `module` varchar(32) NOT NULL DEFAULT '',
+  `object_id` int(11) unsigned NOT NULL DEFAULT '0',
+  `context_id` int(11) unsigned NOT NULL DEFAULT '0',
+  `value` double NOT NULL DEFAULT '0',
+  `engaged` int(11) unsigned NOT NULL DEFAULT '0',
+  `props` text NOT NULL,
+  PRIMARY KEY (`id`),
+  KEY `ts` (`ts`, `type`, `platform`),
+  -- covers pages and the events list by type and period
+  KEY `type_ts` (`type`, `ts`, `platform`, `path`, `name`, `module`, `visitor`, `session_id`, `engaged`),
+  KEY `platform` (`platform`, `ts`),
+  KEY `session_id` (`session_id`, `ts`),
+  -- covers one page (a filter, a goal, a funnel step, the Query chart) over a period, on a platform too
+  KEY `path` (`type`, `path`, `ts`, `platform`, `session_id`, `visitor`, `engaged`),
+  -- the same for page titles: the titles tab, a title filter
+  KEY `title` (`type`, `title`, `ts`, `platform`, `session_id`, `visitor`, `engaged`),
+  KEY `name` (`type`, `name`, `ts`),
+  -- a site action about something: forgetting a deleted profile finds its creation by it
+  KEY `object` (`type`, `name`, `object_id`),
+  -- a member's events: forgetting a deleted profile, the member's actions in a period
+  KEY `profile_id` (`profile_id`, `ts`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+-- A random salt per day for cookieless visitor ids; cron deletes old ones
+CREATE TABLE IF NOT EXISTS `bx_analytics_salts` (
+  `day` date NOT NULL,
+  `salt` binary(32) NOT NULL,
+  PRIMARY KEY (`day`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+-- Rate limits of the public collector: hits per address and minute, new visits per address
+-- and hour. The key is a keyed hash of the kind and the address with the day's salt, never
+-- the address; `bucket` is the minute's or hour's start. Cron deletes buckets over 2 hours old
+CREATE TABLE IF NOT EXISTS `bx_analytics_limits` (
+  `key` binary(8) NOT NULL,
+  `bucket` int(11) unsigned NOT NULL,
+  `hits` int(11) unsigned NOT NULL DEFAULT '0',
+  PRIMARY KEY (`key`, `bucket`),
+  KEY `bucket` (`bucket`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+
+-- Bots and agents per day: crawlers, AI fetchers, link previews and scripts don't run the
+-- tracker, so they are counted on the server and kept as daily totals, not as visits
+CREATE TABLE IF NOT EXISTS `bx_analytics_bots` (
+  `day` date NOT NULL,
+  `bot` varchar(64) NOT NULL,
+  `category` varchar(16) NOT NULL DEFAULT '',
+  `platform` varchar(16) NOT NULL DEFAULT '',
+  `hits` int(11) unsigned NOT NULL DEFAULT '0',
+  `last_seen` int(11) unsigned NOT NULL DEFAULT '0',
+  PRIMARY KEY (`day`, `bot`, `platform`),
+  KEY `category` (`category`, `day`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+-- Pages bots asked for, per day and bot category
+CREATE TABLE IF NOT EXISTS `bx_analytics_bot_pages` (
+  `day` date NOT NULL,
+  `category` varchar(16) NOT NULL DEFAULT '',
+  `path_hash` binary(8) NOT NULL,
+  `path` varchar(255) NOT NULL DEFAULT '',
+  `hits` int(11) unsigned NOT NULL DEFAULT '0',
+  PRIMARY KEY (`day`, `category`, `path_hash`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+-- Goals: a page (path, * for any characters) or an event (name, or name@module) that
+-- counts as a conversion
+CREATE TABLE IF NOT EXISTS `bx_analytics_goals` (
+  `id` int(11) unsigned NOT NULL AUTO_INCREMENT,
+  `name` varchar(128) NOT NULL DEFAULT '',
+  `type` varchar(16) NOT NULL DEFAULT 'page',
+  `pattern` varchar(255) NOT NULL DEFAULT '',
+  `added` int(11) unsigned NOT NULL DEFAULT '0',
+  `order` int(11) NOT NULL DEFAULT '0',
+  PRIMARY KEY (`id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+-- Funnels: steps as a JSON list of {type, pattern}, matched in order within a visit
+CREATE TABLE IF NOT EXISTS `bx_analytics_funnels` (
+  `id` int(11) unsigned NOT NULL AUTO_INCREMENT,
+  `name` varchar(128) NOT NULL DEFAULT '',
+  `steps` text NOT NULL,
+  `added` int(11) unsigned NOT NULL DEFAULT '0',
+  `order` int(11) NOT NULL DEFAULT '0',
+  PRIMARY KEY (`id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+-- Revenue ledger: one row per money movement the payment module reports (a sale, a renewal,
+-- a refund or a reversal of a processed order), with the amount in the site's default
+-- currency and the platform and traffic source of the buyer. Pruning never touches it.
+-- A subscription's trial is registered at the full price although nothing was charged: those
+-- rows have the kind `trial` and no revenue report reads them. A transaction has one charge
+-- row (sale, renewal or trial) and at most one negation (refund or reversal), hence the
+-- unique key on the payment module, transaction and kind.
+CREATE TABLE IF NOT EXISTS `bx_analytics_revenue` (
+  `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
+  `ts` int(11) unsigned NOT NULL DEFAULT '0',
+  `kind` varchar(16) NOT NULL DEFAULT '',
+  `cash` tinyint(4) unsigned NOT NULL DEFAULT '1',
+  `payment` varchar(32) NOT NULL DEFAULT '',
+  `pending_id` int(11) unsigned NOT NULL DEFAULT '0',
+  `transaction_id` int(11) unsigned NOT NULL DEFAULT '0',
+  `order` varchar(32) NOT NULL DEFAULT '',
+  `module_id` int(11) unsigned NOT NULL DEFAULT '0',
+  `module` varchar(32) NOT NULL DEFAULT '',
+  `item_id` int(11) unsigned NOT NULL DEFAULT '0',
+  `item_count` int(11) unsigned NOT NULL DEFAULT '0',
+  `seller_id` int(11) unsigned NOT NULL DEFAULT '0',
+  `author_id` int(11) unsigned NOT NULL DEFAULT '0',
+  `client_id` int(11) unsigned NOT NULL DEFAULT '0',
+  `provider` varchar(32) NOT NULL DEFAULT '',
+  `type` varchar(16) NOT NULL DEFAULT '',
+  `amount` decimal(14,4) NOT NULL DEFAULT '0',
+  `currency` varchar(4) NOT NULL DEFAULT '',
+  `rate` double NOT NULL DEFAULT '1',
+  `amount_default` decimal(14,4) DEFAULT NULL,
+  `platform` varchar(16) NOT NULL DEFAULT '',
+  `session_id` bigint(20) unsigned NOT NULL DEFAULT '0',
+  `first_channel` varchar(16) NOT NULL DEFAULT '',
+  `first_referrer_host` varchar(128) NOT NULL DEFAULT '',
+  `first_utm_campaign` varchar(128) NOT NULL DEFAULT '',
+  `last_channel` varchar(16) NOT NULL DEFAULT '',
+  `last_referrer_host` varchar(128) NOT NULL DEFAULT '',
+  `last_utm_campaign` varchar(128) NOT NULL DEFAULT '',
+  PRIMARY KEY (`id`),
+  UNIQUE KEY `movement` (`payment`, `transaction_id`, `kind`),
+  KEY `ts` (`cash`, `ts`, `platform`),
+  KEY `pending` (`payment`, `pending_id`),
+  KEY `module` (`module_id`, `ts`),
+  KEY `seller` (`seller_id`, `ts`),
+  KEY `client` (`client_id`, `ts`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+-- Subscriptions as the ledger sees them: plan, price per period and per month (MRR) in the
+-- default currency, when they started, were first and last paid, and were cancelled
+CREATE TABLE IF NOT EXISTS `bx_analytics_subscriptions` (
+  `payment` varchar(32) NOT NULL DEFAULT '',
+  `pending_id` int(11) unsigned NOT NULL DEFAULT '0',
+  `subscription_id` varchar(32) NOT NULL DEFAULT '',
+  `client_id` int(11) unsigned NOT NULL DEFAULT '0',
+  `seller_id` int(11) unsigned NOT NULL DEFAULT '0',
+  `module_id` int(11) unsigned NOT NULL DEFAULT '0',
+  `module` varchar(32) NOT NULL DEFAULT '',
+  `item_id` int(11) unsigned NOT NULL DEFAULT '0',
+  `provider` varchar(32) NOT NULL DEFAULT '',
+  `period` int(11) unsigned NOT NULL DEFAULT '1',
+  `period_unit` varchar(16) NOT NULL DEFAULT '',
+  `trial` int(11) unsigned NOT NULL DEFAULT '0',
+  `amount_default` decimal(14,4) DEFAULT NULL,
+  `mrr_default` decimal(14,4) DEFAULT NULL,
+  `status` varchar(16) NOT NULL DEFAULT '',
+  `started` int(11) unsigned NOT NULL DEFAULT '0',
+  `first_paid` int(11) unsigned NOT NULL DEFAULT '0',
+  `last_paid` int(11) unsigned NOT NULL DEFAULT '0',
+  `paid_count` int(11) unsigned NOT NULL DEFAULT '0',
+  `canceled` int(11) unsigned NOT NULL DEFAULT '0',
+  `reason` varchar(255) NOT NULL DEFAULT '',
+  `platform` varchar(16) NOT NULL DEFAULT '',
+  PRIMARY KEY (`payment`, `pending_id`),
+  KEY `paid` (`first_paid`, `canceled`),
+  KEY `started` (`started`),
+  KEY `canceled` (`canceled`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+-- Client errors grouped by fingerprint (kind, normalized message, top frame, HTTP status),
+-- with a sample stack. `created` is server time, for the limit of new groups an hour;
+-- `first_seen` and `last_seen` are when the errors happened.
+CREATE TABLE IF NOT EXISTS `bx_analytics_errors` (
+  `id` int(11) unsigned NOT NULL AUTO_INCREMENT,
+  `fingerprint` binary(16) NOT NULL,
+  `kind` varchar(16) NOT NULL DEFAULT 'js',
+  `message` varchar(255) NOT NULL DEFAULT '',
+  `source` varchar(255) NOT NULL DEFAULT '',
+  `line` int(11) unsigned NOT NULL DEFAULT '0',
+  `col` int(11) unsigned NOT NULL DEFAULT '0',
+  `status_code` smallint(5) unsigned NOT NULL DEFAULT '0',
+  `stack` text NOT NULL,
+  `stack_ts` int(11) unsigned NOT NULL DEFAULT '0',
+  `first_seen` int(11) unsigned NOT NULL DEFAULT '0',
+  `last_seen` int(11) unsigned NOT NULL DEFAULT '0',
+  `occurrences` int(11) unsigned NOT NULL DEFAULT '0',
+  `fatal` tinyint(4) unsigned NOT NULL DEFAULT '0',
+  `status` varchar(16) NOT NULL DEFAULT 'open',
+  `status_changed` int(11) unsigned NOT NULL DEFAULT '0',
+  `created` int(11) unsigned NOT NULL DEFAULT '0',
+  PRIMARY KEY (`id`),
+  UNIQUE KEY `fingerprint` (`fingerprint`),
+  KEY `created` (`created`),
+  KEY `last_seen` (`last_seen`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+-- One row per error report from a client (repeats on one page counted in `repeats`). The
+-- client's columns are copied, so an error outside a visit (`session_id` 0) still has them.
+CREATE TABLE IF NOT EXISTS `bx_analytics_error_hits` (
+  `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
+  `error_id` int(11) unsigned NOT NULL,
+  `ts` int(11) unsigned NOT NULL DEFAULT '0',
+  `session_id` bigint(20) unsigned NOT NULL DEFAULT '0',
+  `visitor` binary(16) NOT NULL,
+  `profile_id` int(11) unsigned NOT NULL DEFAULT '0',
+  `platform` varchar(16) NOT NULL DEFAULT '',
+  `app_version` varchar(32) NOT NULL DEFAULT '',
+  `browser` varchar(32) NOT NULL DEFAULT '',
+  `os` varchar(32) NOT NULL DEFAULT '',
+  `device` varchar(16) NOT NULL DEFAULT '',
+  `country` char(2) NOT NULL DEFAULT '',
+  `path` varchar(255) NOT NULL DEFAULT '',
+  `route` varchar(128) NOT NULL DEFAULT '',
+  `repeats` smallint(5) unsigned NOT NULL DEFAULT '1',
+  `fatal` tinyint(4) unsigned NOT NULL DEFAULT '0',
+  PRIMARY KEY (`id`),
+  KEY `ts` (`ts`, `platform`),
+  KEY `error_id` (`error_id`, `ts`),
+  KEY `profile_id` (`profile_id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+-- Web vitals and app timings: one row per sampled page view or screen, NULL for what wasn't
+-- measured; milliseconds, CLS in thousandths. No visitor or member is stored.
+CREATE TABLE IF NOT EXISTS `bx_analytics_vitals` (
+  `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
+  `ts` int(11) unsigned NOT NULL DEFAULT '0',
+  `session_id` bigint(20) unsigned NOT NULL DEFAULT '0',
+  `platform` varchar(16) NOT NULL DEFAULT '',
+  `device` varchar(16) NOT NULL DEFAULT '',
+  `path` varchar(255) NOT NULL DEFAULT '',
+  `route` varchar(128) NOT NULL DEFAULT '',
+  `nav` varchar(16) NOT NULL DEFAULT '',
+  `ttfb` mediumint(8) unsigned DEFAULT NULL,
+  `fcp` mediumint(8) unsigned DEFAULT NULL,
+  `lcp` mediumint(8) unsigned DEFAULT NULL,
+  `inp` mediumint(8) unsigned DEFAULT NULL,
+  `cls` smallint(5) unsigned DEFAULT NULL,
+  `screen_load` mediumint(8) unsigned DEFAULT NULL,
+  `app_start` mediumint(8) unsigned DEFAULT NULL,
+  PRIMARY KEY (`id`),
+  KEY `ts` (`ts`, `platform`),
+  KEY `route` (`route`, `ts`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
 -- STUDIO: page & widget
 INSERT INTO `sys_std_pages`(`index`, `name`, `header`, `caption`, `icon`) VALUES
 (3, @sName, '_bx_analytics', '_bx_analytics', 'bx_analytics@modules/boonex/analytics/|std-icon.svg');

+ 3 - 0
modules/boonex/analytics/install/sql/uninstall.sql

@@ -1,5 +1,8 @@
 SET @sName = 'bx_analytics';
 
+-- TABLES
+DROP TABLE IF EXISTS `bx_analytics_sessions`, `bx_analytics_events`, `bx_analytics_salts`, `bx_analytics_limits`, `bx_analytics_bots`, `bx_analytics_bot_pages`, `bx_analytics_goals`, `bx_analytics_funnels`, `bx_analytics_revenue`, `bx_analytics_subscriptions`, `bx_analytics_errors`, `bx_analytics_error_hits`, `bx_analytics_vitals`;
+
 -- STUDIO WIDGET
 DELETE FROM `tp`, `tw`, `twb`, `tpw` 
 USING `sys_std_pages` AS `tp` LEFT JOIN `sys_std_widgets` AS `tw` ON `tp`.`id` = `tw`.`page_id` LEFT JOIN `sys_std_widgets_bookmarks` AS `twb` ON `tw`.`id` = `twb`.`widget_id` LEFT JOIN `sys_std_pages_widgets` AS `tpw` ON `tw`.`id` = `tpw`.`widget_id`

+ 770 - 0
modules/boonex/analytics/js/dashboard.js

@@ -0,0 +1,770 @@
+/**
+ * Copyright (c) UNA, Inc - https://una.io
+ * MIT License - https://opensource.org/licenses/MIT
+ *
+ * @defgroup    Analytics Analytics
+ * @ingroup     UnaModules
+ *
+ * @{
+ */
+
+/**
+ * Analytics blocks on a page share one set of filters kept in the page URL: range, tz,
+ * platform, f[dimension] and the chart metric. Changing a filter reloads every block
+ * from the server, which renders it from the same templates as the first page load.
+ * A block's own state (its tab, its page) is kept in the URL too, as tab_<name> and
+ * page_<name>, so a reload or a shared link shows the same thing.
+ */
+function BxAnalyticsDashboard(oOptions) {
+    this._sActionUrl = oOptions.sActionUrl;
+    this._aStrings = oOptions.aStrings || {};
+
+    var $this = this;
+    if (document.readyState === 'loading')
+        document.addEventListener('DOMContentLoaded', function () { $this.init(); });
+    else
+        this.init();
+}
+
+BxAnalyticsDashboard.prototype.init = function () {
+    var $this = this;
+
+    document.addEventListener('change', function (oEvent) {
+        var oSelect = oEvent.target.closest('[data-bx-analytics-param]');
+        if (oSelect)
+            $this.setParam(oSelect.getAttribute('data-bx-analytics-param'), oSelect.value);
+    });
+
+    document.addEventListener('click', function (oEvent) {
+        var oTarget, oBlock, sName;
+
+        if ((oTarget = oEvent.target.closest('[data-bx-analytics-metric]'))) {
+            $this.selectMetric(oTarget);
+            return;
+        }
+
+        if ((oTarget = oEvent.target.closest('[data-bx-analytics-tab]'))) {
+            oBlock = oTarget.closest('[data-bx-analytics-block]');
+            sName = $this.getBlockName(oBlock);
+            $this.setUrlParam('tab_' + sName, oTarget.getAttribute('data-bx-analytics-tab'));
+            $this.setUrlParam('page_' + sName, null);
+            $this.reload(oBlock, true).catch(function () {}); // reload() shows its own failure
+            return;
+        }
+
+        if ((oTarget = oEvent.target.closest('[data-bx-analytics-pager]'))) {
+            oBlock = oTarget.closest('[data-bx-analytics-block]');
+            $this.setUrlParam('page_' + oTarget.getAttribute('data-bx-analytics-pager'), oTarget.getAttribute('data-bx-analytics-page'));
+            $this.reload(oBlock, false).catch(function () {}); // reload() shows its own failure
+            return;
+        }
+
+        if ((oTarget = oEvent.target.closest('[data-bx-analytics-open]'))) {
+            oBlock = oTarget.closest('[data-bx-analytics-block]');
+            sName = oTarget.getAttribute('data-bx-analytics-open');
+            var sValue = oTarget.getAttribute('data-bx-analytics-value');
+            $this.setUrlParam(sName, sValue);
+            $this.reload(oBlock, false).then(function (oNew) {
+                $this.focusAfterOpen(oNew, sName, sValue);
+            }, function () {}); // reload() shows its own failure
+            return;
+        }
+
+        if ((oTarget = oEvent.target.closest('[data-bx-analytics-action]'))) {
+            $this.runAction(oTarget);
+            return;
+        }
+
+        if ((oTarget = oEvent.target.closest('[data-bx-analytics-filter]'))) {
+            // the platform has a parameter of its own (the filter bar's select), not f[platform]
+            sName = oTarget.getAttribute('data-bx-analytics-filter');
+            $this.setParam(sName === 'platform' ? 'platform' : 'f[' + sName + ']', oTarget.getAttribute('data-bx-analytics-value'));
+            return;
+        }
+
+        if ((oTarget = oEvent.target.closest('[data-bx-analytics-unfilter]')))
+            $this.setParam('f[' + oTarget.getAttribute('data-bx-analytics-unfilter') + ']', null);
+    });
+
+    document.addEventListener('submit', function (oEvent) {
+        var oForm = oEvent.target.closest('[data-bx-analytics-form]');
+        if (!oForm)
+            return;
+
+        oEvent.preventDefault();
+        $this.submitForm(oForm);
+    });
+
+    // arrow keys move between tabs, as in any tab list
+    document.addEventListener('keydown', function (oEvent) {
+        var oTab = oEvent.target.closest('[data-bx-analytics-tab]');
+        if (!oTab || (oEvent.key !== 'ArrowRight' && oEvent.key !== 'ArrowLeft'))
+            return;
+
+        var aTabs = Array.prototype.slice.call(oTab.parentNode.querySelectorAll('[data-bx-analytics-tab]'));
+        var iNext = (aTabs.indexOf(oTab) + (oEvent.key === 'ArrowRight' ? 1 : -1) + aTabs.length) % aTabs.length;
+        aTabs[iNext].focus();
+        aTabs[iNext].click();
+        oEvent.preventDefault();
+    });
+
+    this.drawCharts(document);
+    this.drawMaps(document);
+
+    // blocks that follow live data reload themselves while the page is visible
+    setInterval(function () {
+        if (document.visibilityState !== 'visible')
+            return;
+
+        document.querySelectorAll('[data-bx-analytics-refresh]').forEach(function (oRefresh) {
+            var oBlock = oRefresh.closest('[data-bx-analytics-block]');
+            var iEvery = 1000 * parseInt(oRefresh.getAttribute('data-bx-analytics-refresh'), 10);
+            if (oBlock && !oBlock._bxAnalyticsBusy && Date.now() - (oBlock._bxAnalyticsLoaded || $this._iStarted) >= iEvery)
+                $this.reload(oBlock, false, true).catch(function () {}); // a quiet refresh tries again later
+        });
+    }, 5000);
+    this._iStarted = Date.now();
+
+    // without a timezone in the page URL the server takes the viewer's from a cookie, kept
+    // here; only a page drawn before the cookie was set (or in another zone) loads again
+    var sTz = this.getTimezone();
+    var oUrl = new URL(window.location.href);
+    if (!oUrl.searchParams.get('tz') && sTz) {
+        var sRendered = this.getTimezoneCookie() || 'UTC';
+        this.setTimezoneCookie(sTz);
+        if (sTz !== sRendered) {
+            this.setUrlParam('tz', sTz);
+            this.reloadAll();
+        }
+    }
+};
+
+/**
+ * The timezone cookie the server reads when a request has no `tz` (BxAnalyticsFilter).
+ */
+BxAnalyticsDashboard.prototype.getTimezoneCookie = function () {
+    var aMatch = document.cookie.match(/(?:^|;\s*)bx_analytics_tz=([^;]*)/);
+    try {
+        return aMatch ? decodeURIComponent(aMatch[1]) : '';
+    } catch (e) {
+        return '';
+    }
+};
+
+BxAnalyticsDashboard.prototype.setTimezoneCookie = function (sTz) {
+    document.cookie = 'bx_analytics_tz=' + encodeURIComponent(sTz) + '; path=/; max-age=31536000; SameSite=Lax' + (window.location.protocol === 'https:' ? '; Secure' : '');
+};
+
+BxAnalyticsDashboard.prototype.getTimezone = function () {
+    try {
+        return Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
+    } catch (e) {
+        return 'UTC';
+    }
+};
+
+/**
+ * A block's own parameters (group, table...), set by the server.
+ */
+BxAnalyticsDashboard.prototype.getBlockParams = function (oBlock) {
+    try {
+        return JSON.parse(oBlock.getAttribute('data-bx-analytics-params') || '{}') || {};
+    } catch (e) {
+        return {};
+    }
+};
+
+/**
+ * The name a block keeps its tab and page under in the URL.
+ */
+BxAnalyticsDashboard.prototype.getBlockName = function (oBlock) {
+    var oParams = this.getBlockParams(oBlock);
+    return oParams.group || oParams.table || oBlock.getAttribute('data-bx-analytics-block');
+};
+
+/**
+ * Set a filter parameter (null removes it) and reload every block; lists start again
+ * from their first page.
+ */
+BxAnalyticsDashboard.prototype.setParam = function (sName, sValue) {
+    var oUrl = new URL(window.location.href);
+    Array.from(oUrl.searchParams.keys()).forEach(function (sKey) {
+        if (sKey.indexOf('page_') === 0)
+            oUrl.searchParams.delete(sKey);
+    });
+    window.history.replaceState(window.history.state, '', oUrl.toString());
+
+    this.setUrlParam(sName, sValue);
+    if (!new URL(window.location.href).searchParams.get('tz'))
+        this.setUrlParam('tz', this.getTimezone());
+
+    this.reloadAll();
+};
+
+BxAnalyticsDashboard.prototype.setUrlParam = function (sName, sValue) {
+    var oUrl = new URL(window.location.href);
+    if (sValue === null || sValue === '')
+        oUrl.searchParams.delete(sName);
+    else
+        oUrl.searchParams.set(sName, sValue);
+
+    window.history.replaceState(window.history.state, '', oUrl.toString());
+};
+
+BxAnalyticsDashboard.prototype.reloadAll = function () {
+    var $this = this;
+    document.querySelectorAll('[data-bx-analytics-block]').forEach(function (oBlock) {
+        $this.reload(oBlock, false).catch(function () {}); // reload() shows its own failure
+    });
+};
+
+/**
+ * Replace a block with a fresh copy for the current URL parameters. A quiet reload (live
+ * blocks) keeps the block as it is until the new copy arrives.
+ */
+BxAnalyticsDashboard.prototype.reload = function (oBlock, bKeepFocus, bQuiet) {
+    var $this = this;
+    var sReport = oBlock.getAttribute('data-bx-analytics-block');
+    var oParams = new URL(window.location.href).searchParams;
+    oParams.set('bp', oBlock.getAttribute('data-bx-analytics-params') || '{}');
+
+    var sFocusTab = bKeepFocus && document.activeElement ? document.activeElement.getAttribute('data-bx-analytics-tab') : null;
+
+    oBlock._bxAnalyticsBusy = true;
+    if (!bQuiet) {
+        oBlock.setAttribute('aria-busy', 'true');
+        oBlock.classList.add('bx-analytics-loading');
+    }
+
+    // the module URL is m/analytics/ with permalinks and modules/?r=analytics/ without
+    var sUrl = this._sActionUrl + 'report/' + sReport + '/';
+    sUrl += (sUrl.indexOf('?') === -1 ? '?' : '&') + oParams.toString();
+
+    return fetch(sUrl, {credentials: 'same-origin'})
+        .then(function (oResponse) {
+            if (!oResponse.ok)
+                throw new Error(oResponse.status);
+            return oResponse.json();
+        })
+        .then(function (oData) {
+            var oWrapper = document.createElement('div');
+            oWrapper.innerHTML = oData.html;
+            var oNew = oWrapper.firstElementChild;
+            if (!oNew) {
+                oBlock._bxAnalyticsBusy = false;
+                return null;
+            }
+
+            $this.destroyChart(oBlock);
+            oBlock.replaceWith(oNew);
+            oNew._bxAnalyticsLoaded = Date.now();
+            $this.drawCharts(oNew);
+            $this.drawMaps(oNew);
+            if (typeof bx_time === 'function')
+                bx_time(undefined, false, oNew);
+
+            if (sFocusTab) {
+                var oTab = oNew.querySelector('[data-bx-analytics-tab="' + sFocusTab + '"]');
+                if (oTab)
+                    oTab.focus();
+            }
+
+            return oNew;
+        })
+        .catch(function () {
+            oBlock._bxAnalyticsBusy = false;
+            oBlock.removeAttribute('aria-busy');
+            oBlock.classList.remove('bx-analytics-loading');
+            if (!bQuiet) {
+                oBlock.classList.add('bx-analytics-failed');
+                oBlock.setAttribute('data-bx-analytics-error', $this._aStrings.error || '');
+            }
+
+            return null;
+        });
+};
+
+/**
+ * Keyboard focus after a block opened something: the first field of a form it shows, or the
+ * control for the same thing (a back or cancel leads to the control that opened it).
+ */
+BxAnalyticsDashboard.prototype.focusAfterOpen = function (oBlock, sName, sValue) {
+    if (!oBlock)
+        return;
+
+    var oFocus = oBlock.querySelector('[data-bx-analytics-form] input:not([type="hidden"]), [data-bx-analytics-form] select');
+    if (!oFocus && sValue)
+        oFocus = oBlock.querySelector('[data-bx-analytics-open="' + sName + '"][data-bx-analytics-value="' + CSS.escape(sValue) + '"]');
+    if (!oFocus)
+        oFocus = oBlock.querySelector('[data-bx-analytics-open="' + sName + '"]');
+
+    if (oFocus)
+        oFocus.focus();
+};
+
+/**
+ * Send a goal or funnel form; on success close the form and show the saved item.
+ */
+BxAnalyticsDashboard.prototype.submitForm = function (oForm) {
+    var $this = this;
+    var sKind = oForm.getAttribute('data-bx-analytics-form');
+    var oBlock = oForm.closest('[data-bx-analytics-block]');
+    var oError = oForm.querySelector('.bx-analytics-form-error');
+    var oSubmit = oForm.querySelector('[type="submit"]');
+    var sSaved = '';
+
+    var oData = new FormData(oForm);
+    oData.set('op', 'save');
+
+    oSubmit.disabled = true;
+    this.post(sKind, oData)
+        .then(function (oResult) {
+            if (!oResult.ok)
+                throw new Error(oResult.error || $this._aStrings.error || '');
+
+            sSaved = oResult.id ? String(oResult.id) : '';
+            $this.setUrlParam('edit_' + sKind, null);
+            if (sKind === 'funnel' && oResult.id)
+                $this.setUrlParam('funnel', String(oResult.id));
+
+            return $this.reload(oBlock, false);
+        })
+        .then(function (oNew) {
+            $this.focusAfterOpen(oNew, 'edit_' + sKind, sSaved);
+        })
+        .catch(function (oException) {
+            oSubmit.disabled = false;
+            oError.textContent = oException.message || $this._aStrings.error || '';
+            oError.hidden = false;
+        });
+};
+
+/**
+ * A button that changes data: delete a goal or a funnel after the viewer confirms it.
+ */
+BxAnalyticsDashboard.prototype.runAction = function (oButton) {
+    var $this = this;
+    var sKind = oButton.getAttribute('data-bx-analytics-action');
+    var sConfirm = oButton.getAttribute('data-bx-analytics-confirm');
+    if (sConfirm && !window.confirm(sConfirm))
+        return;
+
+    var oBlock = oButton.closest('[data-bx-analytics-block]');
+    var oData = new FormData();
+    oData.set('op', oButton.getAttribute('data-bx-analytics-op'));
+    oData.set('id', oButton.getAttribute('data-bx-analytics-id'));
+    oData.set('csrf_token', oButton.getAttribute('data-bx-analytics-csrf') || '');
+
+    oButton.disabled = true;
+    this.post(sKind, oData)
+        .then(function (oResult) {
+            if (!oResult.ok)
+                throw new Error(oResult.error || '');
+
+            if (sKind === 'funnel')
+                $this.setUrlParam('funnel', null);
+
+            return $this.reload(oBlock, false);
+        })
+        .then(function (oNew) {
+            // a status change keeps the view: focus goes to the first action left
+            var oAction = oNew && sKind === 'error_status' ? oNew.querySelector('[data-bx-analytics-action="error_status"]') : null;
+            if (oAction)
+                oAction.focus();
+            else
+                $this.focusAfterOpen(oNew, 'edit_' + sKind, 'new');
+        })
+        .catch(function (oException) {
+            oButton.disabled = false;
+            window.alert(oException.message || $this._aStrings.error || '');
+        });
+};
+
+BxAnalyticsDashboard.prototype.post = function (sAction, oData) {
+    var sUrl = this._sActionUrl + sAction + '/';
+
+    return fetch(sUrl, {method: 'POST', body: oData, credentials: 'same-origin'})
+        .then(function (oResponse) {
+            return oResponse.json().catch(function () {
+                return {error: ''};
+            });
+        });
+};
+
+BxAnalyticsDashboard.prototype.selectMetric = function (oButton) {
+    var oBlock = oButton.closest('[data-bx-analytics-block]');
+    oBlock.querySelectorAll('[data-bx-analytics-metric]').forEach(function (o) {
+        o.setAttribute('aria-pressed', o === oButton ? 'true' : 'false');
+    });
+
+    this.setUrlParam('metric', oButton.getAttribute('data-bx-analytics-metric'));
+
+    var oJson = oBlock.querySelector('.bx-analytics-chart-data');
+    var oData = oJson ? this.getChartData(oJson) : null;
+    if (oData) {
+        oData.metric = oButton.getAttribute('data-bx-analytics-metric');
+        this.drawChart(oJson, oData);
+    }
+};
+
+/**
+ * The data of a chart: JSON in a script element right after the chart's container.
+ */
+BxAnalyticsDashboard.prototype.getChartData = function (oJson) {
+    if (!oJson._bxAnalyticsData) {
+        try {
+            oJson._bxAnalyticsData = JSON.parse(oJson.textContent);
+        } catch (e) {
+            return null;
+        }
+    }
+
+    return oJson._bxAnalyticsData;
+};
+
+BxAnalyticsDashboard.prototype.drawCharts = function (oRoot) {
+    var $this = this;
+    var aJson = oRoot.querySelectorAll ? oRoot.querySelectorAll('.bx-analytics-chart-data') : [];
+    Array.prototype.forEach.call(aJson, function (oJson) {
+        var oData = $this.getChartData(oJson);
+        if (oData)
+            $this.drawChart(oJson, oData);
+    });
+};
+
+/**
+ * Visitors on a Leaflet map: a circle per country (or place), its area by visitors.
+ */
+BxAnalyticsDashboard.prototype.drawMaps = function (oRoot) {
+    if (typeof L === 'undefined')
+        return;
+
+    var aMaps = oRoot.querySelectorAll ? oRoot.querySelectorAll('.bx-analytics-map') : [];
+    Array.prototype.forEach.call(aMaps, function (oElement) {
+        if (oElement._bxAnalyticsMap)
+            return;
+
+        var oJson = oElement.parentNode.querySelector('.bx-analytics-map-data');
+        var oData;
+        try {
+            oData = JSON.parse(oJson.textContent);
+        } catch (e) {
+            return;
+        }
+
+        var oMap = L.map(oElement, {worldCopyJump: true, minZoom: 1, scrollWheelZoom: false}).setView([25, 10], 2);
+        oElement._bxAnalyticsMap = oMap;
+
+        try {
+            L.tileLayer.provider(oData.provider || 'OpenStreetMap.Mapnik').addTo(oMap);
+        } catch (e) {
+            L.tileLayer.provider('OpenStreetMap.Mapnik').addTo(oMap);
+        }
+
+        var sAccent = window.getComputedStyle(oElement).getPropertyValue('--bx-analytics-accent').trim() || '#4f46e5';
+        var aItems = oData.points && oData.points.length ? oData.points : oData.countries;
+        var iMax = 1;
+        aItems.forEach(function (o) { iMax = Math.max(iMax, o.visitors); });
+
+        // the whole world when there is little to show, otherwise just the places with visitors
+        if (aItems.length > 1)
+            oMap.fitBounds(L.latLngBounds(aItems.map(function (o) { return [o.lat, o.lng]; })), {padding: [30, 30], maxZoom: 5});
+        else if (aItems.length === 1)
+            oMap.setView([aItems[0].lat, aItems[0].lng], 4);
+
+        aItems.forEach(function (o) {
+            L.circleMarker([o.lat, o.lng], {
+                radius: 4 + 26 * Math.sqrt(o.visitors / iMax),
+                color: sAccent,
+                weight: 1,
+                fillColor: sAccent,
+                fillOpacity: 0.35
+            }).bindTooltip(function () {
+                // place names come from request headers: text, never HTML
+                var oTip = document.createElement('span');
+                oTip.textContent = o.label + ': ' + o.visitors + ' ' + oData.visitors.toLowerCase();
+                return oTip;
+            }).addTo(oMap);
+        });
+    });
+};
+
+BxAnalyticsDashboard.prototype.destroyChart = function (oBlock) {
+    var oMap = oBlock.querySelector('.bx-analytics-map');
+    if (oMap && oMap._bxAnalyticsMap) {
+        oMap._bxAnalyticsMap.remove();
+        oMap._bxAnalyticsMap = null;
+    }
+
+    oBlock.querySelectorAll('canvas').forEach(function (oCanvas) {
+        if (oCanvas._bxAnalyticsChart) {
+            oCanvas._bxAnalyticsChart.destroy();
+            oCanvas._bxAnalyticsChart = null;
+        }
+    });
+};
+
+/**
+ * Colours for the chart: the accent and a categorical palette, from the block's CSS.
+ */
+BxAnalyticsDashboard.prototype.getColors = function (oElement) {
+    var oStyle = window.getComputedStyle(oElement);
+    var get = function (sName, sDefault) {
+        return oStyle.getPropertyValue(sName).trim() || sDefault;
+    };
+
+    var aPalette = [];
+    for (var i = 1; i <= 8; i++)
+        aPalette.push(get('--bx-analytics-c' + i, '#4f46e5'));
+
+    return {
+        accent: get('--bx-analytics-accent', '#4f46e5'),
+        fill: get('--bx-analytics-accent-fill', 'rgba(79, 70, 229, 0.15)'),
+        grid: get('--bx-analytics-grid', 'rgba(0, 0, 0, 0.08)'),
+        muted: get('--bx-analytics-muted', '#6b7280'),
+        good: get('--bx-analytics-good', '#047857'),
+        bad: get('--bx-analytics-bad', '#b91c1c'),
+        palette: aPalette
+    };
+};
+
+/**
+ * Several values over time (kind `series`: `series` of {label, points of {t, v, partial}})
+ * as lines per category, the bucket in progress dashed.
+ */
+BxAnalyticsDashboard.prototype.getSeriesLines = function (oData) {
+    var aSeries = oData.series || [];
+
+    return {
+        kind: 'lines',
+        metric: oData.metric,
+        bucket: oData.bucket,
+        tz: oData.tz,
+        legend: true,
+        labels: aSeries.length ? aSeries[0].points.map(function (oPoint) { return oPoint.t; }) : [],
+        datasets: aSeries.map(function (oSeries) {
+            return {
+                label: oSeries.label,
+                values: oSeries.points.map(function (oPoint) { return oPoint.v; }),
+                partial: oSeries.points.map(function (oPoint) { return !!oPoint.partial; })
+            };
+        })
+    };
+};
+
+/**
+ * Draw the chart a JSON element describes, in the canvas of the container before it.
+ * Kinds: a metric over time (the overview, `series` with every metric), stacked bars per
+ * category (`labels` and `datasets`), lines per category (`datasets`, kind `lines`), and
+ * several values of one metric over time (kind `series`, the query).
+ */
+BxAnalyticsDashboard.prototype.drawChart = function (oJson, oData) {
+    var oContainer = oJson.previousElementSibling;
+    var oCanvas = oContainer ? oContainer.querySelector('canvas') : null;
+    if (!oCanvas || typeof Chart === 'undefined')
+        return;
+
+    if (oCanvas._bxAnalyticsChart) {
+        oCanvas._bxAnalyticsChart.destroy();
+        oCanvas._bxAnalyticsChart = null;
+    }
+
+    var $this = this;
+    var oColors = this.getColors(oContainer);
+    var bReducedMotion = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
+    if (oData.kind === 'series')
+        oData = this.getSeriesLines(oData);
+
+    var sKind = oData.kind || 'metric';
+    var sMetric = oData.metric || 'number';
+    var oChart;
+
+    var oScales = {
+        x: {
+            stacked: sKind === 'stacked',
+            grid: {display: false},
+            ticks: {color: oColors.muted, autoSkip: true, maxRotation: 0, maxTicksLimit: 8}
+        },
+        y: {
+            stacked: sKind === 'stacked',
+            beginAtZero: true,
+            grid: {color: oColors.grid},
+            border: {display: false},
+            ticks: {
+                color: oColors.muted,
+                precision: sMetric === 'pages_per_session' ? 1 : (sMetric === 'cls' ? undefined : 0),
+                callback: function (fValue) {
+                    return $this.formatMetric(sMetric, fValue, oData);
+                }
+            }
+        }
+    };
+
+    if (sKind === 'metric') {
+        var aLabels = [], aValues = [], aPartial = [];
+        oData.series.forEach(function (oPoint) {
+            aLabels.push($this.formatBucket(oPoint.t, oData.bucket, oData.tz));
+            aValues.push(oPoint.future ? null : oPoint[sMetric]);
+            aPartial.push(!!oPoint.partial);
+        });
+
+        oChart = {
+            type: 'line',
+            data: {
+                labels: aLabels,
+                datasets: [{
+                    label: oData.titles[sMetric],
+                    data: aValues,
+                    borderColor: oColors.accent,
+                    backgroundColor: oColors.fill,
+                    borderWidth: 2,
+                    fill: true,
+                    tension: 0.3,
+                    pointRadius: 0,
+                    pointHoverRadius: 4,
+                    spanGaps: false,
+                    // the period still in progress is drawn dashed, so its low value doesn't read as a drop
+                    segment: {
+                        borderDash: function (oContext) {
+                            return aPartial[oContext.p1DataIndex] ? [4, 4] : undefined;
+                        }
+                    }
+                }]
+            },
+            legend: false,
+            label: function (oContext) {
+                return oData.titles[sMetric] + ': ' + $this.formatMetric(sMetric, oContext.parsed.y, oData);
+            }
+        };
+    }
+    else {
+        oChart = {
+            type: sKind === 'stacked' ? 'bar' : 'line',
+            data: {
+                labels: oData.labels.map(function (iTime) { return $this.formatBucket(iTime, oData.bucket || 'day', oData.tz || 'UTC'); }),
+                datasets: oData.datasets.map(function (oSet, iIndex) {
+                    // a set may ask for a meaning colour (good, bad) instead of the next in the palette
+                    var sColor = oSet.tone && oColors[oSet.tone] ? oColors[oSet.tone] : oColors.palette[iIndex % oColors.palette.length];
+                    return {
+                        label: oSet.label,
+                        data: oSet.values,
+                        backgroundColor: sColor,
+                        borderColor: sColor,
+                        borderWidth: sKind === 'stacked' ? 0 : (oSet.dashed ? 1.5 : 2),
+                        // reference lines, such as a metric's thresholds
+                        borderDash: oSet.dashed ? [6, 4] : undefined,
+                        spanGaps: false,
+                        borderRadius: sKind === 'stacked' ? 2 : 0,
+                        pointRadius: 0,
+                        pointHoverRadius: sKind === 'stacked' ? 0 : 4,
+                        tension: 0.3,
+                        // the bucket still in progress is drawn dashed
+                        segment: oSet.partial ? {
+                            borderDash: function (oContext) {
+                                return oSet.partial[oContext.p1DataIndex] ? [4, 4] : undefined;
+                            }
+                        } : undefined
+                    };
+                })
+            },
+            legend: oData.legend || oData.datasets.length > 1,
+            label: function (oContext) {
+                return oContext.dataset.label + ': ' + $this.formatMetric(sMetric, oContext.parsed.y, oData);
+            }
+        };
+    }
+
+    oCanvas._bxAnalyticsChart = new Chart(oCanvas, {
+        type: oChart.type,
+        data: oChart.data,
+        options: {
+            responsive: true,
+            maintainAspectRatio: false,
+            animation: bReducedMotion ? false : {duration: 300},
+            interaction: {mode: 'index', intersect: false},
+            plugins: {
+                legend: {
+                    display: oChart.legend,
+                    position: 'bottom',
+                    labels: {color: oColors.muted, boxWidth: 12, boxHeight: 12}
+                },
+                tooltip: {
+                    callbacks: {label: oChart.label}
+                }
+            },
+            scales: oScales
+        }
+    });
+};
+
+BxAnalyticsDashboard.prototype.formatBucket = function (iTime, sBucket, sTz) {
+    var oDate = new Date(iTime * 1000);
+    var oOptions = {timeZone: sTz || 'UTC'};
+
+    if (sBucket === 'hour') {
+        oOptions.hour = 'numeric';
+        oOptions.minute = '2-digit';
+    }
+    else if (sBucket === 'month') {
+        oOptions.month = 'short';
+        oOptions.year = 'numeric';
+    }
+    else {
+        oOptions.month = 'short';
+        oOptions.day = 'numeric';
+    }
+
+    try {
+        return new Intl.DateTimeFormat(document.documentElement.lang || undefined, oOptions).format(oDate);
+    } catch (e) {
+        return oDate.toISOString().slice(0, sBucket === 'hour' ? 16 : 10);
+    }
+};
+
+BxAnalyticsDashboard.prototype.formatMetric = function (sMetric, fValue, oData) {
+    if (fValue === null || fValue === undefined)
+        return '';
+
+    switch (sMetric) {
+        // amounts in the site's default currency, which the chart data names
+        case 'money':
+            try {
+                if (oData && oData.currency)
+                    return new Intl.NumberFormat(document.documentElement.lang || undefined, {style: 'currency', currency: oData.currency, notation: Math.abs(fValue) >= 10000 ? 'compact' : 'standard', minimumFractionDigits: 0, maximumFractionDigits: Math.abs(fValue) >= 100 ? 0 : 2}).format(fValue);
+            } catch (e) {
+                // an unknown currency code: the number alone
+            }
+            break;
+
+        case 'bounce_rate':
+            return Math.round(fValue) + '%';
+
+        // timings: 850ms, 2.40s
+        case 'ms':
+            return fValue >= 1000 ? (fValue / 1000).toFixed(2) + (this._aStrings.duration_s || 's') : Math.round(fValue) + (this._aStrings.ms || 'ms');
+
+        // layout shift: a score
+        case 'cls':
+            return Number(fValue).toFixed(fValue && fValue < 0.1 ? 3 : 2);
+
+        case 'pages_per_session':
+            return Number(fValue).toFixed(1);
+
+        case 'duration':
+        case 'engaged':
+            var iSeconds = Math.round(fValue);
+            if (iSeconds < 60)
+                return iSeconds + (this._aStrings.duration_s || 's');
+            if (iSeconds < 3600)
+                return Math.floor(iSeconds / 60) + (this._aStrings.duration_m || 'm') + ' ' + (iSeconds % 60) + (this._aStrings.duration_s || 's');
+            return Math.floor(iSeconds / 3600) + (this._aStrings.duration_h || 'h') + ' ' + Math.floor((iSeconds % 3600) / 60) + (this._aStrings.duration_m || 'm');
+    }
+
+    try {
+        return new Intl.NumberFormat(document.documentElement.lang || undefined, {notation: 'compact', maximumFractionDigits: 1}).format(fValue);
+    } catch (e) {
+        return String(fValue);
+    }
+};
+
+/** @} */

+ 362 - 0
modules/boonex/analytics/js/studio_tools.js

@@ -0,0 +1,362 @@
+/**
+ * Copyright (c) UNA, Inc - https://una.io
+ * MIT License - https://opensource.org/licenses/MIT
+ *
+ * @defgroup    Analytics Analytics
+ * @ingroup     UnaModules
+ *
+ * @{
+ */
+
+/**
+ * Studio tools of Analytics, for site administrators: the API playground, new analytics
+ * pages, and the tracker checks in Setup. Every action is a POST with the form token; the
+ * server renders any HTML, and report data is shown as text, never as HTML.
+ */
+function BxAnalyticsStudioTools(oOptions) {
+    this._sActionUrl = oOptions.sActionUrl;
+    this._aStrings = oOptions.aStrings || {};
+
+    // a report's response is shown up to this many characters
+    this._iMaxOutput = 1024 * 1024;
+
+    var $this = this;
+    if (document.readyState === 'loading')
+        document.addEventListener('DOMContentLoaded', function () { $this.init(); });
+    else
+        this.init();
+}
+
+BxAnalyticsStudioTools.prototype.init = function () {
+    var $this = this;
+
+    document.addEventListener('click', function (oEvent) {
+        var oTarget;
+
+        if ((oTarget = oEvent.target.closest('[data-bx-analytics-copy]'))) {
+            $this.copy(oTarget);
+            return;
+        }
+
+        if ((oTarget = oEvent.target.closest('[data-bx-analytics-setup]')))
+            $this.runSetup(oTarget);
+    });
+
+    document.addEventListener('submit', function (oEvent) {
+        var oForm;
+
+        if ((oForm = oEvent.target.closest('[data-bx-analytics-api-form]'))) {
+            oEvent.preventDefault();
+            $this.runApi(oForm);
+            return;
+        }
+
+        if ((oForm = oEvent.target.closest('[data-bx-analytics-dashboard-form]'))) {
+            oEvent.preventDefault();
+            $this.createDashboard(oForm);
+        }
+    });
+
+    var oApi = document.querySelector('[data-bx-analytics-api-form]');
+    if (oApi) {
+        var fUpdate = function () { $this.updateApi(oApi); };
+        oApi.querySelector('[data-bx-analytics-api-report]').addEventListener('change', fUpdate);
+        oApi.querySelector('[data-bx-analytics-api-params]').addEventListener('input', fUpdate);
+        fUpdate();
+    }
+};
+
+/**
+ * Copy the text of the element a button names, and say so for a moment.
+ */
+BxAnalyticsStudioTools.prototype.copy = function (oButton) {
+    var oSource = document.getElementById(oButton.getAttribute('data-bx-analytics-copy'));
+    if (!oSource)
+        return;
+
+    var sText = oSource.textContent;
+    var fDone = function () {
+        var sLabel = oButton.getAttribute('data-bx-analytics-copied');
+        if (!sLabel)
+            return;
+
+        if (!oButton._bxAnalyticsLabel)
+            oButton._bxAnalyticsLabel = oButton.textContent;
+        oButton.textContent = sLabel;
+        clearTimeout(oButton._bxAnalyticsTimer);
+        oButton._bxAnalyticsTimer = setTimeout(function () {
+            oButton.textContent = oButton._bxAnalyticsLabel;
+        }, 2000);
+    };
+
+    if (navigator.clipboard && window.isSecureContext) {
+        navigator.clipboard.writeText(sText).then(fDone, function () {
+            if (BxAnalyticsStudioTools.copyFallback(sText))
+                fDone();
+        });
+        return;
+    }
+
+    if (BxAnalyticsStudioTools.copyFallback(sText))
+        fDone();
+};
+
+/**
+ * Copy without the Clipboard API (a page served over plain HTTP).
+ */
+BxAnalyticsStudioTools.copyFallback = function (sText) {
+    var oArea = document.createElement('textarea');
+    oArea.value = sText;
+    oArea.setAttribute('readonly', '');
+    oArea.style.position = 'fixed';
+    oArea.style.opacity = '0';
+    document.body.appendChild(oArea);
+    oArea.select();
+
+    var bCopied = false;
+    try {
+        bCopied = document.execCommand('copy');
+    } catch (e) {
+        bCopied = false;
+    }
+    document.body.removeChild(oArea);
+
+    return bCopied;
+};
+
+/**
+ * The parameters typed in the playground: an object, or null when it isn't valid JSON.
+ */
+BxAnalyticsStudioTools.prototype.getApiParams = function (oForm) {
+    var sValue = oForm.querySelector('[data-bx-analytics-api-params]').value.trim();
+    if (sValue === '')
+        return {};
+
+    try {
+        var oParams = JSON.parse(sValue);
+        return oParams && typeof oParams === 'object' && !Array.isArray(oParams) ? oParams : null;
+    } catch (e) {
+        return null;
+    }
+};
+
+BxAnalyticsStudioTools.prototype.getApiCatalog = function (oForm) {
+    var oTool = oForm.closest('.bx-analytics-api');
+    var oJson = oTool ? oTool.querySelector('[data-bx-analytics-api-catalog]') : null;
+    if (!oJson)
+        return {root: '/', module: 'bx_analytics'};
+
+    if (!oJson._bxAnalyticsData) {
+        try {
+            oJson._bxAnalyticsData = JSON.parse(oJson.textContent);
+        } catch (e) {
+            oJson._bxAnalyticsData = {root: '/', module: 'bx_analytics'};
+        }
+    }
+
+    return oJson._bxAnalyticsData;
+};
+
+/**
+ * Show the chosen report's parameters and write the call three ways.
+ */
+BxAnalyticsStudioTools.prototype.updateApi = function (oForm) {
+    var sReport = oForm.querySelector('[data-bx-analytics-api-report]').value;
+    var oInput = oForm.querySelector('[data-bx-analytics-api-params]');
+    var oParams = this.getApiParams(oForm);
+    var oCatalog = this.getApiCatalog(oForm);
+
+    oForm.querySelectorAll('[data-bx-analytics-api-doc]').forEach(function (oDoc) {
+        oDoc.hidden = oDoc.getAttribute('data-bx-analytics-api-doc') !== sReport;
+    });
+
+    if (oParams === null) {
+        oInput.setAttribute('aria-invalid', 'true');
+        return;
+    }
+    oInput.removeAttribute('aria-invalid');
+
+    var sJson = JSON.stringify(oParams);
+    var aCalls = {
+        'bx-analytics-api-srv': "bx_srv('" + oCatalog.module + "', 'get_report', [" + this.toPhp(sReport) + ', ' + this.toPhp(oParams) + ']);',
+        'bx-analytics-api-url': oCatalog.root + 'api.php?r=' + oCatalog.module + '/get_report&params=' + encodeURIComponent(JSON.stringify([sReport, oParams])),
+        'bx-analytics-api-neo': '/api.php?r=' + oCatalog.module + '/get_report&params[]=' + encodeURIComponent(sReport) + '&params[]=' + encodeURIComponent(sJson)
+    };
+
+    Object.keys(aCalls).forEach(function (sId) {
+        var oCode = document.querySelector('#' + sId + ' code');
+        if (oCode)
+            oCode.textContent = aCalls[sId];
+    });
+};
+
+/**
+ * A JSON value as a PHP literal: short arrays, single-quoted strings.
+ */
+BxAnalyticsStudioTools.prototype.toPhp = function (mixedValue) {
+    var $this = this;
+
+    if (mixedValue === null || mixedValue === undefined)
+        return 'null';
+    if (typeof mixedValue === 'boolean')
+        return mixedValue ? 'true' : 'false';
+    if (typeof mixedValue === 'number')
+        return String(mixedValue);
+    if (typeof mixedValue === 'string')
+        return "'" + mixedValue.replace(/\\/g, '\\\\').replace(/'/g, "\\'") + "'";
+    if (Array.isArray(mixedValue))
+        return '[' + mixedValue.map(function (m) { return $this.toPhp(m); }).join(', ') + ']';
+
+    return '[' + Object.keys(mixedValue).map(function (sKey) {
+        return $this.toPhp(sKey) + ' => ' + $this.toPhp(mixedValue[sKey]);
+    }).join(', ') + ']';
+};
+
+/**
+ * Run the report as the signed-in administrator and show api.php's answer as text.
+ */
+BxAnalyticsStudioTools.prototype.runApi = function (oForm) {
+    var $this = this;
+    var oError = oForm.querySelector('.bx-analytics-form-error');
+    var oSubmit = oForm.querySelector('[type="submit"]');
+    var oTool = oForm.closest('.bx-analytics-api');
+    var oResult = oTool.querySelector('[data-bx-analytics-api-result]');
+    var oOutput = oTool.querySelector('[data-bx-analytics-api-output]');
+    var oMeta = oTool.querySelector('[data-bx-analytics-api-meta]');
+
+    oError.hidden = true;
+    if (this.getApiParams(oForm) === null) {
+        oError.textContent = this._aStrings.bad_json || '';
+        oError.hidden = false;
+        oForm.querySelector('[data-bx-analytics-api-params]').focus();
+        return;
+    }
+
+    var oData = new FormData(oForm);
+    oSubmit.disabled = true;
+    oMeta.textContent = this._aStrings.running || '';
+
+    this.post('api', oData)
+        .then(function (oResponse) {
+            if (!oResponse.ok)
+                throw new Error(oResponse.error || $this._aStrings.error || '');
+
+            var sJson = JSON.stringify(oResponse.response, null, 2);
+            var iBytes = typeof TextEncoder !== 'undefined' ? new TextEncoder().encode(JSON.stringify(oResponse.response)).length : sJson.length;
+            var bTruncated = sJson.length > $this._iMaxOutput;
+
+            oOutput.textContent = bTruncated ? sJson.slice(0, $this._iMaxOutput) + '\n…' : sJson;
+            oMeta.textContent = ($this._aStrings.meta || '')
+                .replace('{status}', String(oResponse.response.status))
+                .replace('{ms}', String(oResponse.ms))
+                .replace('{size}', $this.formatBytes(iBytes)) + (bTruncated ? ' ' + ($this._aStrings.truncated || '') : '');
+            oResult.hidden = false;
+        })
+        .catch(function (oException) {
+            oMeta.textContent = '';
+            oError.textContent = oException.message || $this._aStrings.error || '';
+            oError.hidden = false;
+        })
+        .then(function () {
+            oSubmit.disabled = false;
+        })
+        .catch(function () {}); // nothing is left to fail: the catch above reports errors
+};
+
+BxAnalyticsStudioTools.prototype.formatBytes = function (iBytes) {
+    var sLang = document.documentElement.lang || undefined;
+    try {
+        if (iBytes >= 1024 * 1024)
+            return new Intl.NumberFormat(sLang, {maximumFractionDigits: 1}).format(iBytes / 1024 / 1024) + ' MB';
+        if (iBytes >= 1024)
+            return new Intl.NumberFormat(sLang, {maximumFractionDigits: 1}).format(iBytes / 1024) + ' KB';
+    } catch (e) {
+        // a plain number below
+    }
+
+    return iBytes + ' B';
+};
+
+/**
+ * Create an analytics page and open it in the page builder.
+ */
+BxAnalyticsStudioTools.prototype.createDashboard = function (oForm) {
+    var $this = this;
+    var oError = oForm.querySelector('.bx-analytics-form-error');
+    var oSubmit = oForm.querySelector('[type="submit"]');
+    var oTitle = oForm.querySelector('[name="title"]');
+
+    oError.hidden = true;
+    if (!oTitle.value.trim()) {
+        oTitle.setAttribute('aria-invalid', 'true');
+        oTitle.focus();
+        return;
+    }
+    oTitle.removeAttribute('aria-invalid');
+
+    oSubmit.disabled = true;
+    this.post('dashboard_create', new FormData(oForm))
+        .then(function (oResponse) {
+            if (!oResponse.ok || !oResponse.url)
+                throw new Error(oResponse.error || $this._aStrings.error || '');
+
+            window.location.href = oResponse.url;
+        })
+        .catch(function (oException) {
+            oSubmit.disabled = false;
+            oError.textContent = oException.message || $this._aStrings.error || '';
+            oError.hidden = false;
+        });
+};
+
+/**
+ * Setup: check this browser (stores nothing) or send a test event (stores a visit).
+ */
+BxAnalyticsStudioTools.prototype.runSetup = function (oButton) {
+    var $this = this;
+    var sAction = oButton.getAttribute('data-bx-analytics-setup');
+    var oTool = oButton.closest('.bx-analytics-setup');
+    var oResult = oTool.querySelector('[data-bx-analytics-setup-result]');
+
+    var oData = new FormData();
+    oData.set('csrf_token', oButton.getAttribute('data-bx-analytics-csrf') || '');
+
+    oButton.disabled = true;
+    this.post(sAction, oData)
+        .then(function (oResponse) {
+            if (!oResponse.ok)
+                throw new Error(oResponse.error || $this._aStrings.error || '');
+
+            if (sAction === 'check') {
+                // rendered and escaped by the server
+                oResult.innerHTML = oResponse.html;
+                return;
+            }
+
+            oResult.textContent = oResponse.message || '';
+            var oStatus = oTool.querySelector('[data-bx-analytics-setup-status]');
+            if (oStatus && oResponse.html) {
+                oStatus.innerHTML = oResponse.html;
+                if (typeof bx_time === 'function')
+                    bx_time(undefined, false, oStatus);
+            }
+        })
+        .catch(function (oException) {
+            oResult.textContent = oException.message || $this._aStrings.error || '';
+        })
+        .then(function () {
+            oButton.disabled = false;
+        })
+        .catch(function () {}); // nothing is left to fail: the catch above reports errors
+};
+
+BxAnalyticsStudioTools.prototype.post = function (sAction, oData) {
+    return fetch(this._sActionUrl + sAction + '/', {method: 'POST', body: oData, credentials: 'same-origin'})
+        .then(function (oResponse) {
+            return oResponse.json().catch(function () {
+                return {error: ''};
+            });
+        });
+};
+
+/** @} */

+ 750 - 0
modules/boonex/analytics/js/tracker.js

@@ -0,0 +1,750 @@
+/**
+ * Copyright (c) UNA, Inc - https://una.io
+ * MIT License - https://opensource.org/licenses/MIT
+ *
+ * @defgroup    Analytics Analytics
+ * @ingroup     UnaModules
+ *
+ * @{
+ */
+
+/**
+ * Pageviews, engaged time, custom events, errors and web vitals from the classic UI.
+ *
+ * Sends a pageview when a page loads and after every htmx navigation (not on preload), and the
+ * time the page was visible when it is hidden or left. Custom events:
+ *     bx_analytics.track('signup_click', {plan: 'pro'});
+ * Clicks on links to other sites and to files are sent as the custom events `outbound_link` and
+ * `file_download`, with the link (without its query string) in the `link` property.
+ * Errors the page catches itself:
+ *     bx_analytics.trackError(oError);
+ * Uncaught errors, unhandled rejections, failed htmx requests (500 and up) and the site's own
+ * scripts and styles that fail to load are sent when the site tracks errors: the same error
+ * once per page view with a count of repeats, at most 10 different ones, without query strings.
+ * Web vitals (TTFB, FCP, LCP, CLS, INP) are measured on a share of page loads and sent once,
+ * with the time the page was visible; an htmx navigation sends its load time, CLS and INP.
+ * Stores nothing in the browser unless the site uses persistent visitor ids: then a random id in
+ * localStorage, and in the bx_analytics_vid cookie for the site's own requests, so sign-ups and
+ * other actions join this browser's visit. To stop counting
+ * your own visits in this browser: localStorage.setItem('bx_analytics_ignore', '1').
+ */
+(function (window, document) {
+    'use strict';
+
+    var oScript = document.currentScript || document.querySelector('script[data-bx-analytics-endpoint]');
+    if (!oScript || window.bx_analytics_tracker)
+        return;
+
+    var sEndpoint = oScript.getAttribute('data-bx-analytics-endpoint');
+    var sPlatform = oScript.getAttribute('data-bx-analytics-platform') || 'core-web';
+    var bPersistent = oScript.getAttribute('data-bx-analytics-identity') === 'persistent';
+    var bRespectDnt = oScript.getAttribute('data-bx-analytics-dnt') === '1';
+    var bTrackErrors = oScript.getAttribute('data-bx-analytics-errors') === '1';
+    var iVitalsSample = parseInt(oScript.getAttribute('data-bx-analytics-vitals') || '0', 10) || 0;
+
+    var oNavigator = window.navigator || {};
+    var oPerformance = window.performance;
+
+    function storageGet(sKey) {
+        try {
+            return window.localStorage.getItem(sKey);
+        } catch (e) {
+            return null;
+        }
+    }
+
+    function isDisabled() {
+        if (!sEndpoint || oNavigator.webdriver)
+            return true;
+        if (bRespectDnt && (oNavigator.doNotTrack === '1' || window.doNotTrack === '1' || oNavigator.globalPrivacyControl === true))
+            return true;
+        return storageGet('bx_analytics_ignore') === '1';
+    }
+
+    function getVisitorId() {
+        if (!bPersistent)
+            return '';
+
+        var sId = storageGet('bx_analytics_vid');
+        if (sId && /^[a-f0-9]{32}$/.test(sId))
+            return sId;
+
+        sId = '';
+        var aBytes = new Uint8Array(16);
+        (window.crypto || window.msCrypto).getRandomValues(aBytes);
+        for (var i = 0; i < aBytes.length; i++)
+            sId += ('0' + aBytes[i].toString(16)).slice(-2);
+
+        try {
+            window.localStorage.setItem('bx_analytics_vid', sId);
+        } catch (e) {
+            return '';
+        }
+
+        return sId;
+    }
+
+    var bDisabled = isDisabled();
+    var sVisitorId = bDisabled ? '' : getVisitorId();
+
+    /**
+     * The visitor id for the site's own requests (persistent mode), or none: a cookie left
+     * from persistent mode, or from before tracking was turned off here, is removed.
+     */
+    function setVisitorCookie(sId) {
+        var sCookie = 'bx_analytics_vid=' + sId + '; path=/; SameSite=Lax; max-age=' + (sId ? 34560000 : 0);
+        if (window.location.protocol === 'https:')
+            sCookie += '; Secure';
+        try {
+            document.cookie = sCookie;
+        } catch (e) {
+            // cookies blocked: site actions are counted outside the visit
+        }
+    }
+
+    function hasVisitorCookie() {
+        try {
+            return /(?:^|;\s*)bx_analytics_vid=/.test(document.cookie || '');
+        } catch (e) {
+            return false;
+        }
+    }
+
+    if (sVisitorId)
+        setVisitorCookie(sVisitorId);
+    else if (hasVisitorCookie())
+        setVisitorCookie('');
+    var sReferrer = document.referrer;
+    var sCurrentUrl = '';
+    var iVisibleSince = 0;
+    var iEngaged = 0;
+
+    function send(aHits, bLeaving) {
+        if (bDisabled)
+            return;
+
+        // errors waiting for their batch go with whatever is sent
+        aHits = aHits.concat(takeErrors(bLeaving));
+        if (!aHits.length)
+            return;
+
+        var oPayload = {
+            platform: sPlatform,
+            language: oNavigator.language || '',
+            screen: window.screen ? window.screen.width + 'x' + window.screen.height : '',
+            hits: aHits
+        };
+        if (sReferrer)
+            oPayload.referrer = sReferrer;
+        if (sVisitorId)
+            oPayload.vid = sVisitorId;
+
+        var sBody = JSON.stringify(oPayload);
+
+        // text/plain keeps the beacon a simple request
+        if (oNavigator.sendBeacon && oNavigator.sendBeacon(sEndpoint, new Blob([sBody], {type: 'text/plain'})))
+            return;
+
+        if (window.fetch)
+            window.fetch(sEndpoint, {method: 'POST', body: sBody, keepalive: true, credentials: 'same-origin', headers: {'Content-Type': 'text/plain'}}).catch(function () {});
+    }
+
+    function engagedNow() {
+        var iTotal = iEngaged;
+        if (iVisibleSince)
+            iTotal += Date.now() - iVisibleSince;
+        return iTotal;
+    }
+
+    function leaveHit() {
+        var iMs = engagedNow();
+        iEngaged = 0;
+        iVisibleSince = document.visibilityState === 'visible' ? Date.now() : 0;
+
+        return iMs > 0 && sCurrentUrl ? {type: 'leave', url: sCurrentUrl, engaged: iMs} : null;
+    }
+
+    /**
+     * What a page left behind: engaged time and its vitals, sent together.
+     */
+    function leaveHits() {
+        var aHits = [];
+        var oLeave = leaveHit();
+        if (oLeave)
+            aHits.push(oLeave);
+
+        var oVitals = takeVitals();
+        if (oVitals)
+            aHits.push(oVitals);
+
+        return aHits;
+    }
+
+    function pageview(sNav) {
+        var sUrl = window.location.href;
+        if (sUrl === sCurrentUrl)
+            return;
+
+        var aHits = [];
+        var bFirst = !sCurrentUrl;
+        if (!bFirst) {
+            aHits = leaveHits().concat(takeErrors(true));
+            resetErrors();
+            startSoftView(sNav || 'soft');
+        }
+
+        // after the first page, the referrer of a page is the page before it
+        if (sCurrentUrl)
+            sReferrer = sCurrentUrl;
+
+        sCurrentUrl = sUrl;
+        aHits.push({type: 'pageview', url: sUrl, title: document.title});
+
+        send(aHits);
+    }
+
+    /*
+     * Errors
+     */
+
+    var MAX_ERRORS = 10;
+    var oErrors = {};
+    var aErrorKeys = [];
+    var iErrorTimer = 0;
+
+    function isSameOrigin(sUrl) {
+        try {
+            return new URL(sUrl, window.location.href).origin === window.location.origin;
+        } catch (e) {
+            return false;
+        }
+    }
+
+    /**
+     * A URL without its query string and fragment (only the page parameters `i` and `r` stay),
+     * and without the site's own origin.
+     */
+    function cleanUrl(sUrl) {
+        var oUrl;
+        try {
+            oUrl = new URL(sUrl, window.location.href);
+        } catch (e) {
+            return String(sUrl || '').replace(/[?#].*$/, '');
+        }
+
+        var aKeep = [];
+        ['i', 'r'].forEach(function (sKey) {
+            var sValue = oUrl.searchParams.get(sKey);
+            if (sValue)
+                aKeep.push(sKey + '=' + encodeURIComponent(sValue).replace(/%2F/gi, '/'));
+        });
+
+        return (oUrl.origin === window.location.origin ? '' : oUrl.origin) + oUrl.pathname + (aKeep.length ? '?' + aKeep.join('&') : '');
+    }
+
+    function pageUrl() {
+        var sPath = cleanUrl(window.location.href);
+        return window.location.origin + sPath;
+    }
+
+    /**
+     * Text with every URL in it cleaned; a stack frame's :line:column stays.
+     */
+    function cleanText(s) {
+        return String(s || '').replace(/\b(?:https?|wss?):\/\/[^\s()<>"'`]+/gi, function (sUrl) {
+            var aPosition = sUrl.match(/(:\d+){1,2}$/);
+            var sPosition = aPosition ? aPosition[0] : '';
+            return cleanUrl(sPosition ? sUrl.slice(0, -sPosition.length) : sUrl) + sPosition;
+        });
+    }
+
+    function isExtension(s) {
+        return /\b[a-z-]*extension:\/\//i.test(String(s || ''));
+    }
+
+    /**
+     * At most 30 frames, without the frames of browser extensions.
+     */
+    function cleanStack(sStack) {
+        if (typeof sStack !== 'string' || !sStack)
+            return '';
+
+        return cleanText(sStack.split('\n').filter(function (sLine) {
+            return sLine.trim() !== '' && !isExtension(sLine);
+        }).slice(0, 30).join('\n')).slice(0, 8000);
+    }
+
+    /**
+     * Message and stack of whatever was thrown.
+     */
+    function describe(mixedError, sPrefix) {
+        if (mixedError && typeof mixedError === 'object' && typeof mixedError.message === 'string')
+            return {message: (mixedError.name ? mixedError.name + ': ' : '') + mixedError.message, stack: mixedError.stack};
+
+        // an object that is no Error: its kind and keys, not its values (a form or an API
+        // payload would carry what someone typed)
+        var sText;
+        try {
+            if (Array.isArray(mixedError))
+                sText = 'Array(' + mixedError.length + ')';
+            else if (mixedError && typeof mixedError === 'object') {
+                var aKeys = Object.keys(mixedError);
+                sText = (mixedError.constructor && mixedError.constructor.name || 'Object') + '{' + aKeys.slice(0, 10).join(',') + (aKeys.length > 10 ? ',...' : '') + '}';
+            }
+            else
+                sText = String(mixedError);
+        } catch (e) {
+            sText = Object.prototype.toString.call(mixedError);
+        }
+
+        return {message: (sPrefix || '') + String(sText).slice(0, 200), stack: ''};
+    }
+
+    function captureError(sKind, oData) {
+        if (bDisabled || !bTrackErrors)
+            return;
+
+        var sStack = cleanStack(oData.stack);
+        var sMessage = cleanText(oData.message).trim().slice(0, 500);
+        var sSource = cleanText(oData.source || '');
+        if (!sMessage || (/^Script error\.?$/i.test(sMessage) && !sStack))
+            return;
+
+        // an extension's error, or one whose stack was all extension frames
+        if (isExtension(oData.source) || (oData.stack && !sStack && isExtension(oData.stack)))
+            return;
+
+        var sKey = [sKind, sMessage, sSource, oData.line || 0].join('|');
+        var oItem = oErrors[sKey];
+        if (!oItem) {
+            if (aErrorKeys.length >= MAX_ERRORS)
+                return;
+
+            aErrorKeys.push(sKey);
+            oItem = oErrors[sKey] = {
+                hit: {
+                    type: 'error',
+                    url: pageUrl(),
+                    kind: sKind,
+                    message: sMessage,
+                    source: sSource || undefined,
+                    line: oData.line || undefined,
+                    col: oData.col || undefined,
+                    stack: sStack || undefined,
+                    status: oData.status || undefined,
+                    fatal: oData.fatal ? 1 : undefined
+                },
+                pending: 0,
+                sent: false,
+                time: 0
+            };
+        }
+
+        oItem.pending = Math.min(100, oItem.pending + 1);
+        oItem.time = Date.now();
+
+        // a new error goes out within 2 seconds; its repeats go with the page's last beacon
+        if (!oItem.sent && !iErrorTimer)
+            iErrorTimer = setTimeout(function () {
+                iErrorTimer = 0;
+                send([]);
+            }, 2000);
+    }
+
+    /**
+     * Error hits to send: new errors, and the repeats of sent ones when the page is left.
+     */
+    function takeErrors(bLeaving) {
+        var aHits = [];
+        aErrorKeys.forEach(function (sKey) {
+            var oItem = oErrors[sKey];
+            if (!oItem.pending || (oItem.sent && !bLeaving))
+                return;
+
+            var oHit = {};
+            for (var sField in oItem.hit)
+                oHit[sField] = oItem.hit[sField];
+            oHit.repeats = oItem.pending;
+            oHit.age = Math.max(0, Date.now() - oItem.time);
+
+            oItem.pending = 0;
+            oItem.sent = true;
+            aHits.push(oHit);
+        });
+
+        return aHits;
+    }
+
+    function resetErrors() {
+        oErrors = {};
+        aErrorKeys = [];
+    }
+
+    function onHttpError(oEvent) {
+        var oDetail = oEvent.detail || {};
+        var oCtx = oDetail.ctx || {};
+        var iStatus = oCtx.response ? oCtx.response.status : (oDetail.xhr ? oDetail.xhr.status : 0);
+        if (!iStatus || iStatus < 500)
+            return;
+
+        var sUrl = oCtx.request && oCtx.request.action ? oCtx.request.action : (oDetail.pathInfo && oDetail.pathInfo.requestPath ? oDetail.pathInfo.requestPath : (oDetail.xhr && oDetail.xhr.responseURL) || '');
+        var sPath = cleanUrl(sUrl);
+        captureError('http', {message: 'HTTP ' + iStatus + ' ' + sPath, source: sPath, status: iStatus});
+    }
+
+    /*
+     * Web vitals
+     */
+
+    // a roll in [0, 100) from the CSPRNG getVisitorId() uses; no vitals without one
+    var oCrypto = window.crypto || window.msCrypto;
+    var fVitalsRoll = oCrypto && oCrypto.getRandomValues ? oCrypto.getRandomValues(new Uint32Array(1))[0] / 4294967296 * 100 : 100;
+    var bVitals = !bDisabled && iVitalsSample > 0 && fVitalsRoll < iVitalsSample && typeof window.PerformanceObserver === 'function' && !!oPerformance && typeof oPerformance.now === 'function';
+    var aSupported = bVitals && window.PerformanceObserver.supportedEntryTypes ? window.PerformanceObserver.supportedEntryTypes : [];
+    var oView = null;
+    var aObservers = [];
+    var iFirstHidden = document.visibilityState === 'hidden' ? 0 : Infinity;
+    var iSoftStart = 0;
+    var iLastSettle = 0;
+
+    function getNavigation() {
+        var aEntries = oPerformance && oPerformance.getEntriesByType ? oPerformance.getEntriesByType('navigation') : [];
+        return aEntries && aEntries[0] ? aEntries[0] : null;
+    }
+
+    function getActivationStart() {
+        var oNavigation = getNavigation();
+        return oNavigation && oNavigation.activationStart > 0 ? oNavigation.activationStart : 0;
+    }
+
+    function newView(sNav, iStart, bHard) {
+        oView = {nav: sNav, url: pageUrl(), start: iStart, hard: bHard, metrics: {}, lcpDone: !bHard, cls: 0, clsWindow: 0, clsFirst: 0, clsLast: 0, inp: {}, interactions: 0, waitLoad: false, sent: false};
+    }
+
+    function startSoftView(sNav) {
+        if (!bVitals)
+            return;
+
+        // the navigation's request, when htmx made one for it a moment ago
+        var iNow = oPerformance.now();
+        var iStart = iSoftStart && iNow - iSoftStart < 30000 ? iSoftStart : 0;
+        iSoftStart = 0;
+
+        newView(sNav, iStart || iNow, false);
+        if (iStart && iLastSettle > iStart)
+            oView.metrics.screen_load = iLastSettle - iStart;
+        else if (iStart)
+            oView.waitLoad = true;
+    }
+
+    function observe(sType, fCallback, oOptions) {
+        if (aSupported.indexOf(sType) === -1)
+            return false;
+
+        try {
+            var oParams = {type: sType, buffered: true};
+            for (var sKey in oOptions || {})
+                oParams[sKey] = oOptions[sKey];
+
+            var oObserver = new window.PerformanceObserver(function (oList) {
+                oList.getEntries().forEach(fCallback);
+            });
+            oObserver.observe(oParams);
+            aObservers.push({observer: oObserver, callback: fCallback});
+        } catch (e) {
+            return false;
+        }
+
+        return true;
+    }
+
+    /**
+     * Entries recorded but not yet delivered (observers are called in a later task), so the
+     * last shifts, interaction and paint count before a view's vitals are read.
+     */
+    function flushObservers() {
+        aObservers.forEach(function (oItem) {
+            try {
+                oItem.observer.takeRecords().forEach(oItem.callback);
+            } catch (e) {}
+        });
+    }
+
+    function onInteraction(oEntry) {
+        if (!oView || !oEntry.interactionId || oEntry.startTime < oView.start)
+            return;
+
+        var iId = oEntry.interactionId;
+        if (oView.inp[iId] === undefined)
+            oView.interactions++;
+        if (oView.inp[iId] === undefined || oEntry.duration > oView.inp[iId])
+            oView.inp[iId] = oEntry.duration;
+
+        // only the longest ones matter: keep the list short
+        var aIds = Object.keys(oView.inp);
+        if (aIds.length > 50) {
+            aIds.sort(function (a, b) { return oView.inp[b] - oView.inp[a]; });
+            aIds.slice(10).forEach(function (sId) { delete oView.inp[sId]; });
+        }
+    }
+
+    /**
+     * The view's vitals hit, once; null when nothing was measured.
+     */
+    function takeVitals() {
+        if (!oView || oView.sent)
+            return null;
+        flushObservers();
+        oView.sent = true;
+
+        var oMetrics = {};
+        for (var sKey in oView.metrics)
+            oMetrics[sKey] = oView.metrics[sKey];
+
+        if (oView.hard) {
+            var oNavigation = getNavigation();
+            if (oNavigation && oNavigation.responseStart > 0)
+                oMetrics.ttfb = Math.max(0, oNavigation.responseStart - getActivationStart());
+        }
+
+        if (aSupported.indexOf('layout-shift') !== -1)
+            oMetrics.cls = Math.round(oView.cls * 10000) / 10000;
+
+        // INP: the longest interaction, or a high percentile of many (one in 50 left out)
+        var aDurations = Object.keys(oView.inp).map(function (sId) { return oView.inp[sId]; }).sort(function (a, b) { return b - a; });
+        var iCount = Math.max(oView.interactions, oView.hard && oPerformance.interactionCount ? oPerformance.interactionCount : 0);
+        if (aDurations.length)
+            oMetrics.inp = aDurations[Math.min(aDurations.length - 1, Math.min(9, Math.floor(iCount / 50)))];
+
+        var bAny = false;
+        for (sKey in oMetrics) {
+            if (sKey !== 'cls')
+                oMetrics[sKey] = Math.round(oMetrics[sKey]);
+            bAny = true;
+        }
+
+        return bAny ? {type: 'vitals', url: oView.url, nav: oView.nav, metrics: oMetrics} : null;
+    }
+
+    function stopLcp() {
+        if (!oView || oView.lcpDone)
+            return;
+        // a paint before the input may not be delivered yet
+        flushObservers();
+        oView.lcpDone = true;
+    }
+
+    function startVitals() {
+        var oNavigation = getNavigation();
+        var sNav = oNavigation && oNavigation.type ? String(oNavigation.type).replace('-', '_') : 'navigate';
+        if (document.prerendering || getActivationStart() > 0)
+            sNav = 'prerender';
+
+        newView(sNav, 0, true);
+
+        observe('paint', function (oEntry) {
+            if (oView && oView.hard && oEntry.name === 'first-contentful-paint' && oEntry.startTime < iFirstHidden)
+                oView.metrics.fcp = Math.max(0, oEntry.startTime - getActivationStart());
+        });
+
+        // the largest paint until the visitor first clicks or types, or leaves
+        observe('largest-contentful-paint', function (oEntry) {
+            if (oView && oView.hard && !oView.lcpDone && oEntry.startTime < iFirstHidden)
+                oView.metrics.lcp = Math.max(0, oEntry.startTime - getActivationStart());
+        });
+        ['pointerdown', 'keydown'].forEach(function (sEvent) {
+            window.addEventListener(sEvent, stopLcp, {capture: true, passive: true});
+        });
+
+        // layout shifts in session windows (less than 1 s apart, 5 s at most): the largest counts
+        observe('layout-shift', function (oEntry) {
+            if (!oView || oEntry.hadRecentInput || oEntry.startTime < oView.start)
+                return;
+
+            if (oView.clsWindow && oEntry.startTime - oView.clsLast < 1000 && oEntry.startTime - oView.clsFirst < 5000)
+                oView.clsWindow += oEntry.value;
+            else {
+                oView.clsWindow = oEntry.value;
+                oView.clsFirst = oEntry.startTime;
+            }
+            oView.clsLast = oEntry.startTime;
+            oView.cls = Math.max(oView.cls, oView.clsWindow);
+        });
+
+        observe('event', onInteraction, {durationThreshold: 40});
+        observe('first-input', onInteraction);
+
+        // htmx navigations: from the page's request to its content settled
+        var fRequest = function (oEvent) {
+            var oCtx = oEvent.detail && oEvent.detail.ctx;
+            var oHeaders = oCtx && oCtx.request && oCtx.request.headers;
+            if (!oHeaders || !oHeaders['HX-Request-Type'] || oHeaders['HX-Request-Type'] === 'full')
+                iSoftStart = oPerformance.now();
+        };
+        var fSettle = function () {
+            iLastSettle = oPerformance.now();
+            if (oView && oView.waitLoad) {
+                oView.metrics.screen_load = iLastSettle - oView.start;
+                oView.waitLoad = false;
+            }
+        };
+        document.addEventListener('htmx:before:request', fRequest);
+        document.addEventListener('htmx:beforeRequest', fRequest);
+        document.addEventListener('htmx:after:settle', fSettle);
+        document.addEventListener('htmx:afterSettle', fSettle);
+
+        // a page restored from the back-forward cache is a new view without a load
+        window.addEventListener('pageshow', function (oEvent) {
+            if (oEvent.persisted)
+                newView('restore', oPerformance.now(), false);
+        });
+    }
+
+    window.bx_analytics_tracker = true;
+
+    /**
+     * Public API for custom events. `props` holds short scalar values.
+     */
+    window.bx_analytics = window.bx_analytics || {};
+    window.bx_analytics.track = function (sName, oProps, oOptions) {
+        if (!sName)
+            return;
+
+        send([{
+            type: 'event',
+            name: String(sName),
+            url: window.location.href,
+            props: oProps || undefined,
+            value: oOptions && typeof oOptions.value === 'number' ? oOptions.value : undefined,
+            non_interactive: oOptions && oOptions.nonInteractive ? 1 : undefined
+        }]);
+    };
+
+    /**
+     * An error the page caught itself. Options: `fatal` when the page can't go on.
+     */
+    window.bx_analytics.trackError = function (mixedError, oOptions) {
+        var oError = describe(mixedError);
+        captureError('js', {message: oError.message, stack: oError.stack, fatal: oOptions && oOptions.fatal});
+    };
+
+    if (bDisabled)
+        return;
+
+    iVisibleSince = document.visibilityState === 'visible' ? Date.now() : 0;
+
+    document.addEventListener('visibilitychange', function () {
+        if (document.visibilityState === 'hidden') {
+            iFirstHidden = Math.min(iFirstHidden, oPerformance && oPerformance.now ? oPerformance.now() : 0);
+            stopLcp();
+            send(leaveHits(), true);
+        }
+        else if (!iVisibleSince) {
+            iVisibleSince = Date.now();
+        }
+    });
+    window.addEventListener('pagehide', function () {
+        stopLcp();
+        send(leaveHits(), true);
+    });
+
+    if (bTrackErrors) {
+        // capture: script and stylesheet elements that fail to load don't bubble
+        window.addEventListener('error', function (oEvent) {
+            var oTarget = oEvent.target;
+            if (oTarget && oTarget !== window && oTarget.nodeType === 1) {
+                var sUrl = oTarget.tagName === 'SCRIPT' ? oTarget.src : (oTarget.tagName === 'LINK' && /stylesheet/i.test(oTarget.rel) ? oTarget.href : '');
+                // the site's own files: a blocked tracker or ad elsewhere is not the site's error
+                if (sUrl && isSameOrigin(sUrl))
+                    captureError('resource', {message: 'Failed to load ' + (oTarget.tagName === 'SCRIPT' ? 'script' : 'stylesheet') + ' ' + cleanUrl(sUrl), source: sUrl});
+                return;
+            }
+
+            captureError('js', {
+                message: oEvent.message || (oEvent.error && oEvent.error.message) || '',
+                source: oEvent.filename,
+                line: oEvent.lineno,
+                col: oEvent.colno,
+                stack: oEvent.error && oEvent.error.stack
+            });
+        }, true);
+
+        window.addEventListener('unhandledrejection', function (oEvent) {
+            var oError = describe(oEvent.reason, 'Non-Error promise rejection: ');
+            captureError('promise', {message: oError.message, stack: oError.stack});
+        });
+
+        document.addEventListener('htmx:response:error', onHttpError);
+        document.addEventListener('htmx:responseError', onHttpError);
+    }
+
+    if (bVitals)
+        startVitals();
+
+    // htmx navigation pushes the new URL after swapping the page in (htmx 4 and older names)
+    ['htmx:after:history:push', 'htmx:after:history:replace', 'htmx:pushedIntoHistory', 'htmx:replacedInHistory'].forEach(function (sEvent) {
+        document.addEventListener(sEvent, function () {
+            setTimeout(pageview, 0);
+        });
+    });
+    window.addEventListener('popstate', function () {
+        setTimeout(function () {
+            pageview('back_forward');
+        }, 0);
+    });
+
+    /*
+     * Outbound links and downloads
+     */
+
+    var rDownload = /\.(pdf|zip|rar|7z|gz|tgz|tar|dmg|exe|msi|pkg|apk|ipa|docx?|xlsx?|pptx?|odt|ods|csv|txt|rtf|mp3|wav|flac|mp4|mov|avi|mkv|webm|epub)$/i;
+
+    /**
+     * A click on a link to another site (`outbound_link`) or to a file (`file_download`: a
+     * `download` attribute or a file extension, in the path or in UNA's `r` and `f` parameters
+     * when permalinks are off), sent as a custom event with the link without its query string
+     * and with a download route's token masked. The beacon survives the navigation the click
+     * starts.
+     */
+    function onLinkClick(oEvent) {
+        if (oEvent.type === 'auxclick' && oEvent.button !== 1)
+            return;
+
+        var oLink = oEvent.target && oEvent.target.closest ? oEvent.target.closest('a[href]') : null;
+        if (!oLink)
+            return;
+
+        var sHref = oLink.getAttribute('href') || '';
+        if (!sHref || sHref.charAt(0) === '#' || /^(javascript|mailto|tel|sms|data|blob):/i.test(sHref))
+            return;
+
+        var oUrl;
+        try {
+            oUrl = new URL(oLink.href, window.location.href);
+        } catch (e) {
+            return;
+        }
+        if (oUrl.protocol !== 'http:' && oUrl.protocol !== 'https:')
+            return;
+
+        var sName = '';
+        if (oLink.hasAttribute('download') || rDownload.test(oUrl.pathname) || rDownload.test(oUrl.searchParams.get('r') || '') || rDownload.test(oUrl.searchParams.get('f') || ''))
+            sName = 'file_download';
+        else if (oUrl.origin !== window.location.origin)
+            sName = 'outbound_link';
+        if (!sName)
+            return;
+
+        // files/download/<token>/<id>.<ext>: the token opens the file, keep it out of reports
+        var sLinkUrl = cleanUrl(oUrl.href).replace(/(\/download\/)[^\/?#&]+\//, '$1*/');
+        send([{type: 'event', name: sName, url: window.location.href, props: {link: sLinkUrl}}]);
+    }
+
+    // on the document, so links htmx swaps in later count too
+    document.addEventListener('click', onLinkClick, true);
+    document.addEventListener('auxclick', onLinkClick, true);
+
+    pageview();
+})(window, document);
+
+/** @} */

+ 24 - 0
modules/boonex/analytics/template/api_params.html

@@ -0,0 +1,24 @@
+<bx_if:params>
+    <div class="bx-analytics-table-wrap" role="region" aria-label="__caption__" tabindex="0">
+        <table class="bx-analytics-table bx-analytics-tool-table bx-analytics-api-table">
+            <caption class="bx-analytics-sr">__caption__</caption>
+            <thead>
+                <tr>
+                    <th scope="col" class="bx-analytics-col-text">__col_param__</th>
+                    <th scope="col" class="bx-analytics-col-text">__col_accepts__</th>
+                </tr>
+            </thead>
+            <tbody>
+                <bx_repeat:params>
+                    <tr>
+                        <td class="bx-analytics-col-text"><code>__param_name__</code></td>
+                        <td class="bx-analytics-col-text bx-analytics-col-wrap">__param_accepts__</td>
+                    </tr>
+                </bx_repeat:params>
+            </tbody>
+        </table>
+    </div>
+</bx_if:params>
+<bx_if:none>
+    <p class="bx-analytics-note">__none__</p>
+</bx_if:none>

+ 14 - 0
modules/boonex/analytics/template/bars.html

@@ -0,0 +1,14 @@
+<bx_if:items>
+    <ul class="bx-analytics-rows">
+        <bx_repeat:items>
+            <li class="bx-analytics-row bx-analytics-std-platform">
+                <span class="bx-analytics-row-bar" style="width: __bar__%" aria-hidden="true"></span>
+                <span class="bx-analytics-row-label">__label__</span>
+                <span class="bx-analytics-row-value">__value__ <span class="bx-analytics-row-share">__share__%</span></span>
+            </li>
+        </bx_repeat:items>
+    </ul>
+</bx_if:items>
+<bx_if:empty>
+    <div class="bx-analytics-empty">__empty__</div>
+</bx_if:empty>

+ 1 - 0
modules/boonex/analytics/template/block.html

@@ -0,0 +1 @@
+<div class="bx-analytics bx-analytics-block-__class__" data-bx-analytics-block="__report__" data-bx-analytics-params="__params__">__content__</div>

+ 15 - 0
modules/boonex/analytics/template/bots.html

@@ -0,0 +1,15 @@
+__tabs__
+<div class="bx-analytics-panel" role="tabpanel" id="bx-analytics-panel-bots">
+    __tiles__
+    <div class="bx-analytics-chart bx-analytics-chart-sm">
+        <canvas role="img" aria-label="__chart_label__"></canvas>
+    </div>
+    <script type="application/json" class="bx-analytics-chart-data">__chart_json__</script>
+    <div class="bx-analytics-columns">
+        <div class="bx-analytics-column-wide">__bots__</div>
+        <div>
+            <h3 class="bx-analytics-subtitle">__pages_title__</h3>
+            __pages__
+        </div>
+    </div>
+</div>

+ 7 - 0
modules/boonex/analytics/template/code.html

@@ -0,0 +1,7 @@
+<div class="bx-analytics-code-block">
+    <div class="bx-analytics-code-head">
+        <span class="bx-analytics-code-label" id="__code_id__-label">__code_label__</span>
+        <button type="button" class="bx-analytics-copy-btn" data-bx-analytics-copy="__code_id__" data-bx-analytics-copied="__copied__" aria-describedby="__code_id__-label">__copy__</button>
+    </div>
+    <pre class="bx-analytics-code" id="__code_id__" tabindex="0" aria-labelledby="__code_id__-label"><code>__code__</code></pre>
+</div>

+ 21 - 0
modules/boonex/analytics/template/credits.html

@@ -0,0 +1,21 @@
+<bx_if:credits>
+    __tiles__
+    <div class="bx-analytics-chart bx-analytics-chart-sm">
+        <canvas role="img" aria-label="__chart_label__"></canvas>
+    </div>
+    <script type="application/json" class="bx-analytics-chart-data">__chart_json__</script>
+    <div class="bx-analytics-columns">
+        <div>
+            <h3 class="bx-analytics-subtitle">__spent_title__</h3>
+            __spent__
+        </div>
+        <div>
+            <h3 class="bx-analytics-subtitle">__spenders_title__</h3>
+            __spenders__
+        </div>
+    </div>
+</bx_if:credits>
+<bx_if:none>
+    <div class="bx-analytics-empty">__empty__</div>
+</bx_if:none>
+__notes__

+ 1683 - 0
modules/boonex/analytics/template/css/dashboard.css

@@ -0,0 +1,1683 @@
+/**
+ * Analytics dashboard blocks: overview, top lists and the Studio dashboard traffic block.
+ * Colours are custom properties, read by the chart script too, with a dark set for html.dark.
+ */
+.bx-analytics,
+.bx-analytics-std {
+    --bx-analytics-accent: #4f46e5;
+    --bx-analytics-accent-fill: rgba(79, 70, 229, 0.12);
+    --bx-analytics-bar: rgba(79, 70, 229, 0.09);
+    --bx-analytics-hover: rgba(15, 23, 42, 0.05);
+    --bx-analytics-border: rgba(15, 23, 42, 0.1);
+    --bx-analytics-grid: rgba(15, 23, 42, 0.08);
+    --bx-analytics-muted: #57606a;
+    --bx-analytics-good: #047857;
+    --bx-analytics-bad: #b91c1c;
+    --bx-analytics-warn: #b45309;
+    --bx-analytics-online: #16a34a;
+    --bx-analytics-c1: #4f46e5;
+    --bx-analytics-c2: #0891b2;
+    --bx-analytics-c3: #d97706;
+    --bx-analytics-c4: #db2777;
+    --bx-analytics-c5: #16a34a;
+    --bx-analytics-c6: #7c3aed;
+    --bx-analytics-c7: #dc2626;
+    --bx-analytics-c8: #64748b;
+}
+html.dark .bx-analytics,
+html.dark .bx-analytics-std {
+    --bx-analytics-accent: #a5b4fc;
+    --bx-analytics-accent-fill: rgba(165, 180, 252, 0.14);
+    --bx-analytics-bar: rgba(165, 180, 252, 0.12);
+    --bx-analytics-hover: rgba(255, 255, 255, 0.06);
+    --bx-analytics-border: rgba(255, 255, 255, 0.1);
+    --bx-analytics-grid: rgba(255, 255, 255, 0.08);
+    --bx-analytics-muted: #a8b0bb;
+    --bx-analytics-good: #6ee7b7;
+    --bx-analytics-bad: #fca5a5;
+    --bx-analytics-warn: #fcd34d;
+    --bx-analytics-online: #4ade80;
+    --bx-analytics-c1: #a5b4fc;
+    --bx-analytics-c2: #67e8f9;
+    --bx-analytics-c3: #fcd34d;
+    --bx-analytics-c4: #f9a8d4;
+    --bx-analytics-c5: #86efac;
+    --bx-analytics-c6: #c4b5fd;
+    --bx-analytics-c7: #fca5a5;
+    --bx-analytics-c8: #cbd5e1;
+}
+
+.bx-analytics {
+    position: relative;
+    transition: opacity 0.15s ease;
+}
+.bx-analytics button {
+    font: inherit;
+    color: inherit;
+    background: none;
+    border: 0;
+    margin: 0;
+    cursor: pointer;
+    text-align: inherit;
+}
+.bx-analytics button:focus-visible,
+.bx-analytics select:focus-visible,
+.bx-analytics-std a:focus-visible {
+    outline: 2px solid var(--bx-analytics-accent);
+    outline-offset: 2px;
+}
+.bx-analytics-loading {
+    opacity: 0.55;
+    pointer-events: none;
+}
+.bx-analytics-failed::after {
+    content: attr(data-bx-analytics-error);
+    display: block;
+    margin-top: 0.5rem;
+    color: var(--bx-analytics-bad);
+    font-size: 0.875rem;
+}
+.bx-analytics-sr {
+    position: absolute;
+    width: 1px;
+    height: 1px;
+    margin: -1px;
+    padding: 0;
+    overflow: hidden;
+    clip: rect(0, 0, 0, 0);
+    white-space: nowrap;
+    border: 0;
+}
+
+/* toolbar */
+.bx-analytics-toolbar {
+    display: flex;
+    flex-wrap: wrap;
+    align-items: flex-end;
+    justify-content: space-between;
+    gap: 0.75rem;
+    margin-bottom: 1rem;
+}
+.bx-analytics-controls {
+    display: flex;
+    flex-wrap: wrap;
+    align-items: flex-end;
+    gap: 0.5rem 0.75rem;
+}
+.bx-analytics-control {
+    display: flex;
+    flex-direction: column;
+    gap: 0.25rem;
+}
+.bx-analytics-control-label {
+    font-size: 0.75rem;
+    color: var(--bx-analytics-muted);
+}
+.bx-analytics-select {
+    min-width: 10rem;
+}
+.bx-analytics-chips {
+    display: flex;
+    flex-wrap: wrap;
+    gap: 0.5rem;
+}
+.bx-analytics .bx-analytics-chip {
+    display: inline-flex;
+    align-items: center;
+    gap: 0.25rem;
+    min-height: 2.25rem;
+    padding: 0 0.75rem;
+    border-radius: 999px;
+    background: var(--bx-analytics-accent-fill);
+    font-size: 0.875rem;
+}
+.bx-analytics-chip-name {
+    color: var(--bx-analytics-muted);
+}
+.bx-analytics-chip-value {
+    max-width: 16rem;
+    overflow: hidden;
+    text-overflow: ellipsis;
+    white-space: nowrap;
+}
+.bx-analytics-chip-x {
+    margin-left: 0.25rem;
+    font-size: 1.125rem;
+    line-height: 1;
+}
+
+/* online now */
+.bx-analytics-online {
+    display: inline-flex;
+    align-items: center;
+    gap: 0.5rem;
+    font-variant-numeric: tabular-nums;
+    font-weight: 600;
+}
+.bx-analytics-online-dot {
+    width: 0.625rem;
+    height: 0.625rem;
+    border-radius: 50%;
+    background: var(--bx-analytics-online);
+    box-shadow: 0 0 0 0 var(--bx-analytics-online);
+    animation: bx-analytics-pulse 2s ease-out infinite;
+}
+@keyframes bx-analytics-pulse {
+    0% { box-shadow: 0 0 0 0 rgba(22, 163, 74, 0.45); }
+    70% { box-shadow: 0 0 0 0.5rem rgba(22, 163, 74, 0); }
+    100% { box-shadow: 0 0 0 0 rgba(22, 163, 74, 0); }
+}
+@media (prefers-reduced-motion: reduce) {
+    .bx-analytics-online-dot {
+        animation: none;
+    }
+    .bx-analytics {
+        transition: none;
+    }
+}
+
+/* key figures */
+.bx-analytics-kpis {
+    display: grid;
+    grid-template-columns: repeat(2, 1fr);
+    border: 1px solid var(--bx-analytics-border);
+    border-radius: 0.75rem;
+    overflow: hidden;
+}
+.bx-analytics .bx-analytics-kpi {
+    display: flex;
+    flex-direction: column;
+    gap: 0.25rem;
+    padding: 0.75rem 1rem;
+    box-shadow: inset -1px 0 0 var(--bx-analytics-border), inset 0 -1px 0 var(--bx-analytics-border);
+}
+.bx-analytics .bx-analytics-kpi:hover {
+    background: var(--bx-analytics-hover);
+}
+.bx-analytics .bx-analytics-kpi[aria-pressed="true"] {
+    background: var(--bx-analytics-accent-fill);
+    box-shadow: inset -1px 0 0 var(--bx-analytics-border), inset 0 -3px 0 var(--bx-analytics-accent);
+}
+.bx-analytics-kpi-title {
+    font-size: 0.8125rem;
+    color: var(--bx-analytics-muted);
+}
+.bx-analytics-kpi-figures {
+    display: flex;
+    flex-wrap: wrap;
+    align-items: baseline;
+    justify-content: space-between;
+    gap: 0.25rem 0.5rem;
+}
+.bx-analytics-kpi-value {
+    font-size: 1.5rem;
+    font-weight: 600;
+    line-height: 1.2;
+    font-variant-numeric: tabular-nums;
+}
+.bx-analytics-kpi-change {
+    font-size: 0.8125rem;
+    font-variant-numeric: tabular-nums;
+    color: var(--bx-analytics-muted);
+}
+.bx-analytics-trend-good {
+    color: var(--bx-analytics-good);
+}
+.bx-analytics-trend-bad {
+    color: var(--bx-analytics-bad);
+}
+
+@media (min-width: 40rem) {
+    .bx-analytics-kpis {
+        grid-template-columns: repeat(3, 1fr);
+    }
+}
+@media (min-width: 72rem) {
+    .bx-analytics-kpis {
+        grid-template-columns: repeat(6, 1fr);
+    }
+}
+
+/* chart */
+.bx-analytics-chart {
+    position: relative;
+    height: 18rem;
+    margin-top: 1rem;
+}
+
+/* tabbed top lists */
+.bx-analytics-tabs {
+    display: flex;
+    flex-wrap: wrap;
+    gap: 0 0.25rem;
+    margin-bottom: 0.75rem;
+    box-shadow: inset 0 -1px 0 var(--bx-analytics-border);
+}
+.bx-analytics .bx-analytics-tab {
+    flex: none;
+    min-height: 2.75rem;
+    padding: 0 0.625rem;
+    font-size: 0.9375rem;
+    color: var(--bx-analytics-muted);
+    white-space: nowrap;
+}
+.bx-analytics .bx-analytics-tab:hover {
+    color: inherit;
+}
+.bx-analytics .bx-analytics-tab[aria-selected="true"] {
+    color: inherit;
+    font-weight: 600;
+    box-shadow: inset 0 -2px 0 var(--bx-analytics-accent);
+}
+.bx-analytics-list-head {
+    display: flex;
+    justify-content: space-between;
+    padding: 0 0.625rem 0.375rem;
+    font-size: 0.8125rem;
+    color: var(--bx-analytics-muted);
+}
+.bx-analytics-rows {
+    list-style: none;
+    margin: 0;
+    padding: 0;
+    display: flex;
+    flex-direction: column;
+    gap: 0.25rem;
+}
+.bx-analytics-row {
+    position: relative;
+}
+.bx-analytics .bx-analytics-row-btn,
+.bx-analytics-std-platform {
+    position: relative;
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+    gap: 0.75rem;
+    width: 100%;
+    min-height: 2.25rem;
+    padding: 0 0.625rem;
+    border-radius: 0.5rem;
+    isolation: isolate;
+}
+.bx-analytics .bx-analytics-row-btn:hover {
+    background: var(--bx-analytics-hover);
+}
+.bx-analytics-row-bar {
+    position: absolute;
+    top: 0;
+    bottom: 0;
+    left: 0;
+    z-index: -1;
+    border-radius: 0.5rem;
+    background: var(--bx-analytics-bar);
+}
+.bx-analytics-row-label {
+    min-width: 0;
+    overflow: hidden;
+    text-overflow: ellipsis;
+    white-space: nowrap;
+    font-size: 0.9375rem;
+}
+.bx-analytics-row-value {
+    flex: none;
+    font-variant-numeric: tabular-nums;
+    font-weight: 600;
+    font-size: 0.9375rem;
+}
+.bx-analytics-row-share {
+    display: inline-block;
+    min-width: 3.25rem;
+    text-align: right;
+    font-weight: 400;
+    font-size: 0.8125rem;
+    color: var(--bx-analytics-muted);
+}
+.bx-analytics-empty {
+    padding: 1.5rem 0.625rem;
+    text-align: center;
+    color: var(--bx-analytics-muted);
+}
+
+/* Studio dashboard: traffic */
+.bx-analytics-std {
+    display: flex;
+    flex-direction: column;
+    gap: 0.75rem;
+    height: 100%;
+}
+.bx-analytics-std-online-title {
+    font-weight: 400;
+    color: var(--bx-analytics-muted);
+    font-size: 0.875rem;
+}
+.bx-analytics-std-kpis {
+    display: grid;
+    grid-template-columns: 1fr 1fr;
+    gap: 0.75rem;
+}
+.bx-analytics-std-kpi {
+    display: flex;
+    flex-direction: column;
+    gap: 0.25rem;
+}
+.bx-analytics-std-spark {
+    width: 100%;
+    height: 3.5rem;
+}
+.bx-analytics-std-spark polyline {
+    fill: none;
+    stroke: var(--bx-analytics-accent);
+    stroke-width: 2;
+    stroke-linejoin: round;
+    stroke-linecap: round;
+}
+.bx-analytics-std-platforms {
+    list-style: none;
+    margin: 0;
+    padding: 0;
+    display: flex;
+    flex-direction: column;
+    gap: 0.25rem;
+}
+.bx-analytics-std-link {
+    margin-top: auto;
+    font-size: 0.875rem;
+}
+
+/* Studio: a section's blocks; with the Studio block's own 8px, content sits 16px from its edge */
+.bx-analytics-studio {
+    padding: 0.5rem;
+    display: grid;
+    /* one column when the section is narrower than a card (phones) */
+    grid-template-columns: repeat(auto-fit, minmax(min(22rem, 100%), 1fr));
+    gap: 1.5rem 2rem;
+}
+/* wide tables and code scroll inside their card instead of widening it past the padding */
+.bx-analytics-studio-card {
+    min-width: 0;
+}
+.bx-analytics-studio-wide {
+    grid-column: 1 / -1;
+}
+.bx-analytics-studio-title {
+    margin: 0 0 0.5rem;
+    font-size: 1rem;
+    font-weight: 600;
+}
+.bx-analytics-std-kpi .bx-analytics-kpi-figures {
+    justify-content: flex-start;
+}
+
+/* tables */
+.bx-analytics-table-wrap {
+    overflow-x: auto;
+    border-radius: 0.5rem;
+}
+.bx-analytics-table-wrap:focus-visible {
+    outline: 2px solid var(--bx-analytics-accent);
+    outline-offset: 2px;
+}
+.bx-analytics-table {
+    width: 100%;
+    border-collapse: separate;
+    border-spacing: 0 0.25rem;
+    font-variant-numeric: tabular-nums;
+}
+.bx-analytics-table th {
+    padding: 0 0.625rem 0.25rem;
+    font-size: 0.8125rem;
+    font-weight: 500;
+    color: var(--bx-analytics-muted);
+    text-align: right;
+    white-space: nowrap;
+}
+.bx-analytics-table td {
+    padding: 0 0.625rem;
+    height: 2.25rem;
+    font-size: 0.9375rem;
+    text-align: right;
+    white-space: nowrap;
+}
+.bx-analytics-table th.bx-analytics-col-label,
+.bx-analytics-table td.bx-analytics-col-label,
+.bx-analytics-table th.bx-analytics-col-text,
+.bx-analytics-table td.bx-analytics-col-text {
+    text-align: left;
+}
+.bx-analytics-table td.bx-analytics-col-label {
+    width: 100%;
+    padding: 0;
+}
+.bx-analytics-table tbody tr:hover td {
+    background: var(--bx-analytics-hover);
+}
+.bx-analytics-table tbody td:first-child {
+    border-radius: 0.5rem 0 0 0.5rem;
+}
+.bx-analytics-table tbody td:last-child {
+    border-radius: 0 0.5rem 0.5rem 0;
+}
+.bx-analytics-cell-label {
+    position: relative;
+    display: flex;
+    align-items: center;
+    gap: 0.5rem;
+    min-height: 2.25rem;
+    padding: 0 0.625rem;
+    isolation: isolate;
+}
+.bx-analytics .bx-analytics-cell-btn,
+.bx-analytics-cell-link,
+.bx-analytics-cell-text {
+    min-width: 0;
+    overflow: hidden;
+    text-overflow: ellipsis;
+    white-space: nowrap;
+}
+.bx-analytics .bx-analytics-cell-btn:hover,
+.bx-analytics-cell-link:hover {
+    text-decoration: underline;
+}
+.bx-analytics-cell-sub {
+    flex: none;
+    font-size: 0.8125rem;
+    color: var(--bx-analytics-muted);
+}
+.bx-analytics-pager {
+    display: flex;
+    align-items: center;
+    justify-content: flex-end;
+    gap: 0.75rem;
+    margin-top: 0.75rem;
+    font-size: 0.875rem;
+}
+.bx-analytics .bx-analytics-pager-btn {
+    min-height: 2.25rem;
+    padding: 0 0.875rem;
+    border-radius: 0.5rem;
+    box-shadow: inset 0 0 0 1px var(--bx-analytics-border);
+}
+.bx-analytics .bx-analytics-pager-btn:hover:not([disabled]) {
+    background: var(--bx-analytics-hover);
+}
+.bx-analytics .bx-analytics-pager-btn[disabled] {
+    opacity: 0.45;
+    cursor: default;
+}
+.bx-analytics-pager-info {
+    color: var(--bx-analytics-muted);
+}
+
+/* realtime */
+.bx-analytics-realtime-head {
+    display: grid;
+    grid-template-columns: minmax(12rem, auto) 1fr;
+    gap: 1rem 2rem;
+    align-items: start;
+}
+@media (max-width: 40rem) {
+    .bx-analytics-realtime-head {
+        grid-template-columns: 1fr;
+    }
+}
+.bx-analytics-realtime-online {
+    display: flex;
+    align-items: center;
+    flex-wrap: wrap;
+    gap: 0.5rem 0.75rem;
+}
+.bx-analytics-realtime-count {
+    font-size: 2.5rem;
+    font-weight: 600;
+    line-height: 1;
+    font-variant-numeric: tabular-nums;
+}
+.bx-analytics-realtime-label {
+    flex-basis: 100%;
+    font-size: 0.875rem;
+    color: var(--bx-analytics-muted);
+}
+.bx-analytics-realtime-platforms {
+    min-width: 0;
+}
+.bx-analytics-minutes {
+    display: flex;
+    align-items: flex-end;
+    gap: 2px;
+    height: 4.5rem;
+    margin-top: 1.25rem;
+    box-shadow: inset 0 -1px 0 var(--bx-analytics-border);
+}
+.bx-analytics-minute {
+    flex: 1 1 0;
+    min-height: 0;
+    border-radius: 2px 2px 0 0;
+    background: var(--bx-analytics-accent);
+    opacity: 0.85;
+}
+.bx-analytics-minutes-axis {
+    display: flex;
+    justify-content: space-between;
+    margin-top: 0.25rem;
+    font-size: 0.75rem;
+    color: var(--bx-analytics-muted);
+}
+.bx-analytics-subtitle {
+    margin: 1.25rem 0 0.5rem;
+    font-size: 0.9375rem;
+    font-weight: 600;
+}
+.bx-analytics-feed {
+    list-style: none;
+    margin: 0;
+    padding: 0;
+}
+.bx-analytics-feed-item {
+    display: grid;
+    grid-template-columns: 8.5rem minmax(0, 1fr) auto;
+    gap: 0.25rem 1rem;
+    align-items: baseline;
+    padding: 0.5rem 0;
+    box-shadow: inset 0 -1px 0 var(--bx-analytics-border);
+    font-size: 0.875rem;
+}
+@media (max-width: 40rem) {
+    .bx-analytics-feed-item {
+        grid-template-columns: 1fr;
+    }
+}
+.bx-analytics-feed-time,
+.bx-analytics-feed-meta {
+    color: var(--bx-analytics-muted);
+    white-space: nowrap;
+}
+.bx-analytics-feed-page {
+    display: flex;
+    flex-direction: column;
+    min-width: 0;
+}
+.bx-analytics-feed-title,
+.bx-analytics-feed-path {
+    overflow: hidden;
+    text-overflow: ellipsis;
+    white-space: nowrap;
+}
+.bx-analytics-feed-path {
+    font-size: 0.8125rem;
+    color: var(--bx-analytics-muted);
+}
+
+/* map */
+.bx-analytics-map-layout {
+    display: grid;
+    grid-template-columns: minmax(0, 2fr) minmax(16rem, 1fr);
+    gap: 1.5rem;
+}
+@media (max-width: 64rem) {
+    .bx-analytics-map-layout {
+        grid-template-columns: minmax(0, 1fr);
+    }
+}
+.bx-analytics-map {
+    height: 26rem;
+    border-radius: 0.75rem;
+    overflow: hidden;
+    z-index: 0;
+    background: var(--bx-analytics-hover);
+}
+html.dark .bx-analytics-map .leaflet-tile-pane {
+    filter: invert(1) hue-rotate(180deg) brightness(0.85) contrast(0.9);
+}
+.bx-analytics-note {
+    margin: 0.75rem 0 0;
+    font-size: 0.8125rem;
+    color: var(--bx-analytics-muted);
+}
+
+/* tiles: key figures that aren't chart switches */
+.bx-analytics-tiles {
+    display: grid;
+    grid-template-columns: repeat(auto-fit, minmax(10rem, 1fr));
+    border: 1px solid var(--bx-analytics-border);
+    border-radius: 0.75rem;
+    overflow: hidden;
+}
+.bx-analytics-tile {
+    display: flex;
+    flex-direction: column;
+    gap: 0.25rem;
+    padding: 0.75rem 1rem;
+    box-shadow: inset -1px 0 0 var(--bx-analytics-border), inset 0 -1px 0 var(--bx-analytics-border);
+}
+.bx-analytics-tile-sub {
+    font-size: 0.8125rem;
+    color: var(--bx-analytics-muted);
+}
+.bx-analytics-chart-sm {
+    height: 13rem;
+}
+.bx-analytics-columns {
+    display: grid;
+    grid-template-columns: minmax(0, 3fr) minmax(0, 2fr);
+    gap: 1.5rem 2rem;
+    margin-top: 1.25rem;
+}
+@media (max-width: 64rem) {
+    .bx-analytics-columns {
+        grid-template-columns: minmax(0, 1fr);
+    }
+}
+.bx-analytics-columns > div > .bx-analytics-subtitle:first-child {
+    margin-top: 0;
+}
+.bx-analytics-table .bx-analytics-cell-label {
+    min-width: 14rem;
+    max-width: 36rem;
+}
+
+/* detail views: an event, a visit, a member */
+.bx-analytics .bx-analytics-back {
+    display: inline-flex;
+    align-items: center;
+    min-height: 2.25rem;
+    margin-bottom: 0.75rem;
+    padding: 0 0.75rem 0 0.5rem;
+    border-radius: 0.5rem;
+    font-size: 0.875rem;
+    color: var(--bx-analytics-muted);
+}
+.bx-analytics .bx-analytics-back::before {
+    content: '\2190';
+    margin-right: 0.375rem;
+}
+.bx-analytics .bx-analytics-back:hover {
+    background: var(--bx-analytics-hover);
+    color: inherit;
+}
+.bx-analytics-detail-title {
+    margin: 0 0 0.75rem;
+    font-size: 1.125rem;
+    font-weight: 600;
+}
+.bx-analytics-member {
+    display: flex;
+    align-items: center;
+    gap: 0.75rem;
+    margin-bottom: 1rem;
+}
+.bx-analytics-member-thumb {
+    flex: none;
+    width: 2.5rem;
+    height: 2.5rem;
+    border-radius: 50%;
+    object-fit: cover;
+}
+.bx-analytics-member-guest {
+    display: inline-flex;
+    align-items: center;
+    justify-content: center;
+    background: var(--bx-analytics-hover);
+    color: var(--bx-analytics-muted);
+    font-weight: 600;
+}
+.bx-analytics-member-name {
+    font-size: 1.125rem;
+    font-weight: 600;
+}
+.bx-analytics-facts {
+    display: grid;
+    grid-template-columns: repeat(auto-fill, minmax(12rem, 1fr));
+    gap: 0.75rem 1.5rem;
+    margin: 0 0 0.5rem;
+}
+.bx-analytics-fact dt {
+    font-size: 0.8125rem;
+    color: var(--bx-analytics-muted);
+}
+.bx-analytics-fact dd {
+    margin: 0.125rem 0 0;
+    overflow-wrap: anywhere;
+}
+.bx-analytics-timeline {
+    list-style: none;
+    margin: 0;
+    padding: 0;
+}
+.bx-analytics-timeline-item {
+    position: relative;
+    display: grid;
+    grid-template-columns: 3.5rem 1rem minmax(0, 1fr) auto;
+    gap: 0.5rem;
+    align-items: start;
+    padding: 0.375rem 0;
+    font-size: 0.875rem;
+}
+.bx-analytics-timeline-item::before {
+    content: '';
+    position: absolute;
+    left: calc(4.5rem - 1px);
+    top: 0;
+    bottom: 0;
+    width: 2px;
+    background: var(--bx-analytics-border);
+}
+.bx-analytics-timeline-time {
+    font-variant-numeric: tabular-nums;
+    color: var(--bx-analytics-muted);
+    text-align: right;
+}
+.bx-analytics-timeline-dot {
+    position: relative;
+    z-index: 1;
+    width: 0.625rem;
+    height: 0.625rem;
+    margin: 0.3rem auto 0;
+    border-radius: 50%;
+    background: var(--bx-analytics-c1);
+}
+.bx-analytics-timeline-event .bx-analytics-timeline-dot {
+    background: var(--bx-analytics-c3);
+}
+.bx-analytics-timeline-una .bx-analytics-timeline-dot {
+    background: var(--bx-analytics-c5);
+}
+.bx-analytics-timeline-body {
+    display: flex;
+    flex-direction: column;
+    min-width: 0;
+}
+.bx-analytics-timeline-kind {
+    font-size: 0.75rem;
+    color: var(--bx-analytics-muted);
+}
+.bx-analytics-timeline-label,
+.bx-analytics-timeline-path {
+    overflow: hidden;
+    text-overflow: ellipsis;
+    white-space: nowrap;
+}
+.bx-analytics-timeline-path {
+    font-size: 0.8125rem;
+    color: var(--bx-analytics-muted);
+}
+.bx-analytics-timeline-engaged {
+    font-size: 0.8125rem;
+    color: var(--bx-analytics-muted);
+    white-space: nowrap;
+}
+
+/* journeys */
+.bx-analytics-journeys {
+    display: grid;
+    grid-template-columns: minmax(14rem, 1fr) minmax(0, 3fr);
+    gap: 1.5rem 2rem;
+    margin-bottom: 1rem;
+}
+@media (max-width: 64rem) {
+    .bx-analytics-journeys {
+        grid-template-columns: minmax(0, 1fr);
+    }
+}
+.bx-analytics-journey-start .bx-analytics-subtitle {
+    margin-top: 0;
+}
+.bx-analytics .bx-analytics-row-btn-active {
+    background: var(--bx-analytics-accent-fill);
+    font-weight: 600;
+}
+.bx-analytics-flow {
+    display: grid;
+    grid-template-columns: repeat(3, minmax(0, 1fr));
+    gap: 1rem;
+    align-items: start;
+}
+@media (max-width: 40rem) {
+    .bx-analytics-flow {
+        grid-template-columns: minmax(0, 1fr);
+    }
+}
+.bx-analytics-flow-step {
+    display: flex;
+    flex-direction: column;
+    gap: 0.375rem;
+    min-width: 0;
+}
+.bx-analytics-flow-head {
+    font-size: 0.8125rem;
+    color: var(--bx-analytics-muted);
+}
+.bx-analytics .bx-analytics-flow-node,
+.bx-analytics-flow-node {
+    position: relative;
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+    gap: 0.5rem;
+    min-height: 2.5rem;
+    padding: 0 0.625rem;
+    border-radius: 0.5rem;
+    box-shadow: inset 0 0 0 1px var(--bx-analytics-border);
+    font-size: 0.875rem;
+    isolation: isolate;
+    overflow: hidden;
+}
+.bx-analytics .bx-analytics-flow-node:not([disabled]):hover {
+    background: var(--bx-analytics-hover);
+}
+.bx-analytics .bx-analytics-flow-node[disabled] {
+    cursor: default;
+}
+.bx-analytics-flow-node-main {
+    background: var(--bx-analytics-accent-fill);
+    box-shadow: inset 0 0 0 1px var(--bx-analytics-accent);
+    font-weight: 600;
+}
+.bx-analytics .bx-analytics-flow-node-active {
+    box-shadow: inset 0 0 0 2px var(--bx-analytics-accent);
+    font-weight: 600;
+}
+.bx-analytics-flow-exit {
+    color: var(--bx-analytics-muted);
+    font-style: italic;
+}
+.bx-analytics-flow-page {
+    min-width: 0;
+    overflow: hidden;
+    text-overflow: ellipsis;
+    white-space: nowrap;
+}
+.bx-analytics-flow-count {
+    flex: none;
+    font-variant-numeric: tabular-nums;
+    color: var(--bx-analytics-muted);
+}
+
+/* retention cohorts: each cell tinted by its share */
+.bx-analytics-cohorts th[scope="row"] {
+    font-weight: 500;
+    white-space: nowrap;
+}
+.bx-analytics-cohorts td.bx-analytics-cohort-cell {
+    min-width: 3.5rem;
+    text-align: center;
+    background: color-mix(in srgb, var(--bx-analytics-accent) calc(var(--bx-analytics-heat, 0) * 85%), transparent);
+}
+.bx-analytics-cohorts tbody tr:hover td.bx-analytics-cohort-cell {
+    background: color-mix(in srgb, var(--bx-analytics-accent) calc(var(--bx-analytics-heat, 0) * 85%), transparent);
+}
+.bx-analytics-cohorts td.bx-analytics-cohort-strong {
+    color: #fff;
+}
+html.dark .bx-analytics-cohorts td.bx-analytics-cohort-strong {
+    color: #111827;
+}
+
+/* goals: one card per goal with its conversion rate */
+.bx-analytics-cards {
+    display: grid;
+    grid-template-columns: repeat(auto-fill, minmax(15rem, 1fr));
+    gap: 0.75rem;
+    list-style: none;
+    margin: 0 0 1rem;
+    padding: 0;
+}
+.bx-analytics-card {
+    display: flex;
+    flex-direction: column;
+    gap: 0.375rem;
+    min-width: 0;
+    padding: 1rem;
+    border: 1px solid var(--bx-analytics-border);
+    border-radius: 0.75rem;
+}
+.bx-analytics-card-head {
+    display: flex;
+    align-items: flex-start;
+    justify-content: space-between;
+    gap: 0.5rem;
+}
+.bx-analytics-card-title {
+    min-width: 0;
+    font-weight: 600;
+    overflow-wrap: anywhere;
+}
+.bx-analytics-card-sub {
+    font-size: 0.8125rem;
+    color: var(--bx-analytics-muted);
+    overflow-wrap: anywhere;
+}
+.bx-analytics-card-figures {
+    display: flex;
+    align-items: baseline;
+    gap: 0.5rem;
+    margin-top: 0.25rem;
+}
+.bx-analytics-card-actions {
+    display: flex;
+    flex-wrap: wrap;
+    align-items: center;
+    gap: 0.25rem 0.75rem;
+    margin-top: auto;
+    padding-top: 0.25rem;
+}
+.bx-analytics-badge {
+    flex: none;
+    padding: 0.125rem 0.5rem;
+    border-radius: 999px;
+    background: var(--bx-analytics-hover);
+    font-size: 0.75rem;
+    color: var(--bx-analytics-muted);
+    white-space: nowrap;
+}
+.bx-analytics-meter {
+    height: 0.375rem;
+    border-radius: 999px;
+    background: var(--bx-analytics-bar);
+    overflow: hidden;
+}
+.bx-analytics-meter > span {
+    display: block;
+    height: 100%;
+    border-radius: inherit;
+    background: var(--bx-analytics-accent);
+}
+.bx-analytics .bx-analytics-link-btn {
+    min-height: 2.25rem;
+    padding: 0 0.25rem;
+    color: var(--bx-analytics-accent);
+    font-size: 0.875rem;
+    text-decoration: underline;
+    text-underline-offset: 2px;
+}
+.bx-analytics .bx-analytics-danger {
+    color: var(--bx-analytics-bad);
+}
+.bx-analytics .bx-analytics-add-btn {
+    display: inline-flex;
+    align-items: center;
+    min-height: 2.25rem;
+    padding: 0 0.875rem;
+    border: 1px dashed var(--bx-analytics-accent);
+    border-radius: 0.5rem;
+    color: var(--bx-analytics-accent);
+    font-size: 0.875rem;
+    font-weight: 500;
+}
+.bx-analytics .bx-analytics-add-btn:hover,
+.bx-analytics .bx-analytics-link-btn:hover {
+    background: var(--bx-analytics-hover);
+}
+
+/* funnels: steps as bars of the first step's visits, with the drop between them */
+.bx-analytics-funnel-bar {
+    display: flex;
+    flex-wrap: wrap;
+    align-items: center;
+    justify-content: space-between;
+    gap: 0.75rem;
+    margin-bottom: 1rem;
+}
+.bx-analytics .bx-analytics-chip-active {
+    box-shadow: inset 0 0 0 2px var(--bx-analytics-accent);
+    font-weight: 600;
+}
+.bx-analytics-funnel {
+    display: flex;
+    flex-direction: column;
+    gap: 0.25rem;
+    list-style: none;
+    margin: 0;
+    padding: 0;
+}
+.bx-analytics-funnel-step {
+    display: flex;
+    flex-direction: column;
+    gap: 0.375rem;
+}
+.bx-analytics-funnel-label {
+    display: flex;
+    align-items: center;
+    gap: 0.5rem;
+    min-width: 0;
+}
+.bx-analytics-funnel-number {
+    display: inline-flex;
+    flex: none;
+    align-items: center;
+    justify-content: center;
+    width: 1.5rem;
+    height: 1.5rem;
+    border-radius: 999px;
+    background: var(--bx-analytics-accent-fill);
+    color: var(--bx-analytics-accent);
+    font-size: 0.75rem;
+    font-weight: 600;
+}
+.bx-analytics-funnel-pattern {
+    min-width: 0;
+    overflow: hidden;
+    text-overflow: ellipsis;
+    white-space: nowrap;
+    font-weight: 500;
+}
+.bx-analytics-funnel-bar-track {
+    position: relative;
+    display: flex;
+    align-items: center;
+    min-height: 2.25rem;
+    border-radius: 0.5rem;
+    background: var(--bx-analytics-hover);
+    overflow: hidden;
+}
+.bx-analytics-funnel-fill {
+    position: absolute;
+    inset: 0 auto 0 0;
+    background: color-mix(in srgb, var(--bx-analytics-accent) 32%, transparent);
+}
+.bx-analytics-funnel-figures {
+    position: relative;
+    padding: 0 0.75rem;
+    font-size: 0.875rem;
+    font-variant-numeric: tabular-nums;
+}
+.bx-analytics-funnel-drop {
+    align-self: flex-start;
+    margin: 0 0 0.5rem 2rem;
+    font-size: 0.8125rem;
+    color: var(--bx-analytics-bad);
+}
+
+/* goal and funnel forms */
+.bx-analytics-form {
+    display: flex;
+    flex-wrap: wrap;
+    align-items: flex-start;
+    gap: 0.75rem 1rem;
+    margin-top: 0.5rem;
+    padding: 1rem;
+    border: 1px solid var(--bx-analytics-border);
+    border-radius: 0.75rem;
+}
+.bx-analytics-form .bx-analytics-subtitle {
+    flex: 1 0 100%;
+    margin: 0;
+}
+.bx-analytics-field {
+    display: flex;
+    flex-direction: column;
+    gap: 0.25rem;
+    min-width: 0;
+}
+.bx-analytics-field-wide {
+    flex: 1 0 100%;
+}
+.bx-analytics-field-grow {
+    flex: 1 1 12rem;
+}
+.bx-analytics-field-row {
+    display: flex;
+    flex: 1 0 100%;
+    align-items: center;
+    gap: 0.5rem;
+}
+.bx-analytics-field-row .bx-analytics-funnel-number {
+    margin-right: 0.25rem;
+}
+.bx-analytics-hint {
+    flex: 1 0 100%;
+    font-size: 0.8125rem;
+    color: var(--bx-analytics-muted);
+}
+.bx-analytics-form-error {
+    flex: 1 0 100%;
+    margin: 0;
+    font-size: 0.875rem;
+    color: var(--bx-analytics-bad);
+}
+.bx-analytics-form-actions {
+    display: flex;
+    flex: 1 0 100%;
+    gap: 0.5rem;
+}
+
+/* revenue: splits, notes under a report, the import of past payments */
+.bx-analytics-revenue-split {
+    margin-top: 1.25rem;
+}
+.bx-analytics-notes {
+    margin-top: 1rem;
+}
+.bx-analytics-notes .bx-analytics-note {
+    margin: 0.25rem 0 0;
+}
+.bx-analytics-import {
+    display: flex;
+    flex-wrap: wrap;
+    align-items: center;
+    justify-content: space-between;
+    gap: 0.75rem 1.5rem;
+    margin-top: 1.25rem;
+    padding: 1rem;
+    border: 1px solid var(--bx-analytics-border);
+    border-radius: 0.75rem;
+}
+.bx-analytics-import-text {
+    flex: 1 1 20rem;
+    min-width: 0;
+}
+.bx-analytics-import .bx-analytics-subtitle {
+    margin-top: 0;
+}
+.bx-analytics-import-status {
+    margin: 0 0 0.25rem;
+}
+.bx-analytics .bx-analytics-import-btn {
+    display: inline-flex;
+    align-items: center;
+    min-height: 2.75rem;
+    padding: 0 1rem;
+    border: 1px solid var(--bx-analytics-accent);
+    border-radius: 0.5rem;
+    color: var(--bx-analytics-accent);
+    font-size: 0.875rem;
+    font-weight: 500;
+}
+.bx-analytics .bx-analytics-import-btn:hover {
+    background: var(--bx-analytics-hover);
+}
+.bx-analytics .bx-analytics-import-btn:disabled {
+    opacity: 0.6;
+    cursor: progress;
+}
+
+/* health: errors with a trend line per row, an error's stack, vitals cards */
+.bx-analytics-spark {
+    display: block;
+    width: 6rem;
+    height: 1.5rem;
+    color: var(--bx-analytics-bad);
+}
+.bx-analytics-col-spark {
+    width: 6.5rem;
+}
+.bx-analytics-error-title {
+    overflow-wrap: anywhere;
+}
+.bx-analytics-error-actions {
+    display: flex;
+    flex-wrap: wrap;
+    gap: 0.5rem;
+    margin: 0 0 1rem;
+}
+.bx-analytics .bx-analytics-action-btn {
+    display: inline-flex;
+    align-items: center;
+    min-height: 2.75rem;
+    padding: 0 1rem;
+    border: 1px solid var(--bx-analytics-accent);
+    border-radius: 0.5rem;
+    color: var(--bx-analytics-accent);
+    font-size: 0.875rem;
+    font-weight: 500;
+}
+.bx-analytics .bx-analytics-action-btn:hover {
+    background: var(--bx-analytics-hover);
+}
+.bx-analytics .bx-analytics-action-btn:disabled {
+    opacity: 0.6;
+    cursor: progress;
+}
+.bx-analytics-stack {
+    max-height: 24rem;
+    margin: 0 0 1rem;
+    padding: 0.75rem 1rem;
+    overflow: auto;
+    border: 1px solid var(--bx-analytics-border);
+    border-radius: 0.5rem;
+    background: var(--bx-analytics-hover);
+    font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
+    font-size: 0.8125rem;
+    line-height: 1.5;
+    white-space: pre;
+}
+.bx-analytics-stack:focus-visible {
+    outline: 2px solid var(--bx-analytics-accent);
+    outline-offset: 2px;
+}
+.bx-analytics-error-breakdowns {
+    display: grid;
+    grid-template-columns: repeat(auto-fill, minmax(18rem, 1fr));
+    gap: 0 2rem;
+}
+.bx-analytics-error-breakdowns .bx-analytics-table .bx-analytics-cell-label {
+    min-width: 8rem;
+}
+.bx-analytics-rating {
+    font-weight: 500;
+    white-space: nowrap;
+}
+.bx-analytics-rating-good {
+    color: var(--bx-analytics-good);
+}
+.bx-analytics-rating-needs-improvement {
+    color: var(--bx-analytics-warn);
+}
+.bx-analytics-rating-poor {
+    color: var(--bx-analytics-bad);
+}
+.bx-analytics-performance .bx-analytics-controls {
+    margin-bottom: 1rem;
+}
+.bx-analytics-vitals {
+    display: grid;
+    grid-template-columns: repeat(auto-fill, minmax(12rem, 1fr));
+    gap: 0.75rem;
+    list-style: none;
+    margin: 0 0 1.25rem;
+    padding: 0;
+}
+.bx-analytics .bx-analytics-vital {
+    display: flex;
+    flex-direction: column;
+    gap: 0.25rem;
+    width: 100%;
+    height: 100%;
+    padding: 0.875rem 1rem;
+    border: 1px solid var(--bx-analytics-border);
+    border-radius: 0.75rem;
+}
+.bx-analytics .bx-analytics-vital:hover {
+    background: var(--bx-analytics-hover);
+}
+.bx-analytics .bx-analytics-vital[aria-pressed="true"] {
+    border-color: var(--bx-analytics-accent);
+    box-shadow: inset 0 0 0 1px var(--bx-analytics-accent);
+}
+.bx-analytics-vital-title {
+    font-size: 0.8125rem;
+    color: var(--bx-analytics-muted);
+}
+.bx-analytics-vital-value {
+    font-size: 1.5rem;
+    font-weight: 600;
+    font-variant-numeric: tabular-nums;
+}
+.bx-analytics-vital-is-none .bx-analytics-vital-value {
+    font-size: 1rem;
+    font-weight: 500;
+    color: var(--bx-analytics-muted);
+}
+.bx-analytics-vital-rating {
+    font-size: 0.8125rem;
+    font-weight: 500;
+}
+.bx-analytics-vital-is-good .bx-analytics-vital-rating {
+    color: var(--bx-analytics-good);
+}
+.bx-analytics-vital-is-needs-improvement .bx-analytics-vital-rating {
+    color: var(--bx-analytics-warn);
+}
+.bx-analytics-vital-is-poor .bx-analytics-vital-rating {
+    color: var(--bx-analytics-bad);
+}
+.bx-analytics-vital-dist {
+    display: flex;
+    height: 0.375rem;
+    margin: 0.25rem 0;
+    overflow: hidden;
+    border-radius: 999px;
+    background: var(--bx-analytics-border);
+}
+.bx-analytics-vital-dist > span {
+    display: block;
+    height: 100%;
+}
+.bx-analytics-vital-dist .bx-analytics-vital-good {
+    background: var(--bx-analytics-good);
+}
+.bx-analytics-vital-dist .bx-analytics-vital-ni {
+    background: var(--bx-analytics-warn);
+}
+.bx-analytics-vital-dist .bx-analytics-vital-poor {
+    background: var(--bx-analytics-bad);
+}
+.bx-analytics-vital-sub {
+    font-size: 0.75rem;
+    color: var(--bx-analytics-muted);
+}
+
+/* query: what to break down and sort by, and the CSV download */
+.bx-analytics-query .bx-analytics-download {
+    display: inline-flex;
+    align-items: center;
+    gap: 0.375rem;
+    min-height: 2.25rem;
+    margin-left: auto;
+    padding: 0 0.875rem;
+    border: 1px solid var(--bx-analytics-border);
+    border-radius: 0.5rem;
+    color: var(--bx-analytics-accent);
+    font-size: 0.875rem;
+    font-weight: 500;
+    text-decoration: none;
+}
+.bx-analytics-query .bx-analytics-download::before {
+    content: '\2193';
+    content: '\2193' / '';
+}
+.bx-analytics-query .bx-analytics-download:hover {
+    background: var(--bx-analytics-hover);
+}
+.bx-analytics-query .bx-analytics-download:focus-visible {
+    outline: 2px solid var(--bx-analytics-accent);
+    outline-offset: 2px;
+}
+.bx-analytics-query .bx-analytics-chart {
+    margin-bottom: 1rem;
+}
+
+/* Studio tools: API playground, dashboards, setup */
+.bx-analytics-tool {
+    --bx-analytics-on-accent: #ffffff;
+    min-width: 0;
+}
+html.dark .bx-analytics-tool {
+    --bx-analytics-on-accent: #111827;
+}
+.bx-analytics-tool p {
+    margin: 0 0 0.75rem;
+}
+.bx-analytics-tool-intro {
+    max-width: 48rem;
+    color: var(--bx-analytics-muted);
+}
+.bx-analytics-subtitle-first {
+    margin-top: 0;
+}
+.bx-analytics .bx-analytics-tool-link {
+    color: var(--bx-analytics-accent);
+    text-decoration: underline;
+    text-underline-offset: 2px;
+}
+.bx-analytics .bx-analytics-tool-link:focus-visible {
+    outline: 2px solid var(--bx-analytics-accent);
+    outline-offset: 2px;
+}
+.bx-analytics-notices {
+    display: flex;
+    flex-direction: column;
+    gap: 0.5rem;
+    margin-bottom: 1rem;
+}
+.bx-analytics .bx-analytics-notice {
+    flex: 1 0 100%;
+    margin: 0;
+    padding: 0.625rem 0.875rem;
+    border: 1px solid var(--bx-analytics-border);
+    border-left: 3px solid var(--bx-analytics-warn);
+    border-radius: 0.5rem;
+    font-size: 0.875rem;
+}
+.bx-analytics .bx-analytics-tool-btn {
+    display: inline-flex;
+    align-items: center;
+    justify-content: center;
+    min-height: 2.75rem;
+    padding: 0 1rem;
+    border: 1px solid var(--bx-analytics-accent);
+    border-radius: 0.5rem;
+    color: var(--bx-analytics-accent);
+    font-size: 0.875rem;
+    font-weight: 500;
+    text-align: center;
+}
+.bx-analytics .bx-analytics-tool-btn:hover {
+    background: var(--bx-analytics-hover);
+}
+.bx-analytics .bx-analytics-tool-btn-primary,
+.bx-analytics .bx-analytics-tool-btn-primary:hover {
+    background: var(--bx-analytics-accent);
+    color: var(--bx-analytics-on-accent);
+}
+.bx-analytics .bx-analytics-tool-btn-primary:hover {
+    filter: brightness(1.08);
+}
+.bx-analytics .bx-analytics-tool-btn:disabled {
+    opacity: 0.6;
+    cursor: progress;
+}
+.bx-analytics .bx-analytics-copy-btn {
+    flex: none;
+    min-height: 2.25rem;
+    padding: 0 0.625rem;
+    border-radius: 0.375rem;
+    color: var(--bx-analytics-accent);
+    font-size: 0.8125rem;
+    font-weight: 500;
+}
+.bx-analytics .bx-analytics-copy-btn:hover {
+    background: var(--bx-analytics-hover);
+}
+.bx-analytics-tool .bx-analytics-form-actions {
+    flex-wrap: wrap;
+    margin-bottom: 0.75rem;
+}
+
+/* code with a copy button */
+.bx-analytics-code-block {
+    min-width: 0;
+    margin-bottom: 0.75rem;
+}
+.bx-analytics-code-head {
+    display: flex;
+    flex-wrap: wrap;
+    align-items: center;
+    gap: 0.25rem 0.75rem;
+}
+.bx-analytics-code-label {
+    font-size: 0.8125rem;
+    font-weight: 500;
+    color: var(--bx-analytics-muted);
+}
+.bx-analytics-code-head .bx-analytics-copy-btn {
+    margin-left: auto;
+}
+.bx-analytics-code {
+    max-height: 24rem;
+    margin: 0.25rem 0 0;
+    padding: 0.625rem 0.875rem;
+    overflow: auto;
+    border: 1px solid var(--bx-analytics-border);
+    border-radius: 0.5rem;
+    background: var(--bx-analytics-hover);
+    font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
+    font-size: 0.8125rem;
+    line-height: 1.5;
+    white-space: pre;
+}
+.bx-analytics-code code,
+.bx-analytics-tool td code,
+.bx-analytics-facts code,
+.bx-analytics-tags code {
+    font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
+    font-size: 0.8125rem;
+}
+.bx-analytics-code:focus-visible {
+    outline: 2px solid var(--bx-analytics-accent);
+    outline-offset: 2px;
+}
+.bx-analytics-api-output {
+    max-height: 36rem;
+}
+
+/* tables of the tools: text columns wrap where they must */
+.bx-analytics-tool-table .bx-analytics-col-wrap {
+    white-space: normal;
+    overflow-wrap: anywhere;
+}
+.bx-analytics-tool-table .bx-analytics-col-strong {
+    font-weight: 500;
+}
+
+/* API playground */
+.bx-analytics-api-fields {
+    display: flex;
+    flex-wrap: wrap;
+    align-items: flex-start;
+    gap: 0.75rem 1rem;
+    margin-bottom: 0.75rem;
+}
+.bx-analytics-api-fields .bx-analytics-field-grow {
+    flex: 1 1 20rem;
+}
+.bx-analytics .bx-analytics-code-input {
+    width: 100%;
+    min-height: 6rem;
+    box-sizing: border-box;
+    font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
+    font-size: 0.8125rem;
+    line-height: 1.5;
+    resize: vertical;
+}
+.bx-analytics .bx-analytics-code-input[aria-invalid="true"] {
+    outline: 2px solid var(--bx-analytics-bad);
+    outline-offset: 1px;
+}
+.bx-analytics-api-docs {
+    margin-bottom: 1rem;
+}
+.bx-analytics-api-docs > summary {
+    display: list-item;
+    padding: 0.5rem 0;
+    color: var(--bx-analytics-accent);
+    font-size: 0.875rem;
+    font-weight: 500;
+    cursor: pointer;
+}
+.bx-analytics-api-docs > summary:focus-visible {
+    outline: 2px solid var(--bx-analytics-accent);
+    outline-offset: 2px;
+}
+.bx-analytics-api-doc {
+    margin-bottom: 0.75rem;
+}
+.bx-analytics-api-doc[hidden] {
+    display: none;
+}
+.bx-analytics-api-calls {
+    display: flex;
+    flex-direction: column;
+}
+.bx-analytics-api-meta {
+    font-size: 0.8125rem;
+    color: var(--bx-analytics-muted);
+    font-variant-numeric: tabular-nums;
+}
+
+/* states with an icon: tracker status, checks */
+.bx-analytics-icon {
+    flex: none;
+    width: 1rem;
+    height: 1rem;
+}
+.bx-analytics .bx-analytics-state {
+    display: inline-flex;
+    align-items: center;
+    gap: 0.375rem;
+}
+.bx-analytics-state-receiving,
+.bx-analytics-state-yes {
+    color: var(--bx-analytics-good);
+}
+.bx-analytics-state-quiet {
+    color: var(--bx-analytics-warn);
+}
+.bx-analytics-state-nodata,
+.bx-analytics-state-off,
+.bx-analytics-state-note {
+    color: var(--bx-analytics-muted);
+}
+.bx-analytics-state-no {
+    color: var(--bx-analytics-bad);
+}
+.bx-analytics-checklist {
+    display: flex;
+    flex-direction: column;
+    gap: 0.375rem;
+    margin: 0 0 0.75rem;
+    padding: 0;
+    list-style: none;
+}
+.bx-analytics-checklist .bx-analytics-state {
+    align-items: flex-start;
+}
+.bx-analytics-checklist .bx-analytics-icon {
+    margin-top: 0.125rem;
+}
+
+/* setup */
+.bx-analytics-setup-result:not(:empty) {
+    margin-bottom: 1rem;
+    padding: 0.875rem 1rem;
+    border: 1px solid var(--bx-analytics-border);
+    border-radius: 0.75rem;
+}
+.bx-analytics-setup-verdict {
+    font-weight: 600;
+}
+.bx-analytics-facts {
+    display: grid;
+    grid-template-columns: repeat(auto-fit, minmax(12rem, 1fr));
+    gap: 0.75rem 1.5rem;
+    margin: 0;
+}
+.bx-analytics-fact {
+    min-width: 0;
+    margin: 0;
+}
+.bx-analytics-fact dt {
+    font-size: 0.75rem;
+    color: var(--bx-analytics-muted);
+}
+.bx-analytics-fact dd {
+    margin: 0.125rem 0 0;
+    overflow-wrap: anywhere;
+}
+.bx-analytics-setup-clients {
+    display: grid;
+    grid-template-columns: repeat(auto-fit, minmax(min(100%, 22rem), 1fr));
+    gap: 1rem;
+}
+.bx-analytics-setup-client {
+    min-width: 0;
+    padding: 1rem;
+    border: 1px solid var(--bx-analytics-border);
+    border-radius: 0.75rem;
+}
+.bx-analytics-setup-client-title {
+    margin: 0 0 0.5rem;
+    font-size: 0.9375rem;
+    font-weight: 600;
+}
+.bx-analytics-setup-client .bx-analytics-hint {
+    display: block;
+    margin: 0;
+}
+.bx-analytics-tags {
+    display: flex;
+    flex-wrap: wrap;
+    gap: 0.375rem;
+    margin: 0.25rem 0 0;
+    padding: 0;
+    list-style: none;
+}
+.bx-analytics-tags li {
+    padding: 0.125rem 0.5rem;
+    border-radius: 999px;
+    background: var(--bx-analytics-hover);
+}
+.bx-analytics-setup-events {
+    display: flex;
+    flex-direction: column;
+}

+ 45 - 0
modules/boonex/analytics/template/css/studio.css

@@ -0,0 +1,45 @@
+/**
+ * Studio: the analytics side menu in sections. Items keep Studio's own markup and styles;
+ * this adds the section headings. On phones the menu is one row of tabs, so the sections
+ * dissolve into it and the headings hide.
+ */
+.bx-analytics-std-menu-section + .bx-analytics-std-menu-section {
+    margin-top: 1rem;
+}
+.bx-analytics-std-menu-title {
+    padding: 0 0.75rem 0.375rem;
+    font-size: 0.75rem;
+    font-weight: 600;
+    line-height: 1rem;
+    letter-spacing: 0.02em;
+    text-transform: uppercase;
+    color: #4b5563;
+}
+html.dark .bx-analytics-std-menu-title {
+    color: #9ca3af;
+}
+.bx-analytics-std-menu-section > .bx-clear {
+    display: none;
+}
+
+@media (max-width: 1023px) {
+    .bx-analytics-std-menu-title {
+        padding: 0 0.25rem 0.25rem;
+        font-size: 0.625rem;
+        text-align: center;
+        overflow-wrap: anywhere;
+    }
+}
+@media (max-width: 767px) {
+    .bx-analytics-std-menu-section {
+        display: contents;
+    }
+    .bx-analytics-std-menu-title {
+        display: none;
+    }
+}
+
+/* Lucide glyphs on the menu tiles follow the tile, never the iconset's blue */
+div.bx-std-pmen-item svg.bx-analytics-std-menu-icon {
+    color: inherit;
+}

+ 43 - 0
modules/boonex/analytics/template/error.html

@@ -0,0 +1,43 @@
+<div class="bx-analytics-detail bx-analytics-error">
+    <button type="button" class="bx-analytics-back" data-bx-analytics-open="error" data-bx-analytics-value="">__back__</button>
+    <h3 class="bx-analytics-detail-title bx-analytics-error-title">__title__</h3>
+    <bx_if:actions>
+        <div class="bx-analytics-error-actions" role="group" aria-label="__actions_label__">
+            <bx_repeat:actions>
+                <button type="button" class="bx-analytics-action-btn" data-bx-analytics-action="error_status" data-bx-analytics-op="__action_op__" data-bx-analytics-id="__action_id__" data-bx-analytics-csrf="__action_csrf__">__action_title__</button>
+            </bx_repeat:actions>
+        </div>
+    </bx_if:actions>
+    <div class="bx-analytics-facts">
+        <bx_repeat:facts>
+            <dl class="bx-analytics-fact">
+                <dt>__fact_title__</dt>
+                <dd>__fact_value__</dd>
+            </dl>
+        </bx_repeat:facts>
+    </div>
+    __tiles__
+    <div class="bx-analytics-chart bx-analytics-chart-sm">
+        <canvas role="img" aria-label="__chart_label__"></canvas>
+    </div>
+    <script type="application/json" class="bx-analytics-chart-data">__chart_json__</script>
+    <h3 class="bx-analytics-subtitle">__stack_title__</h3>
+    <bx_if:stack>
+        <pre class="bx-analytics-stack" tabindex="0" role="region" aria-label="__stack_label__">__stack__</pre>
+    </bx_if:stack>
+    <bx_if:no_stack>
+        <div class="bx-analytics-empty">__no_stack__</div>
+    </bx_if:no_stack>
+    <bx_if:breakdowns>
+        <div class="bx-analytics-error-breakdowns">
+            <bx_repeat:breakdowns>
+                <div>
+                    <h3 class="bx-analytics-subtitle">__breakdown_title__</h3>
+                    __breakdown__
+                </div>
+            </bx_repeat:breakdowns>
+        </div>
+    </bx_if:breakdowns>
+    <h3 class="bx-analytics-subtitle">__latest_title__</h3>
+    __latest__
+</div>

+ 4 - 0
modules/boonex/analytics/template/error_missing.html

@@ -0,0 +1,4 @@
+<div class="bx-analytics-detail bx-analytics-error">
+    <button type="button" class="bx-analytics-back" data-bx-analytics-open="error" data-bx-analytics-value="">__back__</button>
+    <div class="bx-analytics-empty">__text__</div>
+</div>

+ 2 - 0
modules/boonex/analytics/template/errors.html

@@ -0,0 +1,2 @@
+__tiles__
+<div class="bx-analytics-errors">__table__</div>

+ 25 - 0
modules/boonex/analytics/template/event.html

@@ -0,0 +1,25 @@
+<div class="bx-analytics-detail">
+    <button type="button" class="bx-analytics-back" data-bx-analytics-open="event" data-bx-analytics-value="">__back__</button>
+    <h3 class="bx-analytics-detail-title">__title__</h3>
+    __tiles__
+    <div class="bx-analytics-chart bx-analytics-chart-sm">
+        <canvas role="img" aria-label="__chart_label__"></canvas>
+    </div>
+    <script type="application/json" class="bx-analytics-chart-data">__chart_json__</script>
+    <div class="bx-analytics-columns">
+        <div>
+            <h3 class="bx-analytics-subtitle">__platforms_title__</h3>
+            __platforms__
+        </div>
+        <div>
+            <bx_repeat:props>
+                <h3 class="bx-analytics-subtitle">__title__</h3>
+                __table__
+            </bx_repeat:props>
+            <bx_if:no_props>
+                <h3 class="bx-analytics-subtitle">__props_title__</h3>
+                <div class="bx-analytics-empty">__no_props__</div>
+            </bx_if:no_props>
+        </div>
+    </div>
+</div>

+ 35 - 0
modules/boonex/analytics/template/filters.html

@@ -0,0 +1,35 @@
+<div class="bx-analytics-toolbar">
+    <div class="bx-analytics-controls">
+        <label class="bx-analytics-control">
+            <span class="bx-analytics-control-label">__range_label__</span>
+            <select class="bx-def-font-inputs bx-form-input-select bx-analytics-select" data-bx-analytics-param="range">
+                <bx_repeat:ranges>
+                    <option value="__value__" __selected__>__title__</option>
+                </bx_repeat:ranges>
+            </select>
+        </label>
+        <label class="bx-analytics-control">
+            <span class="bx-analytics-control-label">__platform_label__</span>
+            <select class="bx-def-font-inputs bx-form-input-select bx-analytics-select" data-bx-analytics-param="platform">
+                <bx_repeat:platforms>
+                    <option value="__value__" __selected__>__title__</option>
+                </bx_repeat:platforms>
+            </select>
+        </label>
+        <bx_if:chips>
+            <div class="bx-analytics-chips">
+                <bx_repeat:chips>
+                    <button type="button" class="bx-analytics-chip" data-bx-analytics-unfilter="__name__" aria-label="__remove__">
+                        <span class="bx-analytics-chip-name">__title__:</span> <span class="bx-analytics-chip-value">__value__</span>
+                        <span class="bx-analytics-chip-x" aria-hidden="true">&times;</span>
+                    </button>
+                </bx_repeat:chips>
+            </div>
+        </bx_if:chips>
+    </div>
+    <div class="bx-analytics-online" title="__online_title__">
+        <span class="bx-analytics-online-dot" aria-hidden="true"></span>
+        <span class="bx-analytics-online-count">__online__</span>
+        <span class="bx-analytics-sr">__online_title__</span>
+    </div>
+</div>

+ 76 - 0
modules/boonex/analytics/template/funnels.html

@@ -0,0 +1,76 @@
+<div class="bx-analytics-funnels">
+    <div class="bx-analytics-funnel-bar">
+        <bx_if:list>
+            <div class="bx-analytics-chips" role="group" aria-label="__list_label__">
+                <bx_repeat:list>
+                    <button type="button" class="bx-analytics-chip __active__" data-bx-analytics-open="funnel" data-bx-analytics-value="__id__" aria-pressed="__pressed__">__name__</button>
+                </bx_repeat:list>
+            </div>
+        </bx_if:list>
+        <bx_if:manage>
+            <div class="bx-analytics-card-actions">
+                <button type="button" class="bx-analytics-add-btn" data-bx-analytics-open="edit_funnel" data-bx-analytics-value="new">__add__</button>
+                <bx_if:current>
+                    <button type="button" class="bx-analytics-link-btn" data-bx-analytics-open="edit_funnel" data-bx-analytics-value="__current_id__">__edit__</button>
+                    <button type="button" class="bx-analytics-link-btn bx-analytics-danger" data-bx-analytics-action="funnel" data-bx-analytics-op="delete" data-bx-analytics-id="__current_id__" data-bx-analytics-confirm="__confirm__" data-bx-analytics-csrf="__csrf__">__delete__</button>
+                </bx_if:current>
+            </div>
+        </bx_if:manage>
+    </div>
+    <bx_if:steps>
+        <ol class="bx-analytics-funnel">
+            <bx_repeat:steps>
+                <li class="bx-analytics-funnel-step">
+                    <div class="bx-analytics-funnel-label">
+                        <span class="bx-analytics-funnel-number">__number__</span>
+                        <span class="bx-analytics-funnel-pattern">__pattern__</span>
+                        <span class="bx-analytics-badge">__type__</span>
+                    </div>
+                    <div class="bx-analytics-funnel-bar-track">
+                        <span class="bx-analytics-funnel-fill" style="width: __rate_bar__%"></span>
+                        <span class="bx-analytics-funnel-figures">__sessions__ · __rate__</span>
+                    </div>
+                    <bx_if:drop>
+                        <span class="bx-analytics-funnel-drop">__drop__</span>
+                    </bx_if:drop>
+                </li>
+            </bx_repeat:steps>
+        </ol>
+    </bx_if:steps>
+    <bx_if:empty>
+        <div class="bx-analytics-empty">__empty__</div>
+    </bx_if:empty>
+    <bx_if:form>
+        <form class="bx-analytics-form" data-bx-analytics-form="funnel" novalidate>
+            <h3 class="bx-analytics-subtitle">__form_title__</h3>
+            <input type="hidden" name="id" value="__form_id__" />
+            <input type="hidden" name="csrf_token" value="__form_csrf__" />
+            <label class="bx-analytics-field bx-analytics-field-wide">
+                <span class="bx-analytics-control-label">__label_name__</span>
+                <input type="text" name="name" value="__form_name__" maxlength="128" required class="bx-def-font-inputs bx-form-input-text" />
+            </label>
+            <bx_repeat:rows>
+                <div class="bx-analytics-field-row">
+                    <span class="bx-analytics-funnel-number" aria-hidden="true">__row_number__</span>
+                    <label class="bx-analytics-field">
+                        <span class="bx-analytics-sr">__row_type_label__</span>
+                        <select name="step_type[]" class="bx-def-font-inputs bx-form-input-select">
+                            <option value="page" __row_page__>__row_type_page__</option>
+                            <option value="event" __row_event__>__row_type_event__</option>
+                        </select>
+                    </label>
+                    <label class="bx-analytics-field bx-analytics-field-grow">
+                        <span class="bx-analytics-sr">__row_pattern_label__</span>
+                        <input type="text" name="step_pattern[]" value="__row_pattern__" maxlength="255" class="bx-def-font-inputs bx-form-input-text" />
+                    </label>
+                </div>
+            </bx_repeat:rows>
+            <span class="bx-analytics-hint">__hint__</span>
+            <p class="bx-analytics-form-error" role="alert" hidden></p>
+            <div class="bx-analytics-form-actions">
+                <button type="submit" class="bx-btn bx-btn-primary bx-btn-small">__save__</button>
+                <button type="button" class="bx-btn bx-btn-small" data-bx-analytics-open="edit_funnel" data-bx-analytics-value="">__cancel__</button>
+            </div>
+        </form>
+    </bx_if:form>
+</div>

+ 61 - 0
modules/boonex/analytics/template/goals.html

@@ -0,0 +1,61 @@
+<div class="bx-analytics-goals">
+    <bx_if:goals>
+        <ul class="bx-analytics-cards">
+            <bx_repeat:goals>
+                <li class="bx-analytics-card">
+                    <div class="bx-analytics-card-head">
+                        <span class="bx-analytics-card-title">__name__</span>
+                        <span class="bx-analytics-badge">__type__</span>
+                    </div>
+                    <span class="bx-analytics-card-sub">__pattern__</span>
+                    <div class="bx-analytics-card-figures">
+                        <span class="bx-analytics-kpi-value">__rate__</span>
+                        <span class="bx-analytics-kpi-change bx-analytics-trend-__trend__">__change__</span>
+                    </div>
+                    <span class="bx-analytics-card-sub">__conversions__</span>
+                    <div class="bx-analytics-meter" aria-hidden="true"><span style="width: __bar__%"></span></div>
+                    <bx_if:actions>
+                        <div class="bx-analytics-card-actions">
+                            <button type="button" class="bx-analytics-link-btn" data-bx-analytics-open="edit_goal" data-bx-analytics-value="__id__">__edit__</button>
+                            <button type="button" class="bx-analytics-link-btn bx-analytics-danger" data-bx-analytics-action="goal" data-bx-analytics-op="delete" data-bx-analytics-id="__id__" data-bx-analytics-confirm="__confirm__" data-bx-analytics-csrf="__csrf__">__delete__</button>
+                        </div>
+                    </bx_if:actions>
+                </li>
+            </bx_repeat:goals>
+        </ul>
+    </bx_if:goals>
+    <bx_if:empty>
+        <div class="bx-analytics-empty">__empty__</div>
+    </bx_if:empty>
+    <bx_if:add>
+        <button type="button" class="bx-analytics-add-btn" data-bx-analytics-open="edit_goal" data-bx-analytics-value="new">__add__</button>
+    </bx_if:add>
+    <bx_if:form>
+        <form class="bx-analytics-form" data-bx-analytics-form="goal" novalidate>
+            <h3 class="bx-analytics-subtitle">__form_title__</h3>
+            <input type="hidden" name="id" value="__form_id__" />
+            <input type="hidden" name="csrf_token" value="__form_csrf__" />
+            <label class="bx-analytics-field">
+                <span class="bx-analytics-control-label">__label_name__</span>
+                <input type="text" name="name" value="__form_name__" maxlength="128" required class="bx-def-font-inputs bx-form-input-text" />
+            </label>
+            <label class="bx-analytics-field">
+                <span class="bx-analytics-control-label">__label_type__</span>
+                <select name="type" class="bx-def-font-inputs bx-form-input-select">
+                    <option value="page" __page_selected__>__type_page__</option>
+                    <option value="event" __event_selected__>__type_event__</option>
+                </select>
+            </label>
+            <label class="bx-analytics-field bx-analytics-field-wide">
+                <span class="bx-analytics-control-label">__label_pattern__</span>
+                <input type="text" name="pattern" value="__form_pattern__" maxlength="255" required class="bx-def-font-inputs bx-form-input-text" />
+                <span class="bx-analytics-hint">__hint__</span>
+            </label>
+            <p class="bx-analytics-form-error" role="alert" hidden></p>
+            <div class="bx-analytics-form-actions">
+                <button type="submit" class="bx-btn bx-btn-primary bx-btn-small">__save__</button>
+                <button type="button" class="bx-btn bx-btn-small" data-bx-analytics-open="edit_goal" data-bx-analytics-value="">__cancel__</button>
+            </div>
+        </form>
+    </bx_if:form>
+</div>

+ 1 - 0
modules/boonex/analytics/template/icon.html

@@ -0,0 +1 @@
+<svg class="bx-analytics-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><bx_if:check><circle cx="12" cy="12" r="10"/><path d="m9 12 2 2 4-4"/></bx_if:check><bx_if:clock><circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/></bx_if:clock><bx_if:minus><circle cx="12" cy="12" r="10"/><path d="M8 12h8"/></bx_if:minus><bx_if:power><path d="M12 2v10"/><path d="M18.4 6.6a9 9 0 1 1-12.77.04"/></bx_if:power><bx_if:x><circle cx="12" cy="12" r="10"/><path d="m15 9-6 6"/><path d="m9 9 6 6"/></bx_if:x><bx_if:info><circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/></bx_if:info></svg>

+ 8 - 0
modules/boonex/analytics/template/import.html

@@ -0,0 +1,8 @@
+<div class="bx-analytics-import">
+    <div class="bx-analytics-import-text">
+        <h3 class="bx-analytics-subtitle">__title__</h3>
+        <p class="bx-analytics-import-status" role="status">__text__</p>
+        <p class="bx-analytics-hint">__hint__</p>
+    </div>
+    <button type="button" class="bx-analytics-import-btn" data-bx-analytics-action="backfill" data-bx-analytics-op="__op__" data-bx-analytics-id="0" data-bx-analytics-confirm="__confirm__" data-bx-analytics-csrf="__csrf__">__button__</button>
+</div>

+ 59 - 0
modules/boonex/analytics/template/journeys.html

@@ -0,0 +1,59 @@
+<div class="bx-analytics-journeys">
+    <div class="bx-analytics-journey-start">
+        <h3 class="bx-analytics-subtitle">__entries_title__</h3>
+        <bx_if:entries>
+            <ul class="bx-analytics-rows">
+                <bx_repeat:entries>
+                    <li class="bx-analytics-row">
+                        <button type="button" class="bx-analytics-row-btn __active__" data-bx-analytics-open="jfrom" data-bx-analytics-value="__page__" aria-pressed="__pressed__">
+                            <span class="bx-analytics-row-label">__label__</span>
+                            <span class="bx-analytics-row-value">__sessions__</span>
+                        </button>
+                    </li>
+                </bx_repeat:entries>
+            </ul>
+        </bx_if:entries>
+        <bx_if:no_entries>
+            <div class="bx-analytics-empty">__empty__</div>
+        </bx_if:no_entries>
+    </div>
+    <bx_if:flow>
+        <div class="bx-analytics-flow" aria-label="__flow_label__" role="group">
+            <div class="bx-analytics-flow-step">
+                <div class="bx-analytics-flow-head">__step1__</div>
+                <div class="bx-analytics-flow-node bx-analytics-flow-node-main">
+                    <span class="bx-analytics-flow-page">__from__</span>
+                    <span class="bx-analytics-flow-count">__from_sessions__</span>
+                </div>
+            </div>
+            <div class="bx-analytics-flow-step">
+                <div class="bx-analytics-flow-head">__step2__</div>
+                <bx_repeat:next>
+                    <button type="button" class="bx-analytics-flow-node __class__" __attrs__>
+                        <span class="bx-analytics-row-bar" style="width: __share__%" aria-hidden="true"></span>
+                        <span class="bx-analytics-flow-page">__label__</span>
+                        <span class="bx-analytics-flow-count">__share__%</span>
+                    </button>
+                </bx_repeat:next>
+            </div>
+            <div class="bx-analytics-flow-step">
+                <div class="bx-analytics-flow-head">__step3__</div>
+                <bx_repeat:after>
+                    <div class="bx-analytics-flow-node __class__">
+                        <span class="bx-analytics-row-bar" style="width: __share__%" aria-hidden="true"></span>
+                        <span class="bx-analytics-flow-page">__label__</span>
+                        <span class="bx-analytics-flow-count">__share__%</span>
+                    </div>
+                </bx_repeat:after>
+                <bx_if:no_after>
+                    <p class="bx-analytics-note">__no_after__</p>
+                </bx_if:no_after>
+            </div>
+        </div>
+    </bx_if:flow>
+</div>
+<h3 class="bx-analytics-subtitle">__paths_title__</h3>
+__paths__
+<bx_if:truncated>
+    <p class="bx-analytics-note">__truncated__</p>
+</bx_if:truncated>

+ 30 - 0
modules/boonex/analytics/template/map.html

@@ -0,0 +1,30 @@
+__tabs__
+<div class="bx-analytics-panel bx-analytics-map-layout" role="tabpanel" id="bx-analytics-panel-map" __refresh__>
+    <div class="bx-analytics-map" role="img" aria-label="__map_label__"></div>
+    <script type="application/json" class="bx-analytics-map-data">__map_json__</script>
+    <div class="bx-analytics-map-side">
+        <div class="bx-analytics-list-head" aria-hidden="true">
+            <span>__column__</span>
+            <span>__visitors__</span>
+        </div>
+        <bx_if:countries>
+            <ul class="bx-analytics-rows">
+                <bx_repeat:countries>
+                    <li class="bx-analytics-row">
+                        <button type="button" class="bx-analytics-row-btn" data-bx-analytics-filter="country" data-bx-analytics-value="__value__" title="__filter_title__">
+                            <span class="bx-analytics-row-bar" style="width: __bar__%" aria-hidden="true"></span>
+                            <span class="bx-analytics-row-label">__label__</span>
+                            <span class="bx-analytics-row-value">__visitors__ <span class="bx-analytics-row-share">__share__%</span></span>
+                        </button>
+                    </li>
+                </bx_repeat:countries>
+            </ul>
+        </bx_if:countries>
+        <bx_if:empty>
+            <div class="bx-analytics-empty">__empty__</div>
+        </bx_if:empty>
+        <bx_if:unknown>
+            <p class="bx-analytics-note">__unknown__</p>
+        </bx_if:unknown>
+    </div>
+</div>

+ 8 - 0
modules/boonex/analytics/template/member.html

@@ -0,0 +1,8 @@
+<div class="bx-analytics-member">
+    <bx_if:thumb><img class="bx-analytics-member-thumb" src="__thumb__" alt="" /></bx_if:thumb>
+    <bx_if:no_thumb><span class="bx-analytics-member-thumb bx-analytics-member-guest" aria-hidden="true">?</span></bx_if:no_thumb>
+    <span class="bx-analytics-member-name">
+        <bx_if:link><a href="__url__">__name__</a></bx_if:link>
+        <bx_if:text>__name__</bx_if:text>
+    </span>
+</div>

+ 5 - 0
modules/boonex/analytics/template/notes.html

@@ -0,0 +1,5 @@
+<div class="bx-analytics-notes">
+    <bx_repeat:notes>
+        <p class="bx-analytics-note">__note__</p>
+    </bx_repeat:notes>
+</div>

+ 15 - 0
modules/boonex/analytics/template/overview.html

@@ -0,0 +1,15 @@
+<div class="bx-analytics-kpis" role="group" aria-label="__kpis_label__">
+    <bx_repeat:kpis>
+        <button type="button" class="bx-analytics-kpi" data-bx-analytics-metric="__metric__" aria-pressed="__pressed__">
+            <span class="bx-analytics-kpi-title">__title__</span>
+            <span class="bx-analytics-kpi-figures">
+                <span class="bx-analytics-kpi-value">__value__</span>
+                <span class="bx-analytics-kpi-change bx-analytics-trend-__trend__" title="__change_title__">__change__</span>
+            </span>
+        </button>
+    </bx_repeat:kpis>
+</div>
+<div class="bx-analytics-chart">
+    <canvas role="img" aria-label="__chart_label__"></canvas>
+</div>
+<script type="application/json" class="bx-analytics-chart-data">__chart_json__</script>

+ 38 - 0
modules/boonex/analytics/template/performance.html

@@ -0,0 +1,38 @@
+<div class="bx-analytics-performance">
+    <div class="bx-analytics-controls">
+        <label class="bx-analytics-control">
+            <span class="bx-analytics-control-label">__device_label__</span>
+            <select class="bx-def-font-inputs bx-form-input-select bx-analytics-select" data-bx-analytics-param="vital_device">
+                <bx_repeat:devices>
+                    <option value="__device_value__" __device_selected__>__device_title__</option>
+                </bx_repeat:devices>
+            </select>
+        </label>
+    </div>
+    <ul class="bx-analytics-vitals" aria-label="__cards_label__">
+        <bx_repeat:cards>
+            <li>
+                <button type="button" class="bx-analytics-vital bx-analytics-vital-is-__card_rating__" data-bx-analytics-open="vital" data-bx-analytics-value="__card_metric__" aria-pressed="__card_pressed__">
+                    <span class="bx-analytics-vital-title">__card_title__</span>
+                    <span class="bx-analytics-vital-value">__card_value__</span>
+                    <span class="bx-analytics-vital-rating">__card_rating_title__</span>
+                    <span class="bx-analytics-vital-dist" aria-hidden="true">
+                        <span class="bx-analytics-vital-good" style="width: __card_good__%"></span><span class="bx-analytics-vital-ni" style="width: __card_ni__%"></span><span class="bx-analytics-vital-poor" style="width: __card_poor__%"></span>
+                    </span>
+                    <span class="bx-analytics-vital-sub">__card_dist__</span>
+                    <span class="bx-analytics-vital-sub">__card_samples__</span>
+                    <span class="bx-analytics-vital-sub">__card_thresholds__</span>
+                </button>
+            </li>
+        </bx_repeat:cards>
+    </ul>
+    <h3 class="bx-analytics-subtitle">__chart_title__</h3>
+    <div class="bx-analytics-chart bx-analytics-chart-sm">
+        <canvas role="img" aria-label="__chart_label__"></canvas>
+    </div>
+    <script type="application/json" class="bx-analytics-chart-data">__chart_json__</script>
+    <h3 class="bx-analytics-subtitle">__pages_title__</h3>
+    __pages__
+    <h3 class="bx-analytics-subtitle">__platforms_title__</h3>
+    __platforms__
+</div>

+ 48 - 0
modules/boonex/analytics/template/query.html

@@ -0,0 +1,48 @@
+<div class="bx-analytics-query">
+    <div class="bx-analytics-controls bx-analytics-query-controls">
+        <label class="bx-analytics-control">
+            <span class="bx-analytics-control-label">__dimension_label__</span>
+            <select class="bx-def-font-inputs bx-form-input-select bx-analytics-select" data-bx-analytics-param="q_dim">
+                <optgroup label="__event_dims_label__">
+                    <bx_repeat:event_dims>
+                        <option value="__dim_value__" __dim_selected__>__dim_title__</option>
+                    </bx_repeat:event_dims>
+                </optgroup>
+                <optgroup label="__session_dims_label__">
+                    <bx_repeat:session_dims>
+                        <option value="__dim_value__" __dim_selected__>__dim_title__</option>
+                    </bx_repeat:session_dims>
+                </optgroup>
+            </select>
+        </label>
+        <label class="bx-analytics-control">
+            <span class="bx-analytics-control-label">__metric_label__</span>
+            <select class="bx-def-font-inputs bx-form-input-select bx-analytics-select" data-bx-analytics-param="q_metric">
+                <bx_repeat:metrics>
+                    <option value="__metric_value__" __metric_selected__>__metric_title__</option>
+                </bx_repeat:metrics>
+            </select>
+        </label>
+        <label class="bx-analytics-control">
+            <span class="bx-analytics-control-label">__dir_label__</span>
+            <select class="bx-def-font-inputs bx-form-input-select bx-analytics-select" data-bx-analytics-param="q_dir">
+                <bx_repeat:dirs>
+                    <option value="__dir_value__" __dir_selected__>__dir_title__</option>
+                </bx_repeat:dirs>
+            </select>
+        </label>
+        <bx_if:download>
+            <a class="bx-analytics-download" href="__export_url__" download>__download__</a>
+        </bx_if:download>
+    </div>
+    <bx_if:chart>
+        <div class="bx-analytics-chart">
+            <canvas role="img" aria-label="__chart_label__"></canvas>
+        </div>
+        <script type="application/json" class="bx-analytics-chart-data">__chart_json__</script>
+    </bx_if:chart>
+    __table__
+    <bx_if:truncated>
+        <p class="bx-analytics-note">__truncated__</p>
+    </bx_if:truncated>
+</div>

+ 1 - 0
modules/boonex/analytics/template/rating.html

@@ -0,0 +1 @@
+<span class="bx-analytics-rating bx-analytics-rating-__rating__">__title__</span>

+ 44 - 0
modules/boonex/analytics/template/realtime.html

@@ -0,0 +1,44 @@
+<div class="bx-analytics-realtime" data-bx-analytics-refresh="15">
+    <div class="bx-analytics-realtime-head">
+        <div class="bx-analytics-realtime-online" role="status">
+            <span class="bx-analytics-online-dot" aria-hidden="true"></span>
+            <span class="bx-analytics-realtime-count">__online__</span>
+            <span class="bx-analytics-realtime-label">__online_title__</span>
+        </div>
+        <bx_if:platforms>
+            <ul class="bx-analytics-rows bx-analytics-realtime-platforms">
+                <bx_repeat:platforms>
+                    <li class="bx-analytics-row bx-analytics-std-platform">
+                        <span class="bx-analytics-row-bar" style="width: __share__%" aria-hidden="true"></span>
+                        <span class="bx-analytics-row-label">__label__</span>
+                        <span class="bx-analytics-row-value">__visitors__</span>
+                    </li>
+                </bx_repeat:platforms>
+            </ul>
+        </bx_if:platforms>
+    </div>
+    <div class="bx-analytics-minutes" role="img" aria-label="__minutes_label__">
+        <bx_repeat:minutes>
+            <span class="bx-analytics-minute" style="height: __height__%" title="__title__"></span>
+        </bx_repeat:minutes>
+    </div>
+    <div class="bx-analytics-minutes-axis" aria-hidden="true"><span>__axis_start__</span><span>__axis_end__</span></div>
+    <h3 class="bx-analytics-subtitle">__recent_title__</h3>
+    <bx_if:recent>
+        <ul class="bx-analytics-feed">
+            <bx_repeat:recent>
+                <li class="bx-analytics-feed-item">
+                    <span class="bx-analytics-feed-time">__time__</span>
+                    <span class="bx-analytics-feed-page">
+                        <span class="bx-analytics-feed-title">__title__</span>
+                        <span class="bx-analytics-feed-path">__path__</span>
+                    </span>
+                    <span class="bx-analytics-feed-meta">__meta__</span>
+                </li>
+            </bx_repeat:recent>
+        </ul>
+    </bx_if:recent>
+    <bx_if:empty>
+        <div class="bx-analytics-empty">__empty__</div>
+    </bx_if:empty>
+</div>

+ 30 - 0
modules/boonex/analytics/template/retention.html

@@ -0,0 +1,30 @@
+__tabs__
+<div class="bx-analytics-panel" role="tabpanel" id="bx-analytics-panel-retention">
+    <bx_if:cookieless>
+        <p class="bx-analytics-note">__cookieless__</p>
+    </bx_if:cookieless>
+    <div class="bx-analytics-table-wrap" role="region" aria-label="__caption__" tabindex="0">
+        <table class="bx-analytics-table bx-analytics-cohorts">
+            <thead>
+                <tr>
+                    <th scope="col" class="bx-analytics-col-text">__cohort_title__</th>
+                    <th scope="col">__size_title__</th>
+                    <bx_repeat:weeks>
+                        <th scope="col">__week__</th>
+                    </bx_repeat:weeks>
+                </tr>
+            </thead>
+            <tbody>
+                <bx_repeat:rows>
+                    <tr>
+                        <th scope="row" class="bx-analytics-col-text">__start__</th>
+                        <td>__size__</td>
+                        <bx_repeat:cells>
+                            <td class="bx-analytics-cohort-cell __strong__" style="--bx-analytics-heat: __heat__">__percent__</td>
+                        </bx_repeat:cells>
+                    </tr>
+                </bx_repeat:rows>
+            </tbody>
+        </table>
+    </div>
+</div>

+ 8 - 0
modules/boonex/analytics/template/revenue.html

@@ -0,0 +1,8 @@
+__tiles__
+<div class="bx-analytics-chart bx-analytics-chart-sm">
+    <canvas role="img" aria-label="__chart_label__"></canvas>
+</div>
+<script type="application/json" class="bx-analytics-chart-data">__chart_json__</script>
+<div class="bx-analytics-revenue-split">__split__</div>
+__notes__
+__import__

+ 16 - 0
modules/boonex/analytics/template/sales.html

@@ -0,0 +1,16 @@
+__tiles__
+<div class="bx-analytics-columns">
+    <div>
+        <h3 class="bx-analytics-subtitle">__items_title__</h3>
+        __items__
+    </div>
+    <div>
+        <h3 class="bx-analytics-subtitle">__sellers_title__</h3>
+        __sellers__
+    </div>
+</div>
+<bx_if:invoices>
+    <h3 class="bx-analytics-subtitle">__invoices_title__</h3>
+    __invoices__
+</bx_if:invoices>
+__notes__

+ 32 - 0
modules/boonex/analytics/template/session.html

@@ -0,0 +1,32 @@
+<div class="bx-analytics-detail">
+    <button type="button" class="bx-analytics-back" data-bx-analytics-open="session" data-bx-analytics-value="">__back__</button>
+    __member__
+    <div class="bx-analytics-facts">
+        <bx_repeat:facts>
+            <dl class="bx-analytics-fact">
+                <dt>__title__</dt>
+                <dd>__value__</dd>
+            </dl>
+        </bx_repeat:facts>
+    </div>
+    <h3 class="bx-analytics-subtitle">__timeline_title__</h3>
+    <bx_if:timeline>
+        <ol class="bx-analytics-timeline">
+            <bx_repeat:timeline>
+                <li class="bx-analytics-timeline-item bx-analytics-timeline-__type__">
+                    <span class="bx-analytics-timeline-time">__offset__</span>
+                    <span class="bx-analytics-timeline-dot" aria-hidden="true"></span>
+                    <span class="bx-analytics-timeline-body">
+                        <span class="bx-analytics-timeline-kind">__kind__</span>
+                        <span class="bx-analytics-timeline-label">__label__</span>
+                        <bx_if:path><span class="bx-analytics-timeline-path">__path__</span></bx_if:path>
+                    </span>
+                    <span class="bx-analytics-timeline-engaged">__engaged__</span>
+                </li>
+            </bx_repeat:timeline>
+        </ol>
+    </bx_if:timeline>
+    <bx_if:empty>
+        <div class="bx-analytics-empty">__empty__</div>
+    </bx_if:empty>
+</div>

+ 28 - 0
modules/boonex/analytics/template/setup_check.html

@@ -0,0 +1,28 @@
+<div class="bx-analytics-setup-check">
+    <p class="bx-analytics-state bx-analytics-state-__verdict_state__ bx-analytics-setup-verdict">__verdict_icon__<span>__verdict__</span></p>
+    <bx_if:reasons>
+        <ul class="bx-analytics-checklist">
+            <bx_repeat:reasons>
+                <li class="bx-analytics-state bx-analytics-state-__reason_kind__">__reason_icon__<span>__reason__</span></li>
+            </bx_repeat:reasons>
+        </ul>
+    </bx_if:reasons>
+    <dl class="bx-analytics-facts">
+        <div class="bx-analytics-fact">
+            <dt>__ip_label__</dt>
+            <dd><code>__ip__</code></dd>
+        </div>
+        <div class="bx-analytics-fact">
+            <dt>__remote_label__</dt>
+            <dd><code>__remote__</code></dd>
+        </div>
+        <div class="bx-analytics-fact">
+            <dt>__geo_label__</dt>
+            <dd>__geo__</dd>
+        </div>
+        <div class="bx-analytics-fact">
+            <dt>__identity_label__</dt>
+            <dd>__identity__</dd>
+        </div>
+    </dl>
+</div>

+ 29 - 0
modules/boonex/analytics/template/setup_status.html

@@ -0,0 +1,29 @@
+<div class="bx-analytics-table-wrap" role="region" aria-label="__caption__" tabindex="0">
+    <table class="bx-analytics-table bx-analytics-tool-table">
+        <caption class="bx-analytics-sr">__caption__</caption>
+        <thead>
+            <tr>
+                <th scope="col" class="bx-analytics-col-text">__col_platform__</th>
+                <th scope="col" class="bx-analytics-col-text">__col_status__</th>
+                <th scope="col" class="bx-analytics-col-text">__col_last__</th>
+                <th scope="col">__col_hits__</th>
+                <th scope="col">__col_visitors__</th>
+                <th scope="col">__col_sessions__</th>
+                <th scope="col" class="bx-analytics-col-text">__col_version__</th>
+            </tr>
+        </thead>
+        <tbody>
+            <bx_repeat:rows>
+                <tr>
+                    <td class="bx-analytics-col-text bx-analytics-col-strong">__platform_title__</td>
+                    <td class="bx-analytics-col-text"><span class="bx-analytics-state bx-analytics-state-__status__">__status_icon__<span>__status_title__</span></span></td>
+                    <td class="bx-analytics-col-text">__last__</td>
+                    <td>__hits__</td>
+                    <td>__visitors__</td>
+                    <td>__sessions__</td>
+                    <td class="bx-analytics-col-text">__version__</td>
+                </tr>
+            </bx_repeat:rows>
+        </tbody>
+    </table>
+</div>

+ 1 - 0
modules/boonex/analytics/template/spark.html

@@ -0,0 +1 @@
+<svg class="bx-analytics-spark" viewBox="0 0 100 24" preserveAspectRatio="none" aria-hidden="true" focusable="false"><polyline points="__points__" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round" vector-effect="non-scaling-stroke" /></svg>

+ 55 - 0
modules/boonex/analytics/template/studio_api.html

@@ -0,0 +1,55 @@
+<div class="bx-analytics bx-analytics-tool bx-analytics-api">
+    <bx_if:notices>
+        <div class="bx-analytics-notices">
+            <bx_repeat:notices>
+                <p class="bx-analytics-notice">__notice__</p>
+            </bx_repeat:notices>
+        </div>
+    </bx_if:notices>
+    <p class="bx-analytics-tool-intro">__intro__</p>
+    <form class="bx-analytics-api-form" data-bx-analytics-api-form novalidate>
+        <input type="hidden" name="csrf_token" value="__csrf__" />
+        <div class="bx-analytics-api-fields">
+            <label class="bx-analytics-field">
+                <span class="bx-analytics-control-label">__report_label__</span>
+                <select name="report" class="bx-def-font-inputs bx-form-input-select bx-analytics-select" data-bx-analytics-api-report>
+                    <bx_repeat:reports>
+                        <option value="__report_value__" __report_selected__>__report_title__</option>
+                    </bx_repeat:reports>
+                </select>
+            </label>
+            <label class="bx-analytics-field bx-analytics-field-grow">
+                <span class="bx-analytics-control-label">__params_label__</span>
+                <textarea name="params" rows="4" spellcheck="false" autocapitalize="off" autocomplete="off" class="bx-def-font-inputs bx-form-input-textarea bx-analytics-code-input" data-bx-analytics-api-params aria-describedby="bx-analytics-api-params-hint">__params_default__</textarea>
+                <span class="bx-analytics-hint" id="bx-analytics-api-params-hint">__params_hint__</span>
+            </label>
+        </div>
+        <details class="bx-analytics-api-docs">
+            <summary>__docs_label__</summary>
+            <bx_repeat:docs>
+                <div class="bx-analytics-api-doc" data-bx-analytics-api-doc="__doc_report__" __doc_hidden__>__doc_table__</div>
+            </bx_repeat:docs>
+            __common_table__
+        </details>
+        <div class="bx-analytics-api-calls">
+            __code_srv__
+            __code_url__
+            __code_neo__
+        </div>
+        <p class="bx-analytics-form-error" role="alert" hidden></p>
+        <div class="bx-analytics-form-actions">
+            <button type="submit" class="bx-analytics-tool-btn bx-analytics-tool-btn-primary">__run__</button>
+        </div>
+    </form>
+    <div class="bx-analytics-api-result" data-bx-analytics-api-result hidden>
+        <div class="bx-analytics-code-block">
+            <div class="bx-analytics-code-head">
+                <span class="bx-analytics-code-label" id="bx-analytics-api-output-label">__response_label__</span>
+                <span class="bx-analytics-api-meta" data-bx-analytics-api-meta role="status"></span>
+                <button type="button" class="bx-analytics-copy-btn" data-bx-analytics-copy="bx-analytics-api-output" data-bx-analytics-copied="__copied__" aria-describedby="bx-analytics-api-output-label">__copy__</button>
+            </div>
+            <pre class="bx-analytics-code bx-analytics-api-output" id="bx-analytics-api-output" tabindex="0" aria-labelledby="bx-analytics-api-output-label"><code data-bx-analytics-api-output></code></pre>
+        </div>
+    </div>
+    <script type="application/json" data-bx-analytics-api-catalog>__catalog_json__</script>
+</div>

+ 46 - 0
modules/boonex/analytics/template/studio_dashboards.html

@@ -0,0 +1,46 @@
+<div class="bx-analytics bx-analytics-tool bx-analytics-dashboards">
+    <p class="bx-analytics-tool-intro">__intro__</p>
+    <bx_if:pages>
+        <div class="bx-analytics-table-wrap" role="region" aria-label="__caption__" tabindex="0">
+            <table class="bx-analytics-table bx-analytics-tool-table">
+                <caption class="bx-analytics-sr">__caption__</caption>
+                <thead>
+                    <tr>
+                        <th scope="col" class="bx-analytics-col-text">__col_page__</th>
+                        <th scope="col">__col_blocks__</th>
+                        <th scope="col">__col_neo__</th>
+                        <th scope="col" class="bx-analytics-col-text"><span class="bx-analytics-sr">__col_actions__</span></th>
+                    </tr>
+                </thead>
+                <tbody>
+                    <bx_repeat:pages>
+                        <tr>
+                            <td class="bx-analytics-col-text bx-analytics-col-wrap"><a class="bx-analytics-tool-link" href="__view_url__">__page_title__</a></td>
+                            <td>__blocks__</td>
+                            <td>__api_blocks__</td>
+                            <td class="bx-analytics-col-text"><a class="bx-analytics-tool-link" href="__edit_url__" aria-label="__edit_label__">__edit__</a></td>
+                        </tr>
+                    </bx_repeat:pages>
+                </tbody>
+            </table>
+        </div>
+    </bx_if:pages>
+    <bx_if:empty>
+        <div class="bx-analytics-empty">__empty__</div>
+    </bx_if:empty>
+    <p class="bx-analytics-note">__neo_note__</p>
+    <form class="bx-analytics-form" data-bx-analytics-dashboard-form novalidate>
+        <h3 class="bx-analytics-subtitle">__create_title__</h3>
+        <input type="hidden" name="csrf_token" value="__csrf__" />
+        <label class="bx-analytics-field bx-analytics-field-grow">
+            <span class="bx-analytics-control-label">__label_title__</span>
+            <input type="text" name="title" maxlength="100" required class="bx-def-font-inputs bx-form-input-text" aria-describedby="bx-analytics-dash-hint bx-analytics-dash-warning" />
+        </label>
+        <p class="bx-analytics-hint" id="bx-analytics-dash-hint">__create_hint__</p>
+        <p class="bx-analytics-notice" id="bx-analytics-dash-warning">__disable_warning__</p>
+        <p class="bx-analytics-form-error" role="alert" hidden></p>
+        <div class="bx-analytics-form-actions">
+            <button type="submit" class="bx-analytics-tool-btn bx-analytics-tool-btn-primary">__create__</button>
+        </div>
+    </form>
+</div>

+ 8 - 0
modules/boonex/analytics/template/studio_menu.html

@@ -0,0 +1,8 @@
+<bx_repeat:sections>
+    <div class="bx-analytics-std-menu-section bx-analytics-std-menu-__name__" __attrs__>
+        <bx_if:title>
+            <div class="bx-analytics-std-menu-title" id="bx-analytics-std-menu-__name__">__title__</div>
+        </bx_if:title>
+        __menu__
+    </div>
+</bx_repeat:sections>

+ 10 - 0
modules/boonex/analytics/template/studio_section.html

@@ -0,0 +1,10 @@
+<div class="bx-analytics-studio">
+    <bx_repeat:cards>
+        <section class="bx-analytics-studio-card __class__">
+            <bx_if:title>
+                <h2 class="bx-analytics-studio-title">__title__</h2>
+            </bx_if:title>
+            __content__
+        </section>
+    </bx_repeat:cards>
+</div>

+ 60 - 0
modules/boonex/analytics/template/studio_setup.html

@@ -0,0 +1,60 @@
+<div class="bx-analytics bx-analytics-tool bx-analytics-setup">
+    <bx_if:tracking_off>
+        <p class="bx-analytics-notice">__tracking_off__ <a class="bx-analytics-tool-link" href="__settings_url__">__settings__</a></p>
+    </bx_if:tracking_off>
+    <h3 class="bx-analytics-subtitle bx-analytics-subtitle-first">__status_title__</h3>
+    <div data-bx-analytics-setup-status>__status__</div>
+
+    <h3 class="bx-analytics-subtitle">__check_title__</h3>
+    <p class="bx-analytics-tool-intro">__check_hint__</p>
+    <div class="bx-analytics-form-actions">
+        <button type="button" class="bx-analytics-tool-btn bx-analytics-tool-btn-primary" data-bx-analytics-setup="check" data-bx-analytics-csrf="__csrf__">__check__</button>
+        <button type="button" class="bx-analytics-tool-btn" data-bx-analytics-setup="test_event" data-bx-analytics-csrf="__csrf__" aria-describedby="bx-analytics-setup-test-warning">__test__</button>
+    </div>
+    <p class="bx-analytics-note" id="bx-analytics-setup-test-warning">__test_warning__</p>
+    <div class="bx-analytics-setup-result" data-bx-analytics-setup-result role="status"></div>
+
+    <h3 class="bx-analytics-subtitle">__install_title__</h3>
+    <div class="bx-analytics-setup-clients">
+        <section class="bx-analytics-setup-client">
+            <h4 class="bx-analytics-setup-client-title">__core_web_title__</h4>
+            <p>__core_web_text__</p>
+            <p class="bx-analytics-state bx-analytics-state-__injection_state__">__injection_icon__<span>__injection__</span></p>
+            <p class="bx-analytics-hint">__core_web_exclusions__</p>
+        </section>
+        <section class="bx-analytics-setup-client">
+            <h4 class="bx-analytics-setup-client-title">__neo_title__</h4>
+            <p>__neo_text__</p>
+            <ul class="bx-analytics-checklist">
+                <bx_repeat:neo_checks>
+                    <li class="bx-analytics-state bx-analytics-state-__check_state__">__check_icon__<span>__check_text__</span></li>
+                </bx_repeat:neo_checks>
+            </ul>
+        </section>
+        <section class="bx-analytics-setup-client">
+            <h4 class="bx-analytics-setup-client-title">__api_title__</h4>
+            <p>__api_text__</p>
+            __code_curl__
+        </section>
+        <section class="bx-analytics-setup-client">
+            <h4 class="bx-analytics-setup-client-title">__core_title__</h4>
+            <p>__core_text__</p>
+            <p class="bx-analytics-state bx-analytics-state-__actions_state__">__actions_icon__<span>__actions__</span></p>
+            <p class="bx-analytics-control-label">__alerts_label__</p>
+            <ul class="bx-analytics-tags">
+                <bx_repeat:alerts>
+                    <li><code>__alert__</code></li>
+                </bx_repeat:alerts>
+            </ul>
+        </section>
+    </div>
+
+    <h3 class="bx-analytics-subtitle">__events_title__</h3>
+    <p class="bx-analytics-tool-intro">__events_hint__</p>
+    <div class="bx-analytics-setup-events">
+        __code_classic__
+        __code_neo__
+        __code_server__
+    </div>
+    <p class="bx-analytics-note">__events_warning__</p>
+</div>

+ 36 - 0
modules/boonex/analytics/template/studio_traffic.html

@@ -0,0 +1,36 @@
+<div class="bx-analytics-std">
+    <div class="bx-analytics-online" title="__online_title__">
+        <span class="bx-analytics-online-dot" aria-hidden="true"></span>
+        <span class="bx-analytics-online-count">__online__</span>
+        <span class="bx-analytics-std-online-title">__online_title__</span>
+    </div>
+    <div class="bx-analytics-std-kpis">
+        <bx_repeat:kpis>
+            <div class="bx-analytics-std-kpi">
+                <span class="bx-analytics-kpi-title">__title__</span>
+                <span class="bx-analytics-kpi-figures">
+                    <span class="bx-analytics-kpi-value">__value__</span>
+                    <span class="bx-analytics-kpi-change bx-analytics-trend-__trend__">__change__</span>
+                </span>
+            </div>
+        </bx_repeat:kpis>
+    </div>
+    <svg class="bx-analytics-std-spark" viewBox="0 0 100 40" preserveAspectRatio="none" role="img" aria-label="__chart_label__">
+        <polyline points="__points__" vector-effect="non-scaling-stroke" />
+    </svg>
+    <bx_if:platforms>
+        <ul class="bx-analytics-rows bx-analytics-std-platforms">
+            <bx_repeat:platforms>
+                <li class="bx-analytics-row bx-analytics-std-platform">
+                    <span class="bx-analytics-row-bar" style="width: __bar__%" aria-hidden="true"></span>
+                    <span class="bx-analytics-row-label">__label__</span>
+                    <span class="bx-analytics-row-value">__share__%</span>
+                </li>
+            </bx_repeat:platforms>
+        </ul>
+    </bx_if:platforms>
+    <bx_if:empty>
+        <div class="bx-analytics-empty">__empty__</div>
+    </bx_if:empty>
+    <a class="bx-analytics-std-link" href="__link__">__link_title__</a>
+</div>

+ 21 - 0
modules/boonex/analytics/template/subscriptions.html

@@ -0,0 +1,21 @@
+__tiles__
+<div class="bx-analytics-chart bx-analytics-chart-sm">
+    <canvas role="img" aria-label="__chart_label__"></canvas>
+</div>
+<script type="application/json" class="bx-analytics-chart-data">__chart_json__</script>
+<h3 class="bx-analytics-subtitle">__movement_title__</h3>
+<div class="bx-analytics-chart bx-analytics-chart-sm">
+    <canvas role="img" aria-label="__movement_label__"></canvas>
+</div>
+<script type="application/json" class="bx-analytics-chart-data">__movement_json__</script>
+<div class="bx-analytics-columns">
+    <div>
+        <h3 class="bx-analytics-subtitle">__plans_title__</h3>
+        __plans__
+    </div>
+    <div>
+        <h3 class="bx-analytics-subtitle">__reasons_title__</h3>
+        __reasons__
+    </div>
+</div>
+__notes__

+ 57 - 0
modules/boonex/analytics/template/table.html

@@ -0,0 +1,57 @@
+__tabs__
+<div class="bx-analytics-panel" __panel_attrs__>
+    <bx_if:rows>
+        <div class="bx-analytics-table-wrap" role="region" aria-label="__caption__" tabindex="0">
+            <table class="bx-analytics-table">
+                <thead>
+                    <tr>
+                        <bx_repeat:columns>
+                            <th scope="col" class="bx-analytics-col-__type__" __sort__>__title__</th>
+                        </bx_repeat:columns>
+                    </tr>
+                </thead>
+                <tbody>
+                    <bx_repeat:rows>
+                        <tr>
+                            <bx_repeat:cells>
+                                <td class="bx-analytics-col-__type__">
+                                    <bx_if:label>
+                                        <div class="bx-analytics-cell-label">
+                                            <span class="bx-analytics-row-bar" style="width: __bar__%" aria-hidden="true"></span>
+                                            <bx_if:filter>
+                                                <button type="button" class="bx-analytics-cell-btn" data-bx-analytics-filter="__filter_dim__" data-bx-analytics-value="__filter_value__" title="__filter_title__">__label__</button>
+                                            </bx_if:filter>
+                                            <bx_if:link>
+                                                <a class="bx-analytics-cell-link" href="__link_href__">__label__</a>
+                                            </bx_if:link>
+                                            <bx_if:open>
+                                                <button type="button" class="bx-analytics-cell-btn" data-bx-analytics-open="__open_param__" data-bx-analytics-value="__open_value__">__label__</button>
+                                            </bx_if:open>
+                                            <bx_if:text>
+                                                <span class="bx-analytics-cell-text">__label__</span>
+                                            </bx_if:text>
+                                            <bx_if:sub>
+                                                <span class="bx-analytics-cell-sub">__sub__</span>
+                                            </bx_if:sub>
+                                        </div>
+                                    </bx_if:label>
+                                    <bx_if:value>__cell_value__</bx_if:value>
+                                </td>
+                            </bx_repeat:cells>
+                        </tr>
+                    </bx_repeat:rows>
+                </tbody>
+            </table>
+        </div>
+    </bx_if:rows>
+    <bx_if:empty>
+        <div class="bx-analytics-empty">__empty__</div>
+    </bx_if:empty>
+    <bx_if:pager>
+        <div class="bx-analytics-pager">
+            <button type="button" class="bx-analytics-pager-btn" data-bx-analytics-page="__prev__" data-bx-analytics-pager="__name__" __prev_disabled__>__txt_prev__</button>
+            <span class="bx-analytics-pager-info">__txt_page__</span>
+            <button type="button" class="bx-analytics-pager-btn" data-bx-analytics-page="__next__" data-bx-analytics-pager="__name__" __next_disabled__>__txt_next__</button>
+        </div>
+    </bx_if:pager>
+</div>

+ 5 - 0
modules/boonex/analytics/template/tabs.html

@@ -0,0 +1,5 @@
+<div class="bx-analytics-tabs" role="tablist" aria-label="__label__">
+    <bx_repeat:tabs>
+        <button type="button" role="tab" class="bx-analytics-tab" id="bx-analytics-tab-__group__-__name__" aria-selected="__selected__" aria-controls="bx-analytics-panel-__group__" tabindex="__tabindex__" data-bx-analytics-tab="__name__">__title__</button>
+    </bx_repeat:tabs>
+</div>

+ 16 - 0
modules/boonex/analytics/template/tiles.html

@@ -0,0 +1,16 @@
+<div class="bx-analytics-tiles">
+    <bx_repeat:tiles>
+        <div class="bx-analytics-tile">
+            <span class="bx-analytics-kpi-title">__title__</span>
+            <span class="bx-analytics-kpi-figures">
+                <span class="bx-analytics-kpi-value">__value__</span>
+                <bx_if:change>
+                    <span class="bx-analytics-kpi-change bx-analytics-trend-__trend__">__change__</span>
+                </bx_if:change>
+            </span>
+            <bx_if:sub>
+                <span class="bx-analytics-tile-sub">__sub__</span>
+            </bx_if:sub>
+        </div>
+    </bx_repeat:tiles>
+</div>

+ 23 - 0
modules/boonex/analytics/template/top.html

@@ -0,0 +1,23 @@
+__tabs__
+<div class="bx-analytics-panel" role="tabpanel" id="bx-analytics-panel-__group__">
+    <div class="bx-analytics-list-head" aria-hidden="true">
+        <span>__column__</span>
+        <span>__visitors__</span>
+    </div>
+    <bx_if:items>
+        <ul class="bx-analytics-rows">
+            <bx_repeat:items>
+                <li class="bx-analytics-row">
+                    <button type="button" class="bx-analytics-row-btn" data-bx-analytics-filter="__dimension__" data-bx-analytics-value="__value__" title="__filter_title__">
+                        <span class="bx-analytics-row-bar" style="width: __bar__%" aria-hidden="true"></span>
+                        <span class="bx-analytics-row-label">__label__</span>
+                        <span class="bx-analytics-row-value">__visitors__ <span class="bx-analytics-row-share">__share__%</span></span>
+                    </button>
+                </li>
+            </bx_repeat:items>
+        </ul>
+    </bx_if:items>
+    <bx_if:empty>
+        <div class="bx-analytics-empty">__empty__</div>
+    </bx_if:empty>
+</div>

+ 1 - 0
modules/boonex/analytics/template/tracker.html

@@ -0,0 +1 @@
+<script src="__src__" defer data-bx-analytics-endpoint="__endpoint__" data-bx-analytics-platform="__platform__" data-bx-analytics-identity="__identity__" data-bx-analytics-dnt="__dnt__" data-bx-analytics-errors="__errors__" data-bx-analytics-vitals="__vitals__"></script>

+ 17 - 0
modules/boonex/analytics/template/user.html

@@ -0,0 +1,17 @@
+<div class="bx-analytics-detail">
+    <button type="button" class="bx-analytics-back" data-bx-analytics-open="user" data-bx-analytics-value="">__back__</button>
+    __member__
+    __tiles__
+    <div class="bx-analytics-columns">
+        <div>
+            <h3 class="bx-analytics-subtitle">__sessions_title__</h3>
+            __sessions__
+        </div>
+        <div>
+            <h3 class="bx-analytics-subtitle">__platforms_title__</h3>
+            __platforms__
+            <h3 class="bx-analytics-subtitle">__actions_title__</h3>
+            __actions__
+        </div>
+    </div>
+</div>

+ 64 - 0
modules/boonex/credits/classes/BxCreditsDb.php

@@ -443,6 +443,70 @@ class BxCreditsDb extends BxBaseModGeneralDb
         $sQuery = "DELETE FROM `" . $this->_oConfig->CNF['TABLE_WITHDRAWALS'] . "` WHERE " . $this->arrayToSQL($aWhere, ' AND ');
         return (int)$this->query($sQuery) > 0;
     }
+
+    /**
+     * Analytics: credits moved in a period (`to` exclusive), by transfer type and direction.
+     */
+    public function getAnalyticsTotals($iFrom, $iTo)
+    {
+        return $this->getAll("SELECT `type`, `direction`, SUM(`amount`) AS `amount`, COUNT(*) AS `count` FROM `" . $this->_oConfig->CNF['TABLE_HISTORY'] . "` WHERE `date` >= :from AND `date` < :to GROUP BY `type`, `direction`", [
+            'from' => (int)$iFrom,
+            'to' => (int)$iTo
+        ]);
+    }
+
+    /**
+     * Analytics: the same per time bucket; a row goes to the latest of the bucket starts before it.
+     */
+    public function getAnalyticsSeries($iFrom, $iTo, $aStarts)
+    {
+        $aStarts = array_values(array_map('intval', $aStarts));
+        if(empty($aStarts))
+            return [];
+
+        $aBindings = ['from' => (int)$iFrom, 'to' => (int)$iTo];
+        $aCases = [];
+        foreach(array_reverse($aStarts) as $i => $iStart) {
+            $aCases[] = 'WHEN `date` >= :b' . $i . ' THEN :b' . $i . 'v';
+            $aBindings['b' . $i] = $iStart;
+            $aBindings['b' . $i . 'v'] = $iStart;
+        }
+
+        return $this->getAll("SELECT CASE " . implode(' ', $aCases) . " ELSE 0 END AS `bucket`, `type`, `direction`, SUM(`amount`) AS `amount` FROM `" . $this->_oConfig->CNF['TABLE_HISTORY'] . "` WHERE `date` >= :from AND `date` < :to GROUP BY `bucket`, `type`, `direction`", $aBindings);
+    }
+
+    /**
+     * Analytics: profiles that spent the most credits on checkouts in a period (the buyer's side).
+     */
+    public function getAnalyticsTopSpenders($iFrom, $iTo, $iLimit)
+    {
+        return $this->getAll("SELECT `first_pid` AS `profile_id`, SUM(`amount`) AS `amount` FROM `" . $this->_oConfig->CNF['TABLE_HISTORY'] . "` WHERE `date` >= :from AND `date` < :to AND `type` = :type AND `direction` = :direction GROUP BY `first_pid` ORDER BY `amount` DESC LIMIT " . max(1, min(100, (int)$iLimit)), [
+            'from' => (int)$iFrom,
+            'to' => (int)$iTo,
+            'type' => BX_CREDITS_TRANSFER_TYPE_CHECKOUT,
+            'direction' => BX_CREDITS_DIRECTION_OUT
+        ]);
+    }
+
+    /**
+     * Analytics: confirmed withdrawals in a period, in credits and in money at their own rate.
+     */
+    public function getAnalyticsWithdrawn($iFrom, $iTo)
+    {
+        return $this->getRow("SELECT COUNT(*) AS `count`, IFNULL(SUM(`amount`), 0) AS `amount`, IFNULL(SUM(`amount` * `rate`), 0) AS `money` FROM `" . $this->_oConfig->CNF['TABLE_WITHDRAWALS'] . "` WHERE `status` = :status AND `confirmed` >= :from AND `confirmed` < :to", [
+            'status' => BX_CREDITS_WITHDRAWAL_STATUS_CONFIRMED,
+            'from' => (int)$iFrom,
+            'to' => (int)$iTo
+        ]);
+    }
+
+    /**
+     * Analytics: credits members hold now, and how many members hold any.
+     */
+    public function getAnalyticsBalance()
+    {
+        return $this->getRow("SELECT IFNULL(SUM(`balance`), 0) AS `balance`, SUM(`balance` > 0) AS `holders` FROM `" . $this->_oConfig->CNF['TABLE_PROFILES'] . "`");
+    }
 }
 
 /** @} */

+ 104 - 0
modules/boonex/credits/classes/BxCreditsModule.php

@@ -856,6 +856,110 @@ class BxCreditsModule extends BxBaseModGeneralModule
     	return true;
     }
 
+    /**
+     * @page service Service Calls
+     * @section bx_credits Credits
+     * @subsection bx_credits-other Other
+     * @subsubsection bx_credits-get_analytics_revenue get_analytics_revenue
+     *
+     * @code bx_srv('bx_credits', 'get_analytics_revenue', [['from' => 1700000000, 'to' => 1702592000, 'starts' => [...], 'limit' => 10]]); @endcode
+     *
+     * The credits economy for the Analytics module's revenue reports: credits bought, spent on
+     * checkouts, granted, sent, moved by other modules and withdrawn in a period, with a series
+     * per time bucket, the top spenders, and the credits members hold now (a liability).
+     * Analytics finds this service in every installed module, so the module is never named there.
+     *
+     * @param $aParams array with:
+     * a. from - period start (unix time)
+     * b. to - period end, exclusive (unix time)
+     * c. starts - bucket starts (unix times, ascending) for the series; a row goes to the latest start before it
+     * d. limit - rows in top lists
+     * e. meta - when true, only the title and the unit are returned
+     * @return array with title (language key), unit (credits), metrics (key => title, value, count, money, series [start => value]), lists (top_spenders => title, rows [profile_id, value]) and snapshot (balance => title, value, holders, money_default)
+     *
+     * @see BxCreditsModule::serviceGetAnalyticsRevenue
+     */
+    /**
+     * @ref bx_credits-get_analytics_revenue "get_analytics_revenue"
+     */
+    public function serviceGetAnalyticsRevenue($aParams = [])
+    {
+        $aResult = [
+            'title' => '_bx_credits',
+            'unit' => 'credits',
+        ];
+        if(!is_array($aParams) || !empty($aParams['meta']))
+            return $aResult;
+
+        $iFrom = isset($aParams['from']) ? (int)$aParams['from'] : 0;
+        $iTo = isset($aParams['to']) ? (int)$aParams['to'] : time();
+        $aStarts = isset($aParams['starts']) && is_array($aParams['starts']) ? $aParams['starts'] : [];
+        $iLimit = isset($aParams['limit']) ? (int)$aParams['limit'] : 10;
+
+        // metric => [transfer type, direction]
+        $aMetrics = [
+            'bought' => [BX_CREDITS_TRANSFER_TYPE_PURCHASE, BX_CREDITS_DIRECTION_IN],
+            'spent' => [BX_CREDITS_TRANSFER_TYPE_CHECKOUT, BX_CREDITS_DIRECTION_OUT],
+            'granted' => [BX_CREDITS_TRANSFER_TYPE_GRANT, BX_CREDITS_DIRECTION_IN],
+            'sent' => [BX_CREDITS_TRANSFER_TYPE_SEND, BX_CREDITS_DIRECTION_OUT],
+            'service_in' => [BX_CREDITS_TRANSFER_TYPE_SERVICE, BX_CREDITS_DIRECTION_IN],
+            'service_out' => [BX_CREDITS_TRANSFER_TYPE_SERVICE, BX_CREDITS_DIRECTION_OUT],
+            'withdrawn' => [BX_CREDITS_TRANSFER_TYPE_WITHDRAW, BX_CREDITS_DIRECTION_OUT],
+        ];
+
+        $aTotals = [];
+        foreach($this->_oDb->getAnalyticsTotals($iFrom, $iTo) as $aRow)
+            $aTotals[$aRow['type'] . '|' . $aRow['direction']] = $aRow;
+
+        $aSeries = [];
+        foreach($this->_oDb->getAnalyticsSeries($iFrom, $iTo, $aStarts) as $aRow)
+            $aSeries[$aRow['type'] . '|' . $aRow['direction']][(int)$aRow['bucket']] = (float)$aRow['amount'];
+
+        $aWithdrawn = $this->_oDb->getAnalyticsWithdrawn($iFrom, $iTo);
+
+        $aResult['metrics'] = [];
+        foreach($aMetrics as $sMetric => list($sType, $sDirection)) {
+            $sKey = $sType . '|' . $sDirection;
+            $fValue = isset($aTotals[$sKey]) ? (float)$aTotals[$sKey]['amount'] : 0;
+
+            $aValues = [];
+            foreach($aStarts as $iStart)
+                $aValues[(int)$iStart] = isset($aSeries[$sKey][(int)$iStart]) ? $aSeries[$sKey][(int)$iStart] : 0;
+
+            $aResult['metrics'][$sMetric] = [
+                'title' => '_bx_credits_txt_analytics_' . $sMetric,
+                'value' => $fValue,
+                'count' => isset($aTotals[$sKey]) ? (int)$aTotals[$sKey]['count'] : 0,
+                // withdrawals are paid out at their own rate
+                'money' => $sMetric == 'withdrawn' ? (float)$aWithdrawn['money'] : $this->_oConfig->convertC2M($fValue),
+                'series' => $aValues,
+            ];
+        }
+
+        $aSpenders = [];
+        foreach($this->_oDb->getAnalyticsTopSpenders($iFrom, $iTo, $iLimit) as $aRow)
+            $aSpenders[] = ['profile_id' => (int)$aRow['profile_id'], 'value' => (float)$aRow['amount']];
+
+        $aResult['lists'] = [
+            'top_spenders' => [
+                'title' => '_bx_credits_txt_analytics_top_spenders',
+                'rows' => $aSpenders,
+            ],
+        ];
+
+        $aBalance = $this->_oDb->getAnalyticsBalance();
+        $aResult['snapshot'] = [
+            'balance' => [
+                'title' => '_bx_credits_txt_analytics_balance',
+                'value' => (float)$aBalance['balance'],
+                'holders' => (int)$aBalance['holders'],
+                'money_default' => $this->_oConfig->convertC2M((float)$aBalance['balance']),
+            ],
+        ];
+
+        return $aResult;
+    }
+
     public function serviceGetProfileBalance($iProfileId = 0)
     {
         return $this->getProfileBalance($iProfileId);

+ 9 - 0
modules/boonex/credits/install/langs/en.xml

@@ -316,6 +316,15 @@
 
 
     <!-- Checkout process -->
+    <string name="_bx_credits_txt_analytics_balance"><![CDATA[Held by members]]></string>
+    <string name="_bx_credits_txt_analytics_bought"><![CDATA[Bought]]></string>
+    <string name="_bx_credits_txt_analytics_granted"><![CDATA[Granted]]></string>
+    <string name="_bx_credits_txt_analytics_sent"><![CDATA[Sent to members]]></string>
+    <string name="_bx_credits_txt_analytics_service_in"><![CDATA[Credited by modules]]></string>
+    <string name="_bx_credits_txt_analytics_service_out"><![CDATA[Charged by modules]]></string>
+    <string name="_bx_credits_txt_analytics_spent"><![CDATA[Spent on checkouts]]></string>
+    <string name="_bx_credits_txt_analytics_top_spenders"><![CDATA[Top spenders]]></string>
+    <string name="_bx_credits_txt_analytics_withdrawn"><![CDATA[Withdrawn]]></string>
     <string name="_bx_credits_txt_checkout"><![CDATA[Checkout]]></string>
     <string name="_bx_credits_txt_checkout_to"><![CDATA[Pay to {0} for:]]></string>
     <string name="_bx_credits_txt_checkout_price_money"><![CDATA[Price:]]></string>

+ 2 - 1
modules/boonex/credits/install/sql/install.sql

@@ -71,7 +71,8 @@ CREATE TABLE IF NOT EXISTS `bx_credits_history` (
   `info` varchar(255) NOT NULL DEFAULT '',
   `date` int(11) NOT NULL DEFAULT '0',
   `cleared` int(11) NOT NULL DEFAULT '0',
-  PRIMARY KEY (`id`)
+  PRIMARY KEY (`id`),
+  KEY `date` (`date`, `type`, `direction`)
 );
 
 -- TABLE: withdrawals

+ 46 - 0
modules/boonex/payment/classes/BxPaymentDb.php

@@ -556,6 +556,41 @@ class BxPaymentDb extends BxBaseModPaymentDb
         return $this->getOrderProcessed($aParams);
     }
 
+    /**
+     * Processed transactions with their order, a page at a time by transaction id: for
+     * reading the whole history in steps (analytics imports past payments this way).
+     */
+    public function getTransactionsAfter($iAfterId, $iLimit)
+    {
+        return $this->getAll("SELECT `tt`.`id`, `tt`.`pending_id`, `tt`.`client_id`, `tt`.`seller_id`, `tt`.`author_id`, `tt`.`module_id`, `tt`.`item_id`, `tt`.`item_count`, `tt`.`amount`, `tt`.`currency`, `tt`.`date`, `ttp`.`type`, `ttp`.`provider`, `ttp`.`order`, `ttp`.`currency` AS `pending_currency`, `ttp`.`amount` AS `pending_amount`, `ttp`.`data`
+            FROM `" . $this->_sPrefix . "transactions` AS `tt`
+            LEFT JOIN `" . $this->_sPrefix . "transactions_pending` AS `ttp` ON `tt`.`pending_id`=`ttp`.`id`
+            WHERE `tt`.`id` > :after
+            ORDER BY `tt`.`id` ASC
+            LIMIT " . max(1, min(5000, (int)$iLimit)), [
+            'after' => (int)$iAfterId
+        ]);
+    }
+
+    /**
+     * Subscriptions, active or deleted, with their order, a page at a time by pending order id.
+     */
+    public function getSubscriptionsAfter($iAfterPendingId, $iLimit, $bDeleted = false)
+    {
+        $sSelectClause = "`ts`.`pending_id`, `ts`.`subscription_id`, `ts`.`period`, `ts`.`period_unit`, `ts`.`trial`, `ts`.`date_add`, `ts`.`date_next`, `ts`.`status`, `ttp`.`client_id`, `ttp`.`seller_id`, `ttp`.`type`, `ttp`.`provider`, `ttp`.`items`, `ttp`.`amount`, `ttp`.`currency`, `ttp`.`order`, `ttp`.`data`";
+        if($bDeleted)
+            $sSelectClause .= ", `ts`.`reason`, `ts`.`deleted`";
+
+        return $this->getAll("SELECT " . $sSelectClause . "
+            FROM `" . $this->_sPrefix . "subscriptions" . ($bDeleted ? "_deleted" : "") . "` AS `ts`
+            LEFT JOIN `" . $this->_sPrefix . "transactions_pending` AS `ttp` ON `ts`.`pending_id`=`ttp`.`id`
+            WHERE `ts`.`pending_id` > :after
+            ORDER BY `ts`.`pending_id` ASC
+            LIMIT " . max(1, min(5000, (int)$iLimit)), [
+            'after' => (int)$iAfterPendingId
+        ]);
+    }
+
     /*
      * Subscriptions methods
      */
@@ -850,6 +885,17 @@ class BxPaymentDb extends BxBaseModPaymentDb
         return call_user_func_array(array($this, $aMethod['name']), $aMethod['params']);
     }
 
+    /**
+     * Analytics: commission invoices issued in a period (`to` exclusive) by status and currency.
+     */
+    public function getAnalyticsInvoices($iFrom, $iTo)
+    {
+        return $this->getAll("SELECT `status`, `currency`, COUNT(*) AS `count`, SUM(`amount`) AS `amount` FROM `" . $this->_oConfig->CNF['TABLE_INVOICES'] . "` WHERE `date_issue` >= :from AND `date_issue` < :to GROUP BY `status`, `currency`", [
+            'from' => (int)$iFrom,
+            'to' => (int)$iTo
+        ]);
+    }
+
     public function insertInvoice($aValues)
     {
         $CNF = &$this->_oConfig->CNF;

+ 60 - 2
modules/boonex/payment/classes/BxPaymentModule.php

@@ -252,6 +252,63 @@ class BxPaymentModule extends BxBaseModPaymentModule
         return $this->_oDb->getModulesWithPayments();
     }
 
+    /**
+     * @page service Service Calls
+     * @section bx_payment Payment
+     * @subsection bx_payment-other Other
+     * @subsubsection bx_payment-get_analytics_revenue get_analytics_revenue
+     *
+     * @code bx_srv('bx_payment', 'get_analytics_revenue', [['from' => 1700000000, 'to' => 1702592000]]); @endcode
+     *
+     * Commission invoices for the Analytics module's revenue reports: how many were issued in
+     * a period and how many of them are paid, unpaid and overdue, with their amounts in the
+     * default currency. Analytics finds this service in every installed module.
+     *
+     * @param $aParams array with `from` and `to` (unix times, `to` exclusive); with `meta` only the title and the unit are returned.
+     * @return array with title (language key), unit (money) and metrics (key => title, value as the number of invoices, money as their amount in the default currency or null when a currency has no exchange rate), or an empty array when commissions are not used
+     *
+     * @see BxPaymentModule::serviceGetAnalyticsRevenue
+     */
+    /**
+     * @ref bx_payment-get_analytics_revenue "get_analytics_revenue"
+     */
+    public function serviceGetAnalyticsRevenue($aParams = [])
+    {
+        $aResult = [
+            'title' => '_bx_payment',
+            'unit' => 'money',
+        ];
+        if(!is_array($aParams) || !empty($aParams['meta']))
+            return $aResult;
+
+        $aRows = $this->_oDb->getAnalyticsInvoices(isset($aParams['from']) ? (int)$aParams['from'] : 0, isset($aParams['to']) ? (int)$aParams['to'] : time());
+        if(empty($aRows))
+            return [];
+
+        $sDefault = $this->_oConfig->getDefaultCurrencyCode();
+        $aMetrics = [];
+        foreach(['issued', BX_PAYMENT_INV_STATUS_PAID, BX_PAYMENT_INV_STATUS_UNPAID, BX_PAYMENT_INV_STATUS_OVERDUE] as $sMetric)
+            $aMetrics[$sMetric] = ['title' => '_bx_payment_txt_analytics_invoices_' . $sMetric, 'value' => 0, 'money' => 0];
+
+        foreach($aRows as $aRow) {
+            $fMoney = (float)$aRow['amount'];
+            if($aRow['currency'] !== '' && strcmp($aRow['currency'], $sDefault) != 0)
+                $fMoney = $this->serviceConvert($fMoney, $aRow['currency'], $sDefault);
+
+            foreach(['issued', $aRow['status']] as $sMetric) {
+                if(!isset($aMetrics[$sMetric]))
+                    continue;
+
+                $aMetrics[$sMetric]['value'] += (int)$aRow['count'];
+                $aMetrics[$sMetric]['money'] = $fMoney === false || $aMetrics[$sMetric]['money'] === null ? null : $aMetrics[$sMetric]['money'] + $fMoney;
+            }
+        }
+
+        $aResult['metrics'] = $aMetrics;
+
+        return $aResult;
+    }
+
     /**
      * @page service Service Calls
      * @section bx_payment Payment
@@ -1729,7 +1786,7 @@ class BxPaymentModule extends BxBaseModPaymentModule
             $this->_oDb->updateSubscription(['pay_attempts' => (int)$aSubscription['pay_attempts'] + 1], ['id' => $aSubscription['id']]);
 
             if(($mixedResult = $oProvider->makePayment($aPending)) !== true) {
-                $this->_oDb->updateSubscription(['status' => BX_PAYMENT_SBS_STATUS_UNPAID], ['id' => $aSubscription['id']]);
+                $this->getObjectSubscriptions()->overdue($aPending);
 
                 $this->log($aSubscription, 'Time Tracker', 'Payment cannot be processed (code: ' . (!empty($mixedResult['code']) ? (int)$mixedResult['code'] : 0) . ').', BX_LOG_ERR);
                 continue;
@@ -1806,7 +1863,8 @@ class BxPaymentModule extends BxBaseModPaymentModule
     {
         $this->alert('cancel_subscription', 0, $aPending['client_id'], [
             'pending' => $aPending,
-            'subscription' => $aSubscription
+            'subscription' => $aSubscription,
+            'reason' => isset($aResult['reason']) ? (string)$aResult['reason'] : ''
         ]);
     }
 

+ 26 - 0
modules/boonex/payment/classes/BxPaymentOrders.php

@@ -148,6 +148,32 @@ class BxPaymentOrders extends BxBaseModPaymentOrders
         return $this->_oModule->_oDb->getOrderPending(array('type' => 'mixed', 'conditions' => $aConditions));
     }
 
+    /**
+     * @page service Service Calls
+     * @section bx_payment Payment
+     * @subsection bx_payment-other Other
+     * @subsubsection bx_payment-get_transactions_after get_transactions_after
+     *
+     * @code bx_srv('bx_payment', 'get_transactions_after', [0, 500], 'Orders'); @endcode
+     *
+     * Get processed transactions a page at a time, in the order they were registered, to read
+     * the whole payment history in steps. Pass the id of the last transaction of the previous
+     * page to get the next one.
+     *
+     * @param $iAfterId integer value, the transactions with a greater ID are returned (0 for the first page).
+     * @param $iLimit integer value, transactions per page (at most 5000).
+     * @return an array of transactions ordered by ID. Each has the transaction's ID, pending_id, client_id, seller_id, author_id, module_id, item_id, item_count, amount, currency and date, and its order's type, provider, order, pending_currency, pending_amount and data (the serialized currency snapshot).
+     *
+     * @see BxPaymentOrders::serviceGetTransactionsAfter
+     */
+    /**
+     * @ref bx_payment-get_transactions_after "get_transactions_after"
+     */
+    public function serviceGetTransactionsAfter($iAfterId = 0, $iLimit = 500)
+    {
+        return $this->_oModule->_oDb->getTransactionsAfter((int)$iAfterId, (int)$iLimit);
+    }
+
     public function serviceProcessOrder($iSellerId, $iClientId, $iModuleId, $aItems, $sType, $sOrder)
     {
         $mixedResult = $this->addOrder(array(

+ 28 - 1
modules/boonex/payment/classes/BxPaymentSubscriptions.php

@@ -169,6 +169,33 @@ class BxPaymentSubscriptions extends BxBaseModPaymentSubscriptions
         return $aSubscriptions;
     }
 
+    /**
+     * @page service Service Calls
+     * @section bx_payment Payment
+     * @subsection bx_payment-other Other
+     * @subsubsection bx_payment-get_subscriptions_after get_subscriptions_after
+     *
+     * @code bx_srv('bx_payment', 'get_subscriptions_after', [0, 500, false], 'Subscriptions'); @endcode
+     *
+     * Get subscriptions a page at a time, ordered by their pending transaction ID, to read
+     * them all in steps. Pass the pending ID of the last subscription of the previous page to
+     * get the next one.
+     *
+     * @param $iAfterPendingId integer value, the subscriptions with a greater pending ID are returned (0 for the first page).
+     * @param $iLimit integer value, subscriptions per page (at most 5000).
+     * @param $bDeleted boolean value, read cancelled (deleted) subscriptions instead of the current ones.
+     * @return an array of subscriptions. Each has pending_id, subscription_id, period, period_unit, trial, date_add, date_next and status, its order's client_id, seller_id, type, provider, items, amount, currency, order and data, and for deleted ones the reason and the time they were deleted.
+     *
+     * @see BxPaymentSubscriptions::serviceGetSubscriptionsAfter
+     */
+    /**
+     * @ref bx_payment-get_subscriptions_after "get_subscriptions_after"
+     */
+    public function serviceGetSubscriptionsAfter($iAfterPendingId = 0, $iLimit = 500, $bDeleted = false)
+    {
+        return $this->_oModule->_oDb->getSubscriptionsAfter((int)$iAfterPendingId, (int)$iLimit, (bool)$bDeleted);
+    }
+
     /**
      * @page service Service Calls
      * @section bx_payment Payment
@@ -506,7 +533,7 @@ class BxPaymentSubscriptions extends BxBaseModPaymentSubscriptions
         if(!$this->_oModule->_oDb->deleteSubscription($aSubscription['id'], $sReason))
             return false;
 
-        $this->_oModule->onSubscriptionCancel($aPending, $aSubscription);
+        $this->_oModule->onSubscriptionCancel($aPending, $aSubscription, ['reason' => $sReason]);
 
         return true;
     }

+ 4 - 0
modules/boonex/payment/install/langs/en.xml

@@ -436,6 +436,10 @@
     <string name="_bx_payment_txt_all_clients"><![CDATA[All Clients]]></string>
     <string name="_bx_payment_txt_all_modules"><![CDATA[All Modules]]></string>
     <string name="_bx_payment_txt_all_items"><![CDATA[All Items]]></string>
+    <string name="_bx_payment_txt_analytics_invoices_issued"><![CDATA[Issued]]></string>
+    <string name="_bx_payment_txt_analytics_invoices_overdue"><![CDATA[Overdue]]></string>
+    <string name="_bx_payment_txt_analytics_invoices_paid"><![CDATA[Paid]]></string>
+    <string name="_bx_payment_txt_analytics_invoices_unpaid"><![CDATA[Unpaid]]></string>
     <string name="_bx_payment_txt_status_scheduled"><![CDATA[Scheduled]]></string>
     <string name="_bx_payment_txt_status_trial"><![CDATA[Trial]]></string>
     <string name="_bx_payment_txt_status_active"><![CDATA[Active]]></string>

+ 681 - 0
modules/boonex/russian/data/langs/bx_analytics/ru.xml

@@ -2,18 +2,317 @@
 <resources name="ru" flag="ru" title="Russian">
 <string name="_bx_analytics"><![CDATA[Аналитика]]></string>
 	<string name="_bx_analytics_adm_stg_cpt_type"><![CDATA[Аналитика]]></string>
+	<string name="_bx_analytics_adm_stg_cpt_vitals_retention_days"><![CDATA[Хранить данные о производительности, дней]]></string>
+	<string name="_bx_analytics_adm_stg_cpt_vitals_sample"><![CDATA[Доля измеряемых загрузок, %]]></string>
+	<string name="_bx_analytics_adm_stg_cpt_category_content"><![CDATA[Отчёты по контенту]]></string>
 	<string name="_bx_analytics_adm_stg_cpt_category_general"><![CDATA[Аналитика]]></string>
+	<string name="_bx_analytics_adm_stg_cpt_category_health"><![CDATA[Здоровье]]></string>
+	<string name="_bx_analytics_adm_stg_cpt_category_privacy"><![CDATA[Приватность и данные]]></string>
+	<string name="_bx_analytics_adm_stg_cpt_category_system"><![CDATA[Системные]]></string>
+	<string name="_bx_analytics_adm_stg_cpt_category_tracking"><![CDATA[Сбор данных]]></string>
+	<string name="_bx_analytics_adm_stg_inf_errors_ignore"><![CDATA[Сообщения об ошибках, которые не нужно записывать, по одному в строке. * означает любые символы, регистр не важен.]]></string>
+	<string name="_bx_analytics_adm_stg_inf_exclude_ips"><![CDATA[Адреса или диапазоны CIDR через запятую или пробел.]]></string>
+	<string name="_bx_analytics_adm_stg_inf_exclude_levels"><![CDATA[Например, администраторов, чтобы не учитывать собственные посещения.]]></string>
+	<string name="_bx_analytics_adm_stg_inf_exclude_paths"><![CDATA[Начала путей или шаблоны со * (например, /page/*-admin) через запятую или пробел.]]></string>
+	<string name="_bx_analytics_adm_stg_inf_geo_source"><![CDATA[Заголовки CDN: страна, которую Cloudflare, Vercel или CloudFront добавляют к каждому запросу, и регион с городом на Vercel.]]></string>
+	<string name="_bx_analytics_adm_stg_inf_identity"><![CDATA[Без cookie: хеш адреса и браузера, который меняется каждый день, на устройстве ничего не хранится. Гость, вернувшийся в другой день, считается новым посетителем. Постоянный: случайный идентификатор в браузере или приложении, позволяет считать удержание гостей, но может требовать согласия.]]></string>
+	<string name="_bx_analytics_adm_stg_inf_ignore_params"><![CDATA[Не включаются в сохраняемые пути страниц, чтобы одна страница не считалась как несколько. Через запятую; * означает любые символы. Метки кампаний и идентификаторы кликов не включаются всегда.]]></string>
+	<string name="_bx_analytics_adm_stg_inf_limit_hits"><![CDATA[Сколько просмотров, событий, ошибок и замеров трекеры могут отправить с одного адреса за минуту (адрес IPv6 считается по своей сети /64). Остальные отбрасываются до следующей минуты, а трекер продолжает отправку. Увеличьте значение, если многие посетители заходят с одного адреса, например из офиса или школы. 0: без ограничения. Действия на сайте и платежи не ограничиваются.]]></string>
+	<string name="_bx_analytics_adm_stg_inf_limit_visits"><![CDATA[Сколько визитов может начаться с одного адреса за час (адрес IPv6 считается по своей сети /64). Обращения, которые начали бы ещё один визит, отбрасываются, чтобы скрипт не заполнил отчёты визитами. Уже идущие визиты продолжаются. 0: без ограничения.]]></string>
+	<string name="_bx_analytics_adm_stg_inf_link_members"><![CDATA[Записывать профиль вошедших участников для их сеансов и удержания. При удалении профиля его посещения отвязываются.]]></string>
+	<string name="_bx_analytics_adm_stg_inf_online_window"><![CDATA[Посетители, активные в течение этого времени, считаются онлайн.]]></string>
+	<string name="_bx_analytics_adm_stg_inf_platforms"><![CDATA[Клиенты, посещения с которых записываются.]]></string>
+	<string name="_bx_analytics_adm_stg_inf_respect_dnt"><![CDATA[Не учитывать браузеры, отправляющие Do Not Track или Global Privacy Control.]]></string>
+	<string name="_bx_analytics_adm_stg_inf_retention_days"><![CDATA[Более старые посещения и события удаляются каждый час.]]></string>
+	<string name="_bx_analytics_adm_stg_inf_session_timeout"><![CDATA[Посещение заканчивается после такого времени без активности.]]></string>
+	<string name="_bx_analytics_adm_stg_inf_track_actions"><![CDATA[Регистрации, входы, новые профили и материалы, комментарии, реакции и связи записываются как события с указанием клиента.]]></string>
+	<string name="_bx_analytics_adm_stg_inf_track_bots"><![CDATA[Поисковые системы, ИИ-краулеры, ИИ-ассистенты, загружающие страницы, превью ссылок, мониторинг и скрипты. Учитываются по дням и страницам, отдельно от посетителей.]]></string>
+	<string name="_bx_analytics_adm_stg_inf_track_errors"><![CDATA[Ошибки JavaScript, необработанные отклонения промисов, неудачные запросы и сбои приложений со всех клиентов. Строки запросов, токены и адреса почты удаляются до сохранения.]]></string>
+	<string name="_bx_analytics_adm_stg_inf_track_vitals"><![CDATA[Web Vitals загрузки страниц (LCP, INP, CLS, FCP, TTFB), а также загрузка экранов и запуск приложений.]]></string>
+	<string name="_bx_analytics_adm_stg_inf_tracking"><![CDATA[Если выключено, сбор останавливается. Отчёты сохраняют уже собранные данные.]]></string>
+	<string name="_bx_analytics_adm_stg_inf_trusted_proxies"><![CDATA[Адреса или диапазоны CIDR прокси и балансировщиков, чей заголовок X-Forwarded-For содержит адрес посетителя. Адресам частной сети и серверу NEO доверие есть всегда.]]></string>
+	<string name="_bx_analytics_adm_stg_inf_vitals_retention_days"><![CDATA[Более старые измерения удаляются. Срок может быть короче срока хранения остальных данных, но не длиннее.]]></string>
+	<string name="_bx_analytics_adm_stg_inf_vitals_sample"><![CDATA[От 0 до 100. Каждый измеренный просмотр добавляет строку, поэтому на загруженном сайте можно измерять только часть.]]></string>
+	<string name="_bx_analytics_bot_ai_agent"><![CDATA[ИИ-ассистент]]></string>
+	<string name="_bx_analytics_bot_ai_crawler"><![CDATA[ИИ-краулер]]></string>
+	<string name="_bx_analytics_bot_feed"><![CDATA[Читалка лент]]></string>
+	<string name="_bx_analytics_bot_monitor"><![CDATA[Мониторинг]]></string>
+	<string name="_bx_analytics_bot_other"><![CDATA[Другой бот]]></string>
+	<string name="_bx_analytics_bot_script"><![CDATA[Скрипт]]></string>
+	<string name="_bx_analytics_bot_search"><![CDATA[Поисковая система]]></string>
+	<string name="_bx_analytics_bot_seo"><![CDATA[SEO-инструмент]]></string>
+	<string name="_bx_analytics_bot_social"><![CDATA[Превью ссылки]]></string>
+	<string name="_bx_analytics_channel_affiliate"><![CDATA[Партнёры]]></string>
+	<string name="_bx_analytics_channel_ai"><![CDATA[ИИ-ассистенты]]></string>
+	<string name="_bx_analytics_channel_campaign"><![CDATA[Другие кампании]]></string>
+	<string name="_bx_analytics_channel_direct"><![CDATA[Прямые заходы]]></string>
+	<string name="_bx_analytics_channel_display"><![CDATA[Медийная реклама]]></string>
+	<string name="_bx_analytics_channel_email"><![CDATA[Почта]]></string>
+	<string name="_bx_analytics_channel_internal"><![CDATA[Внутренние]]></string>
+	<string name="_bx_analytics_channel_paid_search"><![CDATA[Платный поиск]]></string>
+	<string name="_bx_analytics_channel_paid_social"><![CDATA[Платная реклама в соцсетях]]></string>
+	<string name="_bx_analytics_channel_referral"><![CDATA[Переходы с сайтов]]></string>
+	<string name="_bx_analytics_channel_search"><![CDATA[Поиск]]></string>
+	<string name="_bx_analytics_channel_social"><![CDATA[Соцсети]]></string>
+	<string name="_bx_analytics_device_bot"><![CDATA[Бот]]></string>
+	<string name="_bx_analytics_device_console"><![CDATA[Игровая приставка]]></string>
+	<string name="_bx_analytics_device_desktop"><![CDATA[Компьютер]]></string>
+	<string name="_bx_analytics_device_mobile"><![CDATA[Телефон]]></string>
+	<string name="_bx_analytics_device_tablet"><![CDATA[Планшет]]></string>
+	<string name="_bx_analytics_device_tv"><![CDATA[Телевизор]]></string>
+	<string name="_bx_analytics_dim_app"><![CDATA[Приложение]]></string>
+	<string name="_bx_analytics_dim_app_version"><![CDATA[Версия приложения]]></string>
+	<string name="_bx_analytics_dim_browser"><![CDATA[Браузер]]></string>
+	<string name="_bx_analytics_dim_channel"><![CDATA[Канал]]></string>
+	<string name="_bx_analytics_dim_city"><![CDATA[Город]]></string>
+	<string name="_bx_analytics_dim_country"><![CDATA[Страна]]></string>
+	<string name="_bx_analytics_dim_device"><![CDATA[Устройство]]></string>
+	<string name="_bx_analytics_dim_entry_path"><![CDATA[Страница входа]]></string>
+	<string name="_bx_analytics_dim_exit_path"><![CDATA[Страница выхода]]></string>
+	<string name="_bx_analytics_dim_hostname"><![CDATA[Хост]]></string>
+	<string name="_bx_analytics_dim_language"><![CDATA[Язык]]></string>
+	<string name="_bx_analytics_dim_os"><![CDATA[Система]]></string>
+	<string name="_bx_analytics_dim_path"><![CDATA[Страница]]></string>
+	<string name="_bx_analytics_dim_platform"><![CDATA[Платформа]]></string>
+	<string name="_bx_analytics_dim_referrer"><![CDATA[URL источника]]></string>
+	<string name="_bx_analytics_dim_referrer_host"><![CDATA[Источник]]></string>
+	<string name="_bx_analytics_dim_region"><![CDATA[Регион]]></string>
+	<string name="_bx_analytics_dim_screen"><![CDATA[Размер экрана]]></string>
+	<string name="_bx_analytics_dim_title"><![CDATA[Заголовок страницы]]></string>
+	<string name="_bx_analytics_dim_utm_campaign"><![CDATA[UTM campaign]]></string>
+	<string name="_bx_analytics_dim_utm_content"><![CDATA[UTM content]]></string>
+	<string name="_bx_analytics_dim_utm_medium"><![CDATA[UTM medium]]></string>
+	<string name="_bx_analytics_dim_utm_source"><![CDATA[UTM source]]></string>
+	<string name="_bx_analytics_dim_utm_term"><![CDATA[UTM term]]></string>
+	<string name="_bx_analytics_error_kind_http"><![CDATA[Ошибка сервера]]></string>
+	<string name="_bx_analytics_error_kind_js"><![CDATA[Ошибка JavaScript]]></string>
+	<string name="_bx_analytics_error_kind_native"><![CDATA[Ошибка приложения]]></string>
+	<string name="_bx_analytics_error_kind_overflow"><![CDATA[Другое]]></string>
+	<string name="_bx_analytics_error_kind_promise"><![CDATA[Необработанное отклонение]]></string>
+	<string name="_bx_analytics_error_kind_react"><![CDATA[Ошибка отрисовки]]></string>
+	<string name="_bx_analytics_error_kind_resource"><![CDATA[Не загрузилось]]></string>
+	<string name="_bx_analytics_error_status_ignored"><![CDATA[Игнорируется]]></string>
+	<string name="_bx_analytics_error_status_open"><![CDATA[Открыта]]></string>
+	<string name="_bx_analytics_error_status_resolved"><![CDATA[Исправлена]]></string>
+	<string name="_bx_analytics_event_type_event"><![CDATA[Своё событие]]></string>
+	<string name="_bx_analytics_event_type_pageview"><![CDATA[Просмотр]]></string>
+	<string name="_bx_analytics_event_type_una"><![CDATA[Действие на сайте]]></string>
+	<string name="_bx_analytics_geo_headers"><![CDATA[Заголовки CDN]]></string>
+	<string name="_bx_analytics_geo_none"><![CDATA[Выключено]]></string>
+	<string name="_bx_analytics_group_bots"><![CDATA[Категории ботов]]></string>
+	<string name="_bx_analytics_group_devices"><![CDATA[Устройства]]></string>
+	<string name="_bx_analytics_group_errors"><![CDATA[Ошибки по статусу]]></string>
+	<string name="_bx_analytics_group_events"><![CDATA[Типы событий]]></string>
+	<string name="_bx_analytics_group_locations"><![CDATA[География]]></string>
+	<string name="_bx_analytics_group_map"><![CDATA[Карта]]></string>
+	<string name="_bx_analytics_group_pages"><![CDATA[Страницы]]></string>
+	<string name="_bx_analytics_group_performance"><![CDATA[Группировать страницы]]></string>
+	<string name="_bx_analytics_group_platforms"><![CDATA[Платформы]]></string>
+	<string name="_bx_analytics_group_retention"><![CDATA[Кого учитывать]]></string>
+	<string name="_bx_analytics_group_revenue"><![CDATA[Выручка по]]></string>
+	<string name="_bx_analytics_group_sources"><![CDATA[Источники]]></string>
+	<string name="_bx_analytics_identity_cookieless"><![CDATA[Без cookie (рекомендуется)]]></string>
+	<string name="_bx_analytics_identity_persistent"><![CDATA[Постоянный идентификатор на устройстве]]></string>
+	<string name="_bx_analytics_page_block_title_bots"><![CDATA[Боты и агенты]]></string>
 	<string name="_bx_analytics_page_block_title_canvas"><![CDATA[Аналитика]]></string>
+	<string name="_bx_analytics_page_block_title_credits"><![CDATA[Кредиты]]></string>
+	<string name="_bx_analytics_page_block_title_errors"><![CDATA[Ошибки]]></string>
+	<string name="_bx_analytics_page_block_title_events"><![CDATA[События]]></string>
+	<string name="_bx_analytics_page_block_title_filters"><![CDATA[Фильтры]]></string>
+	<string name="_bx_analytics_page_block_title_funnels"><![CDATA[Воронки]]></string>
+	<string name="_bx_analytics_page_block_title_goals"><![CDATA[Цели]]></string>
+	<string name="_bx_analytics_page_block_title_journeys"><![CDATA[Пути]]></string>
+	<string name="_bx_analytics_page_block_title_map"><![CDATA[Посетители по местоположению]]></string>
+	<string name="_bx_analytics_page_block_title_overview"><![CDATA[Обзор]]></string>
+	<string name="_bx_analytics_page_block_title_performance"><![CDATA[Производительность]]></string>
+	<string name="_bx_analytics_page_block_title_query"><![CDATA[Запрос]]></string>
+	<string name="_bx_analytics_page_block_title_realtime"><![CDATA[Прямо сейчас]]></string>
+	<string name="_bx_analytics_page_block_title_retention"><![CDATA[Удержание]]></string>
+	<string name="_bx_analytics_page_block_title_revenue"><![CDATA[Выручка]]></string>
+	<string name="_bx_analytics_page_block_title_sales"><![CDATA[Продажи и продавцы]]></string>
+	<string name="_bx_analytics_page_block_title_sessions"><![CDATA[Сеансы]]></string>
+	<string name="_bx_analytics_page_block_title_std_dash_traffic"><![CDATA[Трафик]]></string>
+	<string name="_bx_analytics_page_block_title_subscriptions"><![CDATA[Подписки]]></string>
+	<string name="_bx_analytics_page_block_title_system_bots"><![CDATA[Аналитика: боты и агенты]]></string>
 	<string name="_bx_analytics_page_block_title_system_canvas"><![CDATA[Аналитика]]></string>
+	<string name="_bx_analytics_page_block_title_system_credits"><![CDATA[Аналитика: кредиты]]></string>
+	<string name="_bx_analytics_page_block_title_system_errors"><![CDATA[Аналитика: ошибки]]></string>
+	<string name="_bx_analytics_page_block_title_system_events"><![CDATA[Аналитика: события]]></string>
+	<string name="_bx_analytics_page_block_title_system_filters"><![CDATA[Аналитика: фильтры]]></string>
+	<string name="_bx_analytics_page_block_title_system_funnels"><![CDATA[Аналитика: воронки]]></string>
+	<string name="_bx_analytics_page_block_title_system_goals"><![CDATA[Аналитика: цели]]></string>
+	<string name="_bx_analytics_page_block_title_system_journeys"><![CDATA[Аналитика: пути]]></string>
+	<string name="_bx_analytics_page_block_title_system_map"><![CDATA[Аналитика: карта]]></string>
+	<string name="_bx_analytics_page_block_title_system_overview"><![CDATA[Аналитика: обзор]]></string>
+	<string name="_bx_analytics_page_block_title_system_performance"><![CDATA[Аналитика: производительность]]></string>
+	<string name="_bx_analytics_page_block_title_system_query"><![CDATA[Аналитика: запрос]]></string>
+	<string name="_bx_analytics_page_block_title_system_realtime"><![CDATA[Аналитика: сейчас на сайте]]></string>
+	<string name="_bx_analytics_page_block_title_system_retention"><![CDATA[Аналитика: удержание]]></string>
+	<string name="_bx_analytics_page_block_title_system_revenue"><![CDATA[Аналитика: выручка]]></string>
+	<string name="_bx_analytics_page_block_title_system_sales"><![CDATA[Аналитика: продажи и продавцы]]></string>
+	<string name="_bx_analytics_page_block_title_system_sessions"><![CDATA[Аналитика: сеансы]]></string>
+	<string name="_bx_analytics_page_block_title_system_subscriptions"><![CDATA[Аналитика: подписки]]></string>
+	<string name="_bx_analytics_page_block_title_system_table_pages"><![CDATA[Аналитика: таблица страниц]]></string>
+	<string name="_bx_analytics_page_block_title_system_table_sources"><![CDATA[Аналитика: таблица источников]]></string>
+	<string name="_bx_analytics_page_block_title_system_top_devices"><![CDATA[Аналитика: устройства]]></string>
+	<string name="_bx_analytics_page_block_title_system_top_locations"><![CDATA[Аналитика: география]]></string>
+	<string name="_bx_analytics_page_block_title_system_top_pages"><![CDATA[Аналитика: страницы]]></string>
+	<string name="_bx_analytics_page_block_title_system_top_platforms"><![CDATA[Аналитика: платформы]]></string>
+	<string name="_bx_analytics_page_block_title_system_top_sources"><![CDATA[Аналитика: источники]]></string>
+	<string name="_bx_analytics_page_block_title_system_users"><![CDATA[Аналитика: пользователи]]></string>
+	<string name="_bx_analytics_page_block_title_table_pages"><![CDATA[Страницы]]></string>
+	<string name="_bx_analytics_page_block_title_table_sources"><![CDATA[Источники]]></string>
+	<string name="_bx_analytics_page_block_title_top_devices"><![CDATA[Устройства]]></string>
+	<string name="_bx_analytics_page_block_title_top_locations"><![CDATA[География]]></string>
+	<string name="_bx_analytics_page_block_title_top_pages"><![CDATA[Страницы]]></string>
+	<string name="_bx_analytics_page_block_title_top_platforms"><![CDATA[Платформы]]></string>
+	<string name="_bx_analytics_page_block_title_top_sources"><![CDATA[Источники]]></string>
+	<string name="_bx_analytics_page_block_title_users"><![CDATA[Пользователи]]></string>
 	<string name="_bx_analytics_adm_stg_cpt_modules_enabled"><![CDATA[Активна для модулей]]></string>
+	<string name="_bx_analytics_adm_stg_cpt_online_window"><![CDATA[Окно «онлайн», минут]]></string>
+	<string name="_bx_analytics_adm_stg_cpt_platforms"><![CDATA[Собирать с]]></string>
+	<string name="_bx_analytics_adm_stg_cpt_respect_dnt"><![CDATA[Учитывать Do Not Track]]></string>
+	<string name="_bx_analytics_adm_stg_cpt_retention_days"><![CDATA[Хранить данные, дней]]></string>
+	<string name="_bx_analytics_adm_stg_cpt_session_timeout"><![CDATA[Тайм-аут сеанса, минут]]></string>
+	<string name="_bx_analytics_adm_stg_cpt_track_actions"><![CDATA[Записывать действия на сайте]]></string>
+	<string name="_bx_analytics_adm_stg_cpt_track_bots"><![CDATA[Учитывать ботов и агентов]]></string>
+	<string name="_bx_analytics_adm_stg_cpt_track_errors"><![CDATA[Записывать ошибки]]></string>
+	<string name="_bx_analytics_adm_stg_cpt_track_vitals"><![CDATA[Измерять производительность]]></string>
+	<string name="_bx_analytics_adm_stg_cpt_tracking"><![CDATA[Собирать посещения и события]]></string>
+	<string name="_bx_analytics_adm_stg_cpt_trusted_proxies"><![CDATA[Доверенные прокси]]></string>
 	<string name="_bx_analytics_adm_stg_cpt_items_count"><![CDATA[Количество позиций]]></string>
+	<string name="_bx_analytics_adm_stg_cpt_limit_hits"><![CDATA[Обращений в минуту с одного адреса]]></string>
+	<string name="_bx_analytics_adm_stg_cpt_limit_visits"><![CDATA[Новых визитов в час с одного адреса]]></string>
+	<string name="_bx_analytics_adm_stg_cpt_link_members"><![CDATA[Связывать посещения с участниками]]></string>
 	<string name="_bx_analytics_adm_stg_cpt_default_interval_day"><![CDATA[Интервал в днях по умолчанию]]></string>
+	<string name="_bx_analytics_adm_stg_cpt_errors_ignore"><![CDATA[Игнорируемые ошибки]]></string>
+	<string name="_bx_analytics_adm_stg_cpt_exclude_ips"><![CDATA[Исключённые IP-адреса]]></string>
+	<string name="_bx_analytics_adm_stg_cpt_exclude_levels"><![CDATA[Не учитывать эти уровни]]></string>
+	<string name="_bx_analytics_adm_stg_cpt_exclude_paths"><![CDATA[Исключённые пути]]></string>
+	<string name="_bx_analytics_adm_stg_cpt_geo_source"><![CDATA[Местоположение посетителей]]></string>
+	<string name="_bx_analytics_adm_stg_cpt_identity"><![CDATA[Идентификаторы гостей]]></string>
+	<string name="_bx_analytics_adm_stg_cpt_ignore_params"><![CDATA[Игнорируемые параметры запроса]]></string>
 	<string name="_bx_analytics_page_title_sys_page"><![CDATA[Аналитика]]></string>
+	<string name="_bx_analytics_page_title_sys_revenue"><![CDATA[Аналитика: доходы]]></string>
 	<string name="_bx_analytics_page_title_page"><![CDATA[Аналитика]]></string>
+	<string name="_bx_analytics_page_title_revenue"><![CDATA[Доходы]]></string>
 	<string name="_bx_analytics_page_block_title_system_canvas"><![CDATA[Аналитика]]></string>
 	<string name="_bx_analytics_page_block_title_canvas"><![CDATA[Аналитика]]></string>
+	<string name="_bx_analytics_platform_api"><![CDATA[API]]></string>
+	<string name="_bx_analytics_platform_core"><![CDATA[Сервер]]></string>
+	<string name="_bx_analytics_platform_core_web"><![CDATA[Веб (классический)]]></string>
+	<string name="_bx_analytics_platform_next_web"><![CDATA[Веб (NEO)]]></string>
+	<string name="_bx_analytics_platform_rn_android"><![CDATA[Приложение Android]]></string>
+	<string name="_bx_analytics_platform_rn_ios"><![CDATA[Приложение iOS]]></string>
+	<string name="_bx_analytics_range_12m"><![CDATA[Последние 12 месяцев]]></string>
+	<string name="_bx_analytics_range_24h"><![CDATA[Последние 24 часа]]></string>
+	<string name="_bx_analytics_range_30d"><![CDATA[Последние 30 дней]]></string>
+	<string name="_bx_analytics_range_7d"><![CDATA[Последние 7 дней]]></string>
+	<string name="_bx_analytics_range_90d"><![CDATA[Последние 90 дней]]></string>
+	<string name="_bx_analytics_range_today"><![CDATA[Сегодня]]></string>
+	<string name="_bx_analytics_range_yesterday"><![CDATA[Вчера]]></string>
+	<string name="_bx_analytics_rating_good"><![CDATA[Хорошо]]></string>
+	<string name="_bx_analytics_rating_needs_improvement"><![CDATA[Нужно улучшить]]></string>
+	<string name="_bx_analytics_rating_poor"><![CDATA[Плохо]]></string>
+	<string name="_bx_analytics_report_bots"><![CDATA[Боты и агенты]]></string>
+	<string name="_bx_analytics_report_credits"><![CDATA[Кредиты]]></string>
+	<string name="_bx_analytics_report_errors"><![CDATA[Ошибки]]></string>
+	<string name="_bx_analytics_report_events"><![CDATA[События]]></string>
+	<string name="_bx_analytics_report_filters"><![CDATA[Панель фильтров]]></string>
+	<string name="_bx_analytics_report_funnels"><![CDATA[Воронки]]></string>
+	<string name="_bx_analytics_report_goals"><![CDATA[Цели]]></string>
+	<string name="_bx_analytics_report_journeys"><![CDATA[Пути]]></string>
+	<string name="_bx_analytics_report_map"><![CDATA[Посетители по местоположению]]></string>
+	<string name="_bx_analytics_report_overview"><![CDATA[Обзор]]></string>
+	<string name="_bx_analytics_report_performance"><![CDATA[Производительность]]></string>
+	<string name="_bx_analytics_report_query"><![CDATA[Запрос]]></string>
+	<string name="_bx_analytics_report_realtime"><![CDATA[Прямо сейчас]]></string>
+	<string name="_bx_analytics_report_retention"><![CDATA[Удержание]]></string>
+	<string name="_bx_analytics_report_revenue"><![CDATA[Выручка]]></string>
+	<string name="_bx_analytics_report_sales"><![CDATA[Продажи и продавцы]]></string>
+	<string name="_bx_analytics_report_sessions"><![CDATA[Сеансы]]></string>
+	<string name="_bx_analytics_report_subscriptions"><![CDATA[Подписки]]></string>
+	<string name="_bx_analytics_report_table"><![CDATA[Таблица]]></string>
+	<string name="_bx_analytics_report_top"><![CDATA[Топ-список]]></string>
+	<string name="_bx_analytics_report_users"><![CDATA[Пользователи]]></string>
+	<string name="_bx_analytics_section_behavior"><![CDATA[Поведение]]></string>
+	<string name="_bx_analytics_section_community"><![CDATA[Сообщество]]></string>
+	<string name="_bx_analytics_section_configure"><![CDATA[Настройка]]></string>
+	<string name="_bx_analytics_section_conversion"><![CDATA[Конверсия]]></string>
+	<string name="_bx_analytics_section_explore"><![CDATA[Исследование]]></string>
+	<string name="_bx_analytics_section_health"><![CDATA[Здоровье]]></string>
+	<string name="_bx_analytics_section_revenue"><![CDATA[Доходы]]></string>
+	<string name="_bx_analytics_section_traffic"><![CDATA[Трафик]]></string>
 	<string name="_bx_analytics_item_title_system_analytics"><![CDATA[Аналитика]]></string>
+	<string name="_bx_analytics_item_title_system_revenue"><![CDATA[Доходы]]></string>
 	<string name="_bx_analytics_item_title_analytics"><![CDATA[Аналитика]]></string>
+	<string name="_bx_analytics_item_title_revenue"><![CDATA[Доходы]]></string>
+	<string name="_bx_analytics_lmi_cpt_api"><![CDATA[API]]></string>
+	<string name="_bx_analytics_lmi_cpt_audience"><![CDATA[Аудитория]]></string>
+	<string name="_bx_analytics_lmi_cpt_bots"><![CDATA[Боты и агенты]]></string>
+	<string name="_bx_analytics_lmi_cpt_content"><![CDATA[Отчёты по контенту]]></string>
+	<string name="_bx_analytics_lmi_cpt_credits"><![CDATA[Кредиты]]></string>
+	<string name="_bx_analytics_lmi_cpt_dashboards"><![CDATA[Дашборды]]></string>
+	<string name="_bx_analytics_lmi_cpt_errors"><![CDATA[Ошибки]]></string>
+	<string name="_bx_analytics_lmi_cpt_events"><![CDATA[События]]></string>
+	<string name="_bx_analytics_lmi_cpt_funnels"><![CDATA[Воронки]]></string>
+	<string name="_bx_analytics_lmi_cpt_goals"><![CDATA[Цели]]></string>
+	<string name="_bx_analytics_lmi_cpt_journeys"><![CDATA[Пути]]></string>
+	<string name="_bx_analytics_lmi_cpt_map"><![CDATA[Карта]]></string>
+	<string name="_bx_analytics_lmi_cpt_overview"><![CDATA[Обзор]]></string>
+	<string name="_bx_analytics_lmi_cpt_pages"><![CDATA[Страницы]]></string>
+	<string name="_bx_analytics_lmi_cpt_performance"><![CDATA[Производительность]]></string>
+	<string name="_bx_analytics_lmi_cpt_query"><![CDATA[Запрос]]></string>
+	<string name="_bx_analytics_lmi_cpt_realtime"><![CDATA[Сейчас на сайте]]></string>
+	<string name="_bx_analytics_lmi_cpt_retention"><![CDATA[Удержание]]></string>
+	<string name="_bx_analytics_lmi_cpt_revenue"><![CDATA[Доходы]]></string>
+	<string name="_bx_analytics_lmi_cpt_sales"><![CDATA[Продажи и продавцы]]></string>
+	<string name="_bx_analytics_lmi_cpt_sessions"><![CDATA[Сеансы]]></string>
+	<string name="_bx_analytics_lmi_cpt_setup"><![CDATA[Подключение]]></string>
+	<string name="_bx_analytics_lmi_cpt_sources"><![CDATA[Источники]]></string>
+	<string name="_bx_analytics_lmi_cpt_subscriptions"><![CDATA[Подписки]]></string>
+	<string name="_bx_analytics_lmi_cpt_users"><![CDATA[Пользователи]]></string>
+	<string name="_bx_analytics_metric_ai_hits"><![CDATA[Запросы ИИ]]></string>
+	<string name="_bx_analytics_metric_bot_hits"><![CDATA[Запросы]]></string>
+	<string name="_bx_analytics_metric_bot_share"><![CDATA[Доля всех запросов]]></string>
+	<string name="_bx_analytics_metric_bots"><![CDATA[Боты]]></string>
+	<string name="_bx_analytics_metric_bounce_rate"><![CDATA[Отказы]]></string>
+	<string name="_bx_analytics_metric_count"><![CDATA[Количество]]></string>
+	<string name="_bx_analytics_metric_crash_free"><![CDATA[Визиты в приложениях без сбоев]]></string>
+	<string name="_bx_analytics_metric_credits_bundles"><![CDATA[Пакеты, проданные за деньги]]></string>
+	<string name="_bx_analytics_metric_duration"><![CDATA[Длительность сеанса]]></string>
+	<string name="_bx_analytics_metric_engaged"><![CDATA[Время на странице]]></string>
+	<string name="_bx_analytics_metric_entries"><![CDATA[Входы]]></string>
+	<string name="_bx_analytics_metric_error_sessions"><![CDATA[Визиты с ошибками]]></string>
+	<string name="_bx_analytics_metric_errors"><![CDATA[Ошибки]]></string>
+	<string name="_bx_analytics_metric_exits"><![CDATA[Выходы]]></string>
+	<string name="_bx_analytics_metric_new_errors"><![CDATA[Новые ошибки]]></string>
+	<string name="_bx_analytics_metric_occurrences"><![CDATA[Случаи]]></string>
+	<string name="_bx_analytics_metric_pages_per_session"><![CDATA[Страниц за сеанс]]></string>
+	<string name="_bx_analytics_metric_pageviews"><![CDATA[Просмотры]]></string>
+	<string name="_bx_analytics_metric_per_visitor"><![CDATA[На посетителя]]></string>
+	<string name="_bx_analytics_metric_revenue_arppu"><![CDATA[Выручка на клиента]]></string>
+	<string name="_bx_analytics_metric_revenue_customers"><![CDATA[Платящие клиенты]]></string>
+	<string name="_bx_analytics_metric_revenue_gross"><![CDATA[Валовая выручка]]></string>
+	<string name="_bx_analytics_metric_revenue_net"><![CDATA[Чистая выручка]]></string>
+	<string name="_bx_analytics_metric_revenue_orders"><![CDATA[Заказы]]></string>
+	<string name="_bx_analytics_metric_revenue_refunds"><![CDATA[Возвраты]]></string>
+	<string name="_bx_analytics_metric_sales_commissions"><![CDATA[Оплаченные комиссии]]></string>
+	<string name="_bx_analytics_metric_sales_site"><![CDATA[Продажи сайта]]></string>
+	<string name="_bx_analytics_metric_sales_units"><![CDATA[Штук]]></string>
+	<string name="_bx_analytics_metric_sales_vendors"><![CDATA[Продажи продавцов]]></string>
+	<string name="_bx_analytics_metric_sbs_active"><![CDATA[Платные подписки]]></string>
+	<string name="_bx_analytics_metric_sbs_cancelled"><![CDATA[Отменённые]]></string>
+	<string name="_bx_analytics_metric_sbs_churn"><![CDATA[Отток]]></string>
+	<string name="_bx_analytics_metric_sbs_mrr"><![CDATA[MRR]]></string>
+	<string name="_bx_analytics_metric_sbs_new"><![CDATA[Новые]]></string>
+	<string name="_bx_analytics_metric_sbs_renewed"><![CDATA[Продлённые]]></string>
+	<string name="_bx_analytics_metric_sbs_trials"><![CDATA[Пробные]]></string>
+	<string name="_bx_analytics_metric_sessions"><![CDATA[Сеансы]]></string>
+	<string name="_bx_analytics_metric_visitors"><![CDATA[Уникальные посетители]]></string>
+	<string name="_bx_analytics_object_sys_profiles_friends"><![CDATA[Друзья]]></string>
+	<string name="_bx_analytics_object_sys_profiles_relations"><![CDATA[Отношения]]></string>
+	<string name="_bx_analytics_object_sys_profiles_subscriptions"><![CDATA[Подписки]]></string>
 	<string name="_bx_analytics_type_top_contents_by_likes"><![CDATA[Наилучшие по лайкам]]></string>
 	<string name="_bx_analytics_type_content_growth"><![CDATA[Общее количество]]></string>
 	<string name="_bx_analytics_type_content_speed"><![CDATA[Скорость роста]]></string>
@@ -22,19 +321,401 @@
 	<string name="_bx_analytics_type_top_by_views"><![CDATA[Наилучшие по просмотрам]]></string>
 	<string name="_bx_analytics_type_most_followed_profiles"><![CDATA[Наиболее следуемые профайлы]]></string>
 	<string name="_bx_analytics_type_most_active_profiles"><![CDATA[Наиболее активные профайлы]]></string>
+	<string name="_bx_analytics_vital_app_start"><![CDATA[Запуск приложения]]></string>
+	<string name="_bx_analytics_vital_cls"><![CDATA[Сдвиг макета (CLS)]]></string>
+	<string name="_bx_analytics_vital_fcp"><![CDATA[Первая отрисовка (FCP)]]></string>
+	<string name="_bx_analytics_vital_inp"><![CDATA[Отклик на действия (INP)]]></string>
+	<string name="_bx_analytics_vital_lcp"><![CDATA[Крупнейшая отрисовка (LCP)]]></string>
+	<string name="_bx_analytics_vital_screen_load"><![CDATA[Загрузка экрана]]></string>
+	<string name="_bx_analytics_vital_ttfb"><![CDATA[Ответ сервера (TTFB)]]></string>
 	<string name="_bx_analytics_system_text"><![CDATA[Системные]]></string>
+	<string name="_bx_analytics_tab_app_versions"><![CDATA[Версии приложений]]></string>
+	<string name="_bx_analytics_tab_apps"><![CDATA[Приложения]]></string>
+	<string name="_bx_analytics_tab_bots_ai"><![CDATA[ИИ]]></string>
+	<string name="_bx_analytics_tab_bots_all"><![CDATA[Все]]></string>
+	<string name="_bx_analytics_tab_bots_monitor"><![CDATA[Мониторинг]]></string>
+	<string name="_bx_analytics_tab_bots_script"><![CDATA[Скрипты и прочие]]></string>
+	<string name="_bx_analytics_tab_bots_search"><![CDATA[Поисковые системы]]></string>
+	<string name="_bx_analytics_tab_bots_seo"><![CDATA[SEO-инструменты]]></string>
+	<string name="_bx_analytics_tab_bots_social"><![CDATA[Превью ссылок]]></string>
+	<string name="_bx_analytics_tab_browsers"><![CDATA[Браузеры]]></string>
+	<string name="_bx_analytics_tab_channels"><![CDATA[Каналы]]></string>
+	<string name="_bx_analytics_tab_cities"><![CDATA[Города]]></string>
+	<string name="_bx_analytics_tab_countries"><![CDATA[Страны]]></string>
+	<string name="_bx_analytics_tab_devices"><![CDATA[Устройства]]></string>
+	<string name="_bx_analytics_tab_entry"><![CDATA[Входы]]></string>
+	<string name="_bx_analytics_tab_errors_all"><![CDATA[Все]]></string>
+	<string name="_bx_analytics_tab_errors_ignored"><![CDATA[Игнорируемые]]></string>
+	<string name="_bx_analytics_tab_errors_new"><![CDATA[Новые]]></string>
+	<string name="_bx_analytics_tab_errors_open"><![CDATA[Открытые]]></string>
+	<string name="_bx_analytics_tab_errors_resolved"><![CDATA[Исправленные]]></string>
+	<string name="_bx_analytics_tab_events_all"><![CDATA[Все]]></string>
+	<string name="_bx_analytics_tab_events_custom"><![CDATA[Свои события]]></string>
+	<string name="_bx_analytics_tab_events_site"><![CDATA[Действия на сайте]]></string>
+	<string name="_bx_analytics_tab_exit"><![CDATA[Выходы]]></string>
+	<string name="_bx_analytics_tab_hostnames"><![CDATA[Хосты]]></string>
+	<string name="_bx_analytics_tab_languages"><![CDATA[Языки]]></string>
+	<string name="_bx_analytics_tab_map_live"><![CDATA[Сейчас]]></string>
+	<string name="_bx_analytics_tab_map_period"><![CDATA[За период]]></string>
+	<string name="_bx_analytics_tab_os"><![CDATA[Системы]]></string>
+	<string name="_bx_analytics_tab_pages"><![CDATA[Страницы]]></string>
+	<string name="_bx_analytics_tab_platforms"><![CDATA[Платформы]]></string>
+	<string name="_bx_analytics_tab_referrer_urls"><![CDATA[Адреса источников]]></string>
+	<string name="_bx_analytics_tab_referrers"><![CDATA[Источники]]></string>
+	<string name="_bx_analytics_tab_regions"><![CDATA[Регионы]]></string>
+	<string name="_bx_analytics_tab_retention_members"><![CDATA[Участники]]></string>
+	<string name="_bx_analytics_tab_retention_visitors"><![CDATA[Все посетители]]></string>
+	<string name="_bx_analytics_tab_revenue_campaigns"><![CDATA[Кампания]]></string>
+	<string name="_bx_analytics_tab_revenue_first_touch"><![CDATA[Первый визит из]]></string>
+	<string name="_bx_analytics_tab_revenue_last_touch"><![CDATA[Последний визит из]]></string>
+	<string name="_bx_analytics_tab_revenue_platforms"><![CDATA[Платформа]]></string>
+	<string name="_bx_analytics_tab_revenue_providers"><![CDATA[Платёжная система]]></string>
+	<string name="_bx_analytics_tab_revenue_sources"><![CDATA[Источник]]></string>
+	<string name="_bx_analytics_tab_screens"><![CDATA[Экраны]]></string>
+	<string name="_bx_analytics_tab_titles"><![CDATA[Заголовки]]></string>
+	<string name="_bx_analytics_tab_utm_campaign"><![CDATA[UTM campaign]]></string>
+	<string name="_bx_analytics_tab_utm_content"><![CDATA[UTM content]]></string>
+	<string name="_bx_analytics_tab_utm_medium"><![CDATA[UTM medium]]></string>
+	<string name="_bx_analytics_tab_utm_source"><![CDATA[UTM source]]></string>
+	<string name="_bx_analytics_tab_utm_term"><![CDATA[UTM term]]></string>
+	<string name="_bx_analytics_tab_vitals_path"><![CDATA[Пути]]></string>
+	<string name="_bx_analytics_tab_vitals_route"><![CDATA[Маршруты]]></string>
 	<string name="_bx_analytics_txt_empty_title_item"><![CDATA[Пустой заголовок]]></string>
+	<string name="_bx_analytics_txt_error"><![CDATA[Ошибка]]></string>
+	<string name="_bx_analytics_txt_error_actions"><![CDATA[Изменить статус]]></string>
+	<string name="_bx_analytics_txt_error_all_time"><![CDATA[Всего случаев]]></string>
+	<string name="_bx_analytics_txt_error_http_status"><![CDATA[Статус HTTP]]></string>
+	<string name="_bx_analytics_txt_error_kind"><![CDATA[Вид]]></string>
+	<string name="_bx_analytics_txt_error_latest"><![CDATA[Последние случаи]]></string>
+	<string name="_bx_analytics_txt_error_loading"><![CDATA[Не удалось загрузить отчёт. Обновите страницу, чтобы попробовать снова.]]></string>
+	<string name="_bx_analytics_txt_error_mark_ignored"><![CDATA[Игнорировать]]></string>
+	<string name="_bx_analytics_txt_error_mark_open"><![CDATA[Открыть снова]]></string>
+	<string name="_bx_analytics_txt_error_mark_resolved"><![CDATA[Отметить исправленной]]></string>
+	<string name="_bx_analytics_txt_error_missing"><![CDATA[Эта ошибка больше не хранится.]]></string>
+	<string name="_bx_analytics_txt_error_no_stack"><![CDATA[Стек вызовов с этой ошибкой не пришёл.]]></string>
+	<string name="_bx_analytics_txt_error_overflow"><![CDATA[Другие ошибки: за час появилось больше новых видов, чем позволяет предел]]></string>
+	<string name="_bx_analytics_txt_error_report"><![CDATA[Не удалось загрузить отчёт.]]></string>
+	<string name="_bx_analytics_txt_error_source"><![CDATA[Где]]></string>
+	<string name="_bx_analytics_txt_error_stack"><![CDATA[Стек вызовов]]></string>
+	<string name="_bx_analytics_txt_error_stack_hidden"><![CDATA[Стек вызовов может показать, что посетитель ввёл или открыл, поэтому его видят только те, кому доступны данные посетителей.]]></string>
+	<string name="_bx_analytics_txt_error_status"><![CDATA[Статус]]></string>
+	<string name="_bx_analytics_txt_event"><![CDATA[Событие]]></string>
 	<string name="_bx_analytics_txt_item"><![CDATA[Позиция]]></string>
+	<string name="_bx_analytics_txt_item_n"><![CDATA[Товар №{0}]]></string>
+	<string name="_bx_analytics_txt_journey_from"><![CDATA[Куда переходили посетители со страницы {0}]]></string>
+	<string name="_bx_analytics_txt_journeys_truncated"><![CDATA[За этот период слишком много просмотров, чтобы проследить все; показаны последние визиты.]]></string>
+	<string name="_bx_analytics_txt_key_figures"><![CDATA[Основные показатели]]></string>
+	<string name="_bx_analytics_txt_last_seen"><![CDATA[Последний раз]]></string>
+	<string name="_bx_analytics_txt_latest_pageviews"><![CDATA[Последние просмотры]]></string>
+	<string name="_bx_analytics_txt_left_site"><![CDATA[Ушли]]></string>
+	<string name="_bx_analytics_txt_lifetime_value"><![CDATA[Пожизненная ценность]]></string>
+	<string name="_bx_analytics_txt_location"><![CDATA[Местоположение]]></string>
+	<string name="_bx_analytics_txt_map_label"><![CDATA[Карта посетителей по местоположению]]></string>
+	<string name="_bx_analytics_txt_map_unknown"><![CDATA[{0} посетителей из неизвестных мест на карте не показаны.]]></string>
+	<string name="_bx_analytics_txt_match_hint"><![CDATA[Путь страницы начинается с / и может содержать * вместо любых символов, например /blogs/*. Действие задаётся названием события, например signup, или название@модуль для одного модуля, например added@bx_posts.]]></string>
+	<string name="_bx_analytics_txt_member"><![CDATA[участник]]></string>
+	<string name="_bx_analytics_txt_member_name"><![CDATA[Участник]]></string>
+	<string name="_bx_analytics_txt_metric"><![CDATA[Показатель]]></string>
+	<string name="_bx_analytics_txt_minutes_ago"><![CDATA[{0} мин назад]]></string>
+	<string name="_bx_analytics_txt_ms"><![CDATA[ мс]]></string>
+	<string name="_bx_analytics_txt_n_samples"><![CDATA[Измерений: {0}]]></string>
+	<string name="_bx_analytics_txt_n_sessions"><![CDATA[Визитов: {0}]]></string>
+	<string name="_bx_analytics_txt_name"><![CDATA[Название]]></string>
+	<string name="_bx_analytics_txt_next"><![CDATA[Далее]]></string>
+	<string name="_bx_analytics_txt_no_bots"><![CDATA[За этот период ботов не было]]></string>
+	<string name="_bx_analytics_txt_no_cancellations"><![CDATA[За этот период отмен нет.]]></string>
+	<string name="_bx_analytics_txt_no_credit_orders"><![CDATA[За этот период нет заказов, оплаченных кредитами.]]></string>
+	<string name="_bx_analytics_txt_no_credits"><![CDATA[За этот период кредиты не тратили.]]></string>
+	<string name="_bx_analytics_txt_no_data"><![CDATA[Нет данных за этот период]]></string>
+	<string name="_bx_analytics_txt_no_data_yet"><![CDATA[Посещений пока нет]]></string>
+	<string name="_bx_analytics_txt_no_errors"><![CDATA[За этот период ошибок нет.]]></string>
+	<string name="_bx_analytics_txt_no_events"><![CDATA[За этот период событий не было]]></string>
+	<string name="_bx_analytics_txt_no_funnels"><![CDATA[Воронок пока нет. Воронка показывает, сколько визитов проходят цепочку шагов и где посетители уходят.]]></string>
+	<string name="_bx_analytics_txt_no_goals"><![CDATA[Целей пока нет. Цель считает визиты, которые дошли до страницы или действия, например регистрации или новой записи.]]></string>
+	<string name="_bx_analytics_txt_no_members"><![CDATA[За этот период участники не заходили]]></string>
+	<string name="_bx_analytics_txt_no_properties"><![CDATA[У этого события нет свойств]]></string>
+	<string name="_bx_analytics_txt_no_recent"><![CDATA[За последние 30 минут просмотров не было]]></string>
+	<string name="_bx_analytics_txt_no_revenue"><![CDATA[За этот период выручки нет.]]></string>
+	<string name="_bx_analytics_txt_no_subscriptions"><![CDATA[Платных подписок нет.]]></string>
+	<string name="_bx_analytics_txt_no_vitals"><![CDATA[За этот период измерений нет.]]></string>
+	<string name="_bx_analytics_txt_not_available"><![CDATA[н/д]]></string>
+	<string name="_bx_analytics_txt_not_enough_data"><![CDATA[Мало данных]]></string>
+	<string name="_bx_analytics_txt_now"><![CDATA[сейчас]]></string>
+	<string name="_bx_analytics_txt_online_now"><![CDATA[онлайн за последние {0} мин.]]></string>
+	<string name="_bx_analytics_txt_open_analytics"><![CDATA[Открыть аналитику]]></string>
+	<string name="_bx_analytics_txt_p75"><![CDATA[75-й перцентиль]]></string>
+	<string name="_bx_analytics_txt_page_of"><![CDATA[Страница {0} из {1}]]></string>
+	<string name="_bx_analytics_txt_pageviews_30m"><![CDATA[Просмотры по минутам за последние 30 минут]]></string>
+	<string name="_bx_analytics_txt_pageviews_at"><![CDATA[{0} просмотров в {1}]]></string>
+	<string name="_bx_analytics_txt_path"><![CDATA[Путь]]></string>
+	<string name="_bx_analytics_txt_pick_next"><![CDATA[Выберите страницу на шаге 2, чтобы увидеть, куда переходили после неё.]]></string>
+	<string name="_bx_analytics_txt_platform"><![CDATA[Платформа]]></string>
+	<string name="_bx_analytics_txt_prev"><![CDATA[Назад]]></string>
+	<string name="_bx_analytics_txt_previous_period"><![CDATA[Прошлый период: {0}]]></string>
+	<string name="_bx_analytics_txt_properties"><![CDATA[Свойства]]></string>
+	<string name="_bx_analytics_txt_range"><![CDATA[Период]]></string>
+	<string name="_bx_analytics_txt_rating"><![CDATA[Оценка]]></string>
+	<string name="_bx_analytics_txt_raw_value"><![CDATA[Исходное значение]]></string>
+	<string name="_bx_analytics_txt_remove_filter"><![CDATA[Убрать фильтр: {0}]]></string>
+	<string name="_bx_analytics_txt_retention_cookieless"><![CDATA[В режиме без cookie у гостей каждый день новый идентификатор, поэтому здесь все они выглядят новыми. Чтобы учитывать гостей, включите постоянные идентификаторы в настройках.]]></string>
+	<string name="_bx_analytics_txt_revenue_as_recorded"><![CDATA[Суммы в {0} в том виде, как их записал платёжный модуль, включая налоги, если их взял провайдер. Заказы, оплаченные кредитами, учтены в разделе «Кредиты».]]></string>
+	<string name="_bx_analytics_txt_revenue_commissions"><![CDATA[Счета за комиссию]]></string>
+	<string name="_bx_analytics_txt_revenue_credits_only"><![CDATA[Сайт принимает оплату только кредитами, поэтому выручка здесь пустая. Что оплачено кредитами, показано в разделе «Кредиты».]]></string>
+	<string name="_bx_analytics_txt_revenue_imported"><![CDATA[Импортировано (платформа неизвестна)]]></string>
+	<string name="_bx_analytics_txt_revenue_no_campaign"><![CDATA[Без кампании]]></string>
+	<string name="_bx_analytics_txt_revenue_no_payment"><![CDATA[Платёжный модуль не активен, поэтому новые платежи не записываются.]]></string>
+	<string name="_bx_analytics_txt_revenue_no_rate"><![CDATA[Платежи в другой валюте без курса обмена, не учтённые в итогах: {0}.]]></string>
+	<string name="_bx_analytics_txt_revenue_split"><![CDATA[Структура выручки]]></string>
+	<string name="_bx_analytics_txt_revenue_untracked"><![CDATA[Визит не отслежен]]></string>
+	<string name="_bx_analytics_txt_route"><![CDATA[Маршрут]]></string>
+	<string name="_bx_analytics_txt_sales_invoices"><![CDATA[Счета за комиссию, выставленные за период]]></string>
+	<string name="_bx_analytics_txt_sales_items"><![CDATA[Лучшие товары]]></string>
+	<string name="_bx_analytics_txt_sales_seller"><![CDATA[Продавец]]></string>
+	<string name="_bx_analytics_txt_sales_sellers"><![CDATA[Лучшие продавцы]]></string>
+	<string name="_bx_analytics_txt_sales_vendor_count"><![CDATA[Продавцов: {0}]]></string>
+	<string name="_bx_analytics_txt_samples"><![CDATA[Измерения]]></string>
+	<string name="_bx_analytics_txt_save"><![CDATA[Сохранить]]></string>
+	<string name="_bx_analytics_txt_sbs_churn_no_base"><![CDATA[В начале периода не было оплаченных подписок]]></string>
+	<string name="_bx_analytics_txt_sbs_movement"><![CDATA[Новые, продлённые и отменённые]]></string>
+	<string name="_bx_analytics_txt_sbs_no_reason"><![CDATA[Причина не указана]]></string>
+	<string name="_bx_analytics_txt_sbs_plan"><![CDATA[Тариф]]></string>
+	<string name="_bx_analytics_txt_sbs_plans"><![CDATA[Тарифы]]></string>
+	<string name="_bx_analytics_txt_sbs_reason"><![CDATA[Причина]]></string>
+	<string name="_bx_analytics_txt_sbs_reasons"><![CDATA[Почему отменяют подписку]]></string>
+	<string name="_bx_analytics_txt_sbs_trial_mrr"><![CDATA[{0} в месяц после оплаты]]></string>
+	<string name="_bx_analytics_txt_setup_actions_off"><![CDATA[Действия на сайте не записываются (Настройки > Отслеживать действия на сайте)]]></string>
+	<string name="_bx_analytics_txt_setup_actions_on"><![CDATA[Действия на сайте записываются (Настройки > Отслеживать действия на сайте)]]></string>
+	<string name="_bx_analytics_txt_setup_alerts"><![CDATA[Записываются по этим событиям системы и по новому контенту каждого контентного модуля:]]></string>
+	<string name="_bx_analytics_txt_setup_api"><![CDATA[Любой другой клиент отправляет обращения в формате JSON в сервис collect с ключом API (Студия > API). Формат описан в BxAnalyticsCollector.]]></string>
+	<string name="_bx_analytics_txt_setup_app_version"><![CDATA[Последняя версия приложения]]></string>
+	<string name="_bx_analytics_txt_setup_check"><![CDATA[Проверить этот браузер]]></string>
+	<string name="_bx_analytics_txt_setup_check_hint"><![CDATA[Узнайте, записываются ли ваши посещения сайта и что сервер видит об этом браузере. Проверка ничего не сохраняет.]]></string>
+	<string name="_bx_analytics_txt_setup_check_title"><![CDATA[Проверка этого браузера]]></string>
+	<string name="_bx_analytics_txt_setup_col_status"><![CDATA[Состояние]]></string>
+	<string name="_bx_analytics_txt_setup_core"><![CDATA[Действия на сайте записываются на сервере, на платформе запроса, который их выполнил. Платформа core нужна для cron и других задач без запроса.]]></string>
+	<string name="_bx_analytics_txt_setup_core_web"><![CDATA[Автоматически: скрипт трекера добавляется на каждую страницу классического сайта.]]></string>
+	<string name="_bx_analytics_txt_setup_core_web_exclusions"><![CDATA[Не записываются: боты, участники исключённых уровней, браузеры, которые просят не отслеживать их (если сайт это соблюдает), исключённые адреса и пути. На страницах Студии трекера нет.]]></string>
+	<string name="_bx_analytics_txt_setup_curl"><![CDATA[Пример запроса]]></string>
+	<string name="_bx_analytics_txt_setup_events"><![CDATA[Собственные события]]></string>
+	<string name="_bx_analytics_txt_setup_events_classic"><![CDATA[Классический сайт (JavaScript)]]></string>
+	<string name="_bx_analytics_txt_setup_events_hint"><![CDATA[Название: до 64 букв, цифр, пробелов и символов _ . : / -. Свойства: короткий текст, числа или true и false, всего до 2 КБ.]]></string>
+	<string name="_bx_analytics_txt_setup_events_neo"><![CDATA[NEO: веб и приложения]]></string>
+	<string name="_bx_analytics_txt_setup_events_server"><![CDATA[Сервер (PHP)]]></string>
+	<string name="_bx_analytics_txt_setup_events_warning"><![CDATA[В серверном коде вызывайте track, а не collect: collect записывает собственный адрес и агент сервера как клиента API.]]></string>
+	<string name="_bx_analytics_txt_setup_hits_24h"><![CDATA[Обращения за 24 ч]]></string>
+	<string name="_bx_analytics_txt_setup_injection_off"><![CDATA[Трекер не добавляется на страницы: его вставка (bx_analytics_tracker) выключена]]></string>
+	<string name="_bx_analytics_txt_setup_injection_on"><![CDATA[Трекер добавляется на страницы]]></string>
+	<string name="_bx_analytics_txt_setup_install"><![CDATA[Что нужно каждому клиенту]]></string>
+	<string name="_bx_analytics_txt_setup_last_hit"><![CDATA[Последнее обращение]]></string>
+	<string name="_bx_analytics_txt_setup_neo"><![CDATA[Встроено: NEO получает настройки из get_tracker_config и отправляет обращения в сервис collect. Их последние обращения указаны в таблице выше.]]></string>
+	<string name="_bx_analytics_txt_setup_neo_access"><![CDATA[Доступ к API по ключу или по источнику включён]]></string>
+	<string name="_bx_analytics_txt_setup_neo_api"><![CDATA[API включён]]></string>
+	<string name="_bx_analytics_txt_setup_neo_title"><![CDATA[NEO: веб и приложения]]></string>
+	<string name="_bx_analytics_txt_setup_never"><![CDATA[Никогда]]></string>
+	<string name="_bx_analytics_txt_setup_none"><![CDATA[Нет]]></string>
+	<string name="_bx_analytics_txt_setup_open_settings"><![CDATA[Открыть настройки]]></string>
+	<string name="_bx_analytics_txt_setup_platform_off"><![CDATA[Платформа «{0}» не отслеживается (Настройки > Платформы)]]></string>
+	<string name="_bx_analytics_txt_setup_platform_on"><![CDATA[Платформа «{0}» отслеживается]]></string>
+	<string name="_bx_analytics_txt_setup_sessions_7d"><![CDATA[Визиты за 7 дней]]></string>
+	<string name="_bx_analytics_txt_setup_status"><![CDATA[Состояние трекера]]></string>
+	<string name="_bx_analytics_txt_setup_status_nodata"><![CDATA[Нет данных]]></string>
+	<string name="_bx_analytics_txt_setup_status_off"><![CDATA[Выключено]]></string>
+	<string name="_bx_analytics_txt_setup_status_quiet"><![CDATA[Затишье]]></string>
+	<string name="_bx_analytics_txt_setup_status_receiving"><![CDATA[Поступают]]></string>
+	<string name="_bx_analytics_txt_setup_test"><![CDATA[Отправить тестовое событие]]></string>
+	<string name="_bx_analytics_txt_setup_test_dropped"><![CDATA[Тестовое событие не сохранено: этот браузер не отслеживается. Проверьте браузер, чтобы узнать причину.]]></string>
+	<string name="_bx_analytics_txt_setup_test_stored"><![CDATA[Тестовое событие сохранено: оно есть в разделе «События» как analytics_setup_test, а таблица состояния обновлена.]]></string>
+	<string name="_bx_analytics_txt_setup_test_title"><![CDATA[Проверка настройки аналитики]]></string>
+	<string name="_bx_analytics_txt_setup_test_warning"><![CDATA[Тестовое событие проходит через настоящий сборщик данных, поэтому в отчётах появится ваш визит.]]></string>
+	<string name="_bx_analytics_txt_setup_tracking_off"><![CDATA[Отслеживание выключено: ни на одной платформе ничего не записывается.]]></string>
+	<string name="_bx_analytics_txt_setup_visitors_24h"><![CDATA[Посетители за 24 ч]]></string>
+	<string name="_bx_analytics_txt_share"><![CDATA[Доля]]></string>
+	<string name="_bx_analytics_txt_sort"><![CDATA[Порядок]]></string>
+	<string name="_bx_analytics_txt_sort_asc"><![CDATA[Сначала меньшие]]></string>
+	<string name="_bx_analytics_txt_sort_desc"><![CDATA[Сначала большие]]></string>
+	<string name="_bx_analytics_txt_source"><![CDATA[Источник]]></string>
+	<string name="_bx_analytics_txt_start_page"><![CDATA[Начальная страница]]></string>
+	<string name="_bx_analytics_txt_started"><![CDATA[Начало]]></string>
+	<string name="_bx_analytics_txt_step"><![CDATA[Шаг {0}]]></string>
+	<string name="_bx_analytics_txt_step_match"><![CDATA[Шаг {0}: путь страницы или название действия]]></string>
+	<string name="_bx_analytics_txt_step_type"><![CDATA[Шаг {0}: что делает посетитель]]></string>
+	<string name="_bx_analytics_txt_threshold_good"><![CDATA[Хорошо до]]></string>
+	<string name="_bx_analytics_txt_threshold_poor"><![CDATA[Плохо выше]]></string>
+	<string name="_bx_analytics_txt_time"><![CDATA[Время]]></string>
+	<string name="_bx_analytics_txt_time_spent"><![CDATA[Проведено времени]]></string>
+	<string name="_bx_analytics_txt_timeline"><![CDATA[Хронология]]></string>
+	<string name="_bx_analytics_txt_top_paths"><![CDATA[Самые частые пути]]></string>
 	<string name="_bx_analytics_txt_date"><![CDATA[Дата]]></string>
+	<string name="_bx_analytics_txt_delete"><![CDATA[Удалить]]></string>
+	<string name="_bx_analytics_txt_deleted_member"><![CDATA[Удалённый участник]]></string>
+	<string name="_bx_analytics_txt_device"><![CDATA[Устройства]]></string>
+	<string name="_bx_analytics_txt_device_all"><![CDATA[Все устройства]]></string>
+	<string name="_bx_analytics_txt_device_desktop"><![CDATA[Компьютеры]]></string>
+	<string name="_bx_analytics_txt_device_mobile"><![CDATA[Телефоны и планшеты]]></string>
+	<string name="_bx_analytics_txt_dimension"><![CDATA[Разбить по]]></string>
+	<string name="_bx_analytics_txt_dims_event"><![CDATA[Страницы]]></string>
+	<string name="_bx_analytics_txt_dims_session"><![CDATA[Сессии]]></string>
+	<string name="_bx_analytics_txt_direct"><![CDATA[Прямой заход]]></string>
+	<string name="_bx_analytics_txt_download_csv"><![CDATA[Скачать CSV]]></string>
+	<string name="_bx_analytics_txt_dropped"><![CDATA[Ушли: {0}]]></string>
+	<string name="_bx_analytics_txt_duration"><![CDATA[Длительность]]></string>
+	<string name="_bx_analytics_txt_duration_h"><![CDATA[ ч]]></string>
+	<string name="_bx_analytics_txt_duration_m"><![CDATA[ мин]]></string>
+	<string name="_bx_analytics_txt_duration_s"><![CDATA[ с]]></string>
+	<string name="_bx_analytics_txt_edit"><![CDATA[Изменить]]></string>
+	<string name="_bx_analytics_txt_edit_funnel"><![CDATA[Изменить воронку]]></string>
+	<string name="_bx_analytics_txt_edit_goal"><![CDATA[Изменить цель]]></string>
 	<string name="_bx_analytics_txt_value"><![CDATA[Заначение]]></string>
+	<string name="_bx_analytics_txt_visitor"><![CDATA[Посетитель]]></string>
+	<string name="_bx_analytics_txt_vital_distribution"><![CDATA[Хорошо {0} · нужно улучшить {1} · плохо {2}]]></string>
+	<string name="_bx_analytics_txt_vital_thresholds"><![CDATA[Хорошо до {0}, плохо выше {1}]]></string>
+	<string name="_bx_analytics_txt_vitals_metrics"><![CDATA[Метрики: выберите одну для графика и таблиц]]></string>
+	<string name="_bx_analytics_txt_vitals_pages"><![CDATA[Страницы]]></string>
+	<string name="_bx_analytics_txt_week_n"><![CDATA[Неделя {0}]]></string>
 	<string name="_bx_analytics_txt_total_count"><![CDATA[{0} - общее количество]]></string>
+	<string name="_bx_analytics_txt_trend"><![CDATA[Динамика]]></string>
+	<string name="_bx_analytics_txt_unknown"><![CDATA[(нет)]]></string>
 	<string name="_bx_analytics_txt_count"><![CDATA[{0} - количество]]></string>
+	<string name="_bx_analytics_txt_crash_free_apps"><![CDATA[Приложения iOS и Android]]></string>
+	<string name="_bx_analytics_txt_credits_all_platforms"><![CDATA[Данные о кредитах приведены по всем платформам: модули, которые ведут кредиты, не записывают, откуда пришёл участник.]]></string>
+	<string name="_bx_analytics_txt_credits_liability"><![CDATA[Стоимость: {0}]]></string>
+	<string name="_bx_analytics_txt_credits_none"><![CDATA[Ни один установленный модуль не ведёт кредиты.]]></string>
+	<string name="_bx_analytics_txt_credits_not_in_ledger"><![CDATA[Кредиты потрачены на заказы, которых ещё нет в журнале доходов, поэтому они не разбиты по тому, за что заплачено. Импорт прошлых платежей добавит их.]]></string>
+	<string name="_bx_analytics_txt_credits_spenders"><![CDATA[Больше всего потратили]]></string>
+	<string name="_bx_analytics_txt_credits_spent"><![CDATA[Потрачено кредитов]]></string>
+	<string name="_bx_analytics_txt_credits_spent_on"><![CDATA[Что оплачено кредитами]]></string>
+	<string name="_bx_analytics_txt_credits_value"><![CDATA[Стоимость]]></string>
+	<string name="_bx_analytics_txt_csv_truncated"><![CDATA[В файл попадут первые {0} строк из {1}.]]></string>
+	<string name="_bx_analytics_txt_dash_blocks"><![CDATA[Блоки]]></string>
+	<string name="_bx_analytics_txt_dash_col_actions"><![CDATA[Действия]]></string>
+	<string name="_bx_analytics_txt_dash_col_page"><![CDATA[Страница]]></string>
+	<string name="_bx_analytics_txt_dash_create"><![CDATA[Новая страница аналитики]]></string>
+	<string name="_bx_analytics_txt_dash_create_btn"><![CDATA[Создать страницу]]></string>
+	<string name="_bx_analytics_txt_dash_create_failed"><![CDATA[Не удалось создать страницу. Введите название длиной до 100 символов и попробуйте ещё раз.]]></string>
+	<string name="_bx_analytics_txt_dash_create_hint"><![CDATA[На странице будут панель фильтров, ключевые показатели и топ-списки, видимые на сайте и в NEO. После создания она откроется в конструкторе страниц.]]></string>
+	<string name="_bx_analytics_txt_dash_disable_warning"><![CDATA[Созданные здесь страницы принадлежат модулю «Аналитика»: при его отключении они удаляются вместе с блоками.]]></string>
+	<string name="_bx_analytics_txt_dash_edit"><![CDATA[Изменить]]></string>
+	<string name="_bx_analytics_txt_dash_edit_label"><![CDATA[Изменить «{0}» в конструкторе страниц]]></string>
+	<string name="_bx_analytics_txt_dash_empty"><![CDATA[Ни на одной странице сайта нет блоков аналитики.]]></string>
+	<string name="_bx_analytics_txt_dash_intro"><![CDATA[Страницы сайта с блоками аналитики. Блоки можно добавить на любую страницу в конструкторе страниц или создать здесь новую страницу аналитики.]]></string>
+	<string name="_bx_analytics_txt_dash_neo_blocks"><![CDATA[В NEO]]></string>
+	<string name="_bx_analytics_txt_dash_neo_note"><![CDATA[В столбце «В NEO» указано, сколько блоков показывают NEO и приложения. Остальные видны только на классическом сайте.]]></string>
+	<string name="_bx_analytics_txt_dash_pages"><![CDATA[Страницы с блоками аналитики]]></string>
+	<string name="_bx_analytics_txt_dash_title"><![CDATA[Название]]></string>
 	<string name="_bx_analytics_txt_accounts"><![CDATA[Аккаунты]]></string>
+	<string name="_bx_analytics_txt_actions"><![CDATA[События и действия]]></string>
+	<string name="_bx_analytics_txt_add_funnel"><![CDATA[Добавить воронку]]></string>
+	<string name="_bx_analytics_txt_add_goal"><![CDATA[Добавить цель]]></string>
+	<string name="_bx_analytics_txt_all_platforms"><![CDATA[Все платформы]]></string>
+	<string name="_bx_analytics_txt_api_bad_params"><![CDATA[Параметры должны быть объектом JSON, например {"range": "7d"}.]]></string>
+	<string name="_bx_analytics_txt_api_col_accepts"><![CDATA[Значения]]></string>
+	<string name="_bx_analytics_txt_api_col_param"><![CDATA[Параметр]]></string>
+	<string name="_bx_analytics_txt_api_common_params"><![CDATA[Параметры фильтров любого отчёта]]></string>
+	<string name="_bx_analytics_txt_api_default"><![CDATA[По умолчанию: {0}.]]></string>
+	<string name="_bx_analytics_txt_api_disabled"><![CDATA[API выключен (Студия > API), поэтому другие клиенты не могут вызвать get_report. Здесь отчёты по-прежнему выполняются.]]></string>
+	<string name="_bx_analytics_txt_api_docs"><![CDATA[Допустимые параметры]]></string>
+	<string name="_bx_analytics_txt_api_intro"><![CDATA[Попробуйте get_report: через этот сервис NEO и другие клиенты получают отчёты. Здесь он выполняется от вашего имени, с вашими правами, и возвращает тот же JSON, что и api.php.]]></string>
+	<string name="_bx_analytics_txt_api_meta"><![CDATA[Статус {0}, {1} мс, {2}]]></string>
+	<string name="_bx_analytics_txt_api_neo"><![CDATA[Запрос элемента NEO]]></string>
+	<string name="_bx_analytics_txt_api_no_access"><![CDATA[Доступ к API по ключу и по источнику выключен, поэтому api.php отказывает всем клиентам. Здесь отчёты по-прежнему выполняются.]]></string>
+	<string name="_bx_analytics_txt_api_no_params"><![CDATA[У этого отчёта нет собственных параметров.]]></string>
+	<string name="_bx_analytics_txt_api_params"><![CDATA[Параметры (JSON)]]></string>
+	<string name="_bx_analytics_txt_api_params_hint"><![CDATA[Фильтры и собственные параметры отчёта, например {"range": "30d", "platform": "next-web", "f": {"country": "US"}}.]]></string>
+	<string name="_bx_analytics_txt_api_report"><![CDATA[Отчёт]]></string>
+	<string name="_bx_analytics_txt_api_report_params"><![CDATA[Параметры отчёта «{0}»]]></string>
+	<string name="_bx_analytics_txt_api_response"><![CDATA[Ответ]]></string>
+	<string name="_bx_analytics_txt_api_run"><![CDATA[Выполнить]]></string>
+	<string name="_bx_analytics_txt_api_running"><![CDATA[Выполняется...]]></string>
+	<string name="_bx_analytics_txt_api_srv"><![CDATA[PHP]]></string>
+	<string name="_bx_analytics_txt_api_truncated"><![CDATA[Показан только первый 1 МБ.]]></string>
+	<string name="_bx_analytics_txt_api_type_date"><![CDATA[Дата в формате ГГГГ-ММ-ДД, для периода custom.]]></string>
+	<string name="_bx_analytics_txt_api_type_dims"><![CDATA[Фильтры: объект из измерений и значений. Измерения: {0}.]]></string>
+	<string name="_bx_analytics_txt_api_type_enum"><![CDATA[Одно из: {0}.]]></string>
+	<string name="_bx_analytics_txt_api_type_enum_by"><![CDATA[Зависит от {0}: {1}.]]></string>
+	<string name="_bx_analytics_txt_api_type_int"><![CDATA[Число не меньше {0}.]]></string>
+	<string name="_bx_analytics_txt_api_type_int_range"><![CDATA[Число от {0} до {1}.]]></string>
+	<string name="_bx_analytics_txt_api_type_string"><![CDATA[Текст.]]></string>
+	<string name="_bx_analytics_txt_api_url"><![CDATA[Адрес api.php (нужен ключ API или зарегистрированный источник)]]></string>
+	<string name="_bx_analytics_txt_back_errors"><![CDATA[Все ошибки]]></string>
+	<string name="_bx_analytics_txt_back_events"><![CDATA[Все события]]></string>
+	<string name="_bx_analytics_txt_back_members"><![CDATA[Все участники]]></string>
+	<string name="_bx_analytics_txt_back_sessions"><![CDATA[Все сеансы]]></string>
+	<string name="_bx_analytics_txt_bot"><![CDATA[Бот]]></string>
+	<string name="_bx_analytics_txt_bot_pages"><![CDATA[Самые запрашиваемые страницы]]></string>
+	<string name="_bx_analytics_txt_cancel"><![CDATA[Отмена]]></string>
+	<string name="_bx_analytics_txt_chart_bots"><![CDATA[Запросы ботов по дням и категориям]]></string>
+	<string name="_bx_analytics_txt_chart_credits"><![CDATA[Кредиты по времени]]></string>
+	<string name="_bx_analytics_txt_chart_error"><![CDATA[Случаи ошибки {0} во времени]]></string>
+	<string name="_bx_analytics_txt_chart_event"><![CDATA[{0} во времени]]></string>
+	<string name="_bx_analytics_txt_chart_label"><![CDATA[График выбранного показателя за период]]></string>
+	<string name="_bx_analytics_txt_chart_mrr"><![CDATA[Ежемесячная регулярная выручка по времени]]></string>
+	<string name="_bx_analytics_txt_chart_query"><![CDATA[{0} во времени для первых строк таблицы: {1}]]></string>
+	<string name="_bx_analytics_txt_chart_revenue"><![CDATA[Выручка по времени]]></string>
+	<string name="_bx_analytics_txt_chart_visitors_7d"><![CDATA[Уникальные посетители за последние 7 дней]]></string>
+	<string name="_bx_analytics_txt_chart_vital"><![CDATA[{0}: 75-й перцентиль во времени]]></string>
+	<string name="_bx_analytics_txt_check_bot"><![CDATA[Этот браузер представляется ботом, поэтому учитывается вместе с ботами.]]></string>
+	<string name="_bx_analytics_txt_check_cross_site"><![CDATA[Запрос пришёл с другого сайта.]]></string>
+	<string name="_bx_analytics_txt_check_dnt"><![CDATA[Этот браузер просит не отслеживать его (Do Not Track или Global Privacy Control), и сайт это соблюдает.]]></string>
+	<string name="_bx_analytics_txt_check_excluded_ip"><![CDATA[Ваш адрес исключён (Настройки > Исключённые адреса).]]></string>
+	<string name="_bx_analytics_txt_check_excluded_level"><![CDATA[Ваш уровень участника исключён (Настройки > Исключённые уровни).]]></string>
+	<string name="_bx_analytics_txt_check_excluded_path"><![CDATA[Главная страница входит в исключённые пути (Настройки > Исключённые пути).]]></string>
+	<string name="_bx_analytics_txt_check_geo"><![CDATA[Страна]]></string>
+	<string name="_bx_analytics_txt_check_geo_header"><![CDATA[{1}, из заголовка {0}]]></string>
+	<string name="_bx_analytics_txt_check_geo_none"><![CDATA[В этом запросе нет заголовка со страной]]></string>
+	<string name="_bx_analytics_txt_check_geo_off"><![CDATA[Не определяется: источник местоположения выключен]]></string>
+	<string name="_bx_analytics_txt_check_ip"><![CDATA[Записываемый адрес]]></string>
+	<string name="_bx_analytics_txt_check_link_members_off"><![CDATA[Визиты записываются, но не связываются с вашей учётной записью: привязка участников к визитам выключена.]]></string>
+	<string name="_bx_analytics_txt_check_no_hits"><![CDATA[Адрес страницы не принадлежит сайту.]]></string>
+	<string name="_bx_analytics_txt_check_not_tracked"><![CDATA[Визиты из этого браузера не записываются]]></string>
+	<string name="_bx_analytics_txt_check_platform_off"><![CDATA[Классического сайта нет среди отслеживаемых платформ (Настройки > Платформы).]]></string>
+	<string name="_bx_analytics_txt_check_rate_limited"><![CDATA[С вашего адреса отправлено больше, чем позволяют ограничения (Настройки > Обращений в минуту, Новых визитов в час). Обращения отбрасываются до следующей минуты, а новые визиты до следующего часа.]]></string>
+	<string name="_bx_analytics_txt_check_remote_addr"><![CDATA[Подключение с адреса (REMOTE_ADDR)]]></string>
+	<string name="_bx_analytics_txt_check_tracked"><![CDATA[Визиты из этого браузера записываются]]></string>
+	<string name="_bx_analytics_txt_check_tracking_off"><![CDATA[Отслеживание выключено.]]></string>
+	<string name="_bx_analytics_txt_cohort"><![CDATA[Первый визит, неделя]]></string>
+	<string name="_bx_analytics_txt_conversions"><![CDATA[Визитов: {0}, посетителей: {1}]]></string>
+	<string name="_bx_analytics_txt_copied"><![CDATA[Скопировано]]></string>
+	<string name="_bx_analytics_txt_copy"><![CDATA[Копировать]]></string>
 	<string name="_bx_analytics_txt_export_to_csv_title"><![CDATA[Экспорт в файл CSV]]></string>
+	<string name="_bx_analytics_txt_fatal"><![CDATA[Фатальная]]></string>
+	<string name="_bx_analytics_txt_filter_by"><![CDATA[Показать только {0}]]></string>
+	<string name="_bx_analytics_txt_first_seen"><![CDATA[Впервые]]></string>
+	<string name="_bx_analytics_txt_funnel_delete_confirm"><![CDATA[Удалить воронку «{0}»?]]></string>
+	<string name="_bx_analytics_txt_funnel_hint"><![CDATA[Заполните от 2 до 6 шагов в том порядке, в котором их проходят посетители. Визит засчитывается для шага, если дошёл до него после предыдущего. Каждый шаг: путь страницы (/blogs/*) или действие (signup, added@bx_posts).]]></string>
+	<string name="_bx_analytics_txt_funnel_invalid"><![CDATA[Введите название и от 2 до 6 шагов. Пути страниц начинаются с / (или *); названия действий состоят из букв, цифр и символов _ . : / -]]></string>
+	<string name="_bx_analytics_txt_goal_badge_event"><![CDATA[Действие]]></string>
+	<string name="_bx_analytics_txt_goal_badge_page"><![CDATA[Страница]]></string>
+	<string name="_bx_analytics_txt_goal_delete_confirm"><![CDATA[Удалить цель «{0}»?]]></string>
+	<string name="_bx_analytics_txt_goal_invalid"><![CDATA[Введите название и путь страницы, начинающийся с / (или *), или название действия из букв, цифр и символов _ . : / -]]></string>
+	<string name="_bx_analytics_txt_goal_match"><![CDATA[Путь страницы или название действия]]></string>
+	<string name="_bx_analytics_txt_goal_type"><![CDATA[Достигается, когда посетитель]]></string>
+	<string name="_bx_analytics_txt_goal_type_event"><![CDATA[Совершает действие]]></string>
+	<string name="_bx_analytics_txt_goal_type_page"><![CDATA[Открывает страницу]]></string>
+	<string name="_bx_analytics_txt_guest"><![CDATA[Гость]]></string>
+	<string name="_bx_analytics_txt_import_again"><![CDATA[Импортировать заново]]></string>
+	<string name="_bx_analytics_txt_import_confirm"><![CDATA[Прочитать все прошлые платежи заново? Платежи, которые уже есть, будут пропущены.]]></string>
+	<string name="_bx_analytics_txt_import_continue"><![CDATA[Продолжить сейчас]]></string>
+	<string name="_bx_analytics_txt_import_done"><![CDATA[Прошлые платежи импортированы: всего {0}.]]></string>
+	<string name="_bx_analytics_txt_import_hint"><![CDATA[Импорт выполняется каждый час, пока не завершится. Возвращённые заказы импортировать нельзя, потому что платёжный модуль их удаляет, а у импортированных платежей нет платформы и визита.]]></string>
+	<string name="_bx_analytics_txt_import_running"><![CDATA[Импорт прошлых платежей: импортировано {0}.]]></string>
+	<string name="_bx_analytics_txt_import_title"><![CDATA[Прошлые платежи]]></string>
 	<string name="_bx_analytics_txt_total_content"><![CDATA[Весь контент]]></string>
 	<string name="_bx_analytics_type_top_by_views_label"><![CDATA[Количество просмотров]]></string>
 	<string name="_bx_analytics_type_top_by_likes_label"><![CDATA[Количество лайков]]></string>
 	<string name="_bx_analytics_type_top_by_followers_label"><![CDATA[Количество последователей]]></string>
 	<string name="_bx_analytics_type_top_by_activity_label"><![CDATA[Количество контента]]></string>  
+	<string name="_bx_analytics_acl_action_manage"><![CDATA[Управление целями и воронками аналитики]]></string>
 	<string name="_bx_analytics_acl_action_use_analytics"><![CDATA[Использовать Аналитику]]></string>	
+	<string name="_bx_analytics_acl_action_view_people"><![CDATA[Видеть участников в аналитике]]></string>
+	<string name="_bx_analytics_acl_action_view_revenue"><![CDATA[Просмотр выручки в аналитике]]></string>
+	<string name="_bx_analytics_action_comment"><![CDATA[Комментарий]]></string>
+	<string name="_bx_analytics_action_connection"><![CDATA[Связь]]></string>
+	<string name="_bx_analytics_action_content_add"><![CDATA[Создан материал]]></string>
+	<string name="_bx_analytics_action_login"><![CDATA[Вход]]></string>
+	<string name="_bx_analytics_action_profile_add"><![CDATA[Создан профиль]]></string>
+	<string name="_bx_analytics_action_purchase"><![CDATA[Покупка]]></string>
+	<string name="_bx_analytics_action_renewal"><![CDATA[Продление]]></string>
+	<string name="_bx_analytics_action_signup"><![CDATA[Регистрация]]></string>
+	<string name="_bx_analytics_action_trial_start"><![CDATA[Начало пробного периода]]></string>
+	<string name="_bx_analytics_action_vote"><![CDATA[Реакция]]></string>
 </resources>

+ 9 - 0
modules/boonex/russian/data/langs/bx_credits/ru.xml

@@ -314,6 +314,15 @@
 
 
     <!-- Checkout process -->
+    <string name="_bx_credits_txt_analytics_balance"><![CDATA[На балансах участников]]></string>
+    <string name="_bx_credits_txt_analytics_bought"><![CDATA[Куплено]]></string>
+    <string name="_bx_credits_txt_analytics_granted"><![CDATA[Начислено администрацией]]></string>
+    <string name="_bx_credits_txt_analytics_sent"><![CDATA[Переведено участникам]]></string>
+    <string name="_bx_credits_txt_analytics_service_in"><![CDATA[Начислено модулями]]></string>
+    <string name="_bx_credits_txt_analytics_service_out"><![CDATA[Списано модулями]]></string>
+    <string name="_bx_credits_txt_analytics_spent"><![CDATA[Потрачено на покупки]]></string>
+    <string name="_bx_credits_txt_analytics_top_spenders"><![CDATA[Больше всего потратили]]></string>
+    <string name="_bx_credits_txt_analytics_withdrawn"><![CDATA[Выведено]]></string>
     <string name="_bx_credits_txt_checkout"><![CDATA[Подтверждение оплаты]]></string>
     <string name="_bx_credits_txt_checkout_to"><![CDATA[Заплатить {0} за:]]></string>
     <string name="_bx_credits_txt_checkout_price_money"><![CDATA[Цена:]]></string>

+ 4 - 0
modules/boonex/russian/data/langs/bx_payment/ru.xml

@@ -438,6 +438,10 @@
     <string name="_bx_payment_txt_all_clients"><![CDATA[Все клиенты]]></string>
     <string name="_bx_payment_txt_all_modules"><![CDATA[Все модули]]></string>
     <string name="_bx_payment_txt_all_items"><![CDATA[Все продукты]]></string>
+    <string name="_bx_payment_txt_analytics_invoices_issued"><![CDATA[Выставлено]]></string>
+    <string name="_bx_payment_txt_analytics_invoices_overdue"><![CDATA[Просрочено]]></string>
+    <string name="_bx_payment_txt_analytics_invoices_paid"><![CDATA[Оплачено]]></string>
+    <string name="_bx_payment_txt_analytics_invoices_unpaid"><![CDATA[Не оплачено]]></string>
     <string name="_bx_payment_txt_status_scheduled"><![CDATA[Запланированно]]></string>
     <string name="_bx_payment_txt_status_trial"><![CDATA[Пробный]]></string>
     <string name="_bx_payment_txt_status_active"><![CDATA[Активно]]></string>

+ 2 - 1
neo/AGENTS.md

@@ -97,12 +97,13 @@ patches/         patch-package patches, applied on postinstall
 ## Talking to UNA
 
 - Call UNA through `fetcher` from `app/lib/fetcher`:
-  - `fetcher(path)` does a GET; `fetcher([path, token, body])` POSTs `body`. The full signature is `fetcher(req, useProxy = false, { signal, timeoutMs, maxAttempts, silent, onUploadProgress })`.
+  - `fetcher(path)` does a GET; `fetcher([path, token, body])` POSTs `body`. The full signature is `fetcher(req, useProxy = false, { signal, timeoutMs, maxAttempts, silent, onUploadProgress, keepalive })`.
   - It appends `&lang=`, so the path must already contain `?r=`.
   - It returns UNA's envelope `{status, module, method, params, data, hash}`; read `.data`. A body that isn't JSON comes back as `{}`.
   - GETs are retried three times on network errors, never on a timeout (`config.fetch_timeout_ms`, 30 s).
   - On web it calls same-origin `/api/...`, and `proxy.js` forwards that to UNA with the API key and the viewer's cookies. Native calls `UNA_URL` directly with `Origin: APP_ORIGIN` and the session cookie.
 - Routes are `/api.php?r={module}/{method}/{class}` with `params[]=...` or `params=[...]`; the class defaults to `Module`, system services use `Templ*` classes. UNA only allows services marked safe or public. Many endpoints come from UNA data (`'/api.php?r=' + data.request_url`); fixed ones live in `appSetting('urls', ...)`.
+- `fetcher` sends `X-Neo-Platform` (`web`, `ios`, `android`; web only through the same-origin proxy), so UNA's analytics records site actions with the right client; when the site keeps persistent visitor ids it also sends `X-Analytics-Vid`, so those actions join the device's visit. Pageviews and screen views go to UNA's analytics module from `AnalyticsTracker` (`app/lib/analytics`, mounted in `apps/next/app/providers.js` and the Expo tabs layout); send custom events with `trackEvent(name, props)` from the same module.
 - Three places bypass `fetcher` on purpose: the web page shell above, AI chat streaming (`ui/molecules/ai-agent/helper.js`) and direct uploads (`lib/util/upload.ts`).
 - If the page JSON can't be parsed (a PHP fatal or an HTML error page), web shows the maintenance screen for the whole page and home falls back to a static splash. Fix the UNA service; the [`una-api` skill](.agents/skills/una-api/SKILL.md) lists the rules, the usual cause being a block that fails for guests.
 - Auth: `useCurrentUser()` from `app/context/user` returns `{ currentUser, setCurrentUser }`. `currentUser` is `null` until known, `false` for a guest and an object when signed in. `Root` and the layouts hold rendering until it is known, so components rarely need to wait themselves. The guest sign-in prompt is `layout.show_login_modal`.

+ 1 - 0
neo/README.md

@@ -59,6 +59,7 @@ APP_ORIGIN=neo://app
 | `EAS_PROJECT_ID` | Optional. EAS builds and updates. |
 | `REDIS_URL` | Multi-tenant mode only: the domain map. |
 | `PORT`, `HOST`, `PROTO`, `HTTPS` | Self-hosted web server (`apps/next/pm2.config.js`). |
+| `NEO_EDGE` | Optional. What sits in front of the web server, so the proxy passes UNA only the visitor address and location headers it set: `vercel` (the default on Vercel), `cloudflare`, `cloudfront`, `proxy` (the default: a reverse proxy or load balancer that appends to `X-Forwarded-For`, such as nginx with `$proxy_add_x_forwarded_for`, Caddy or Railway) or `none` (browsers reach `next start` directly). |
 | `NEO_IMAGES_ALLOW_LOCAL_IP` | Local development only. `1` lets `next/image` load images from a UNA on your machine or network; see below. |
 
 Next.js refuses to optimize an image whose host resolves to a private IP address (`127.0.0.1`, `::1`, `10.x`, `192.168.x`, ...). Against a local UNA (`UNA_URL=http://localhost:8088`), the dev server then logs "upstream image ... hostname resolved to private IP" and `/_next/image` answers 400 for every UNA image. Set `NEO_IMAGES_ALLOW_LOCAL_IP=1` in `.env.local` and restart the dev server; it turns on Next's `images.dangerouslyAllowLocalIP`.

+ 2 - 0
neo/apps/expo/app/(tabs)/_layout.js

@@ -9,6 +9,7 @@ import { QueryClientProvider } from '@tanstack/react-query'
 import { queryClient } from 'app/lib/platform/query-client'
 import { Provider as JotaiProvider } from 'jotai'
 import { NetworkStatus } from 'app/ui/molecules/system/net-info';
+import { AnalyticsTracker } from 'app/lib/analytics';
 
 //import RNScreenshotPrevent, { addListener } from 'react-native-screenshot-prevent';
 import i18n from 'i18next';
@@ -103,6 +104,7 @@ const AppLayout = React.memo(() => {
                         <Shell {...shellProps}>
                             <Tabs />
                             <PortalHost />
+                            <AnalyticsTracker />
                         </Shell>
                     </QueryClientProvider>
                 </NetworkStatus>

+ 14 - 1
neo/apps/expo/index.js

@@ -21,9 +21,20 @@ import { useColorScheme } from 'react-native';
 import { KeyboardProvider } from "react-native-keyboard-controller";
 import { SafeAreaProvider, initialWindowMetrics } from 'react-native-safe-area-context';
 import { applyNativeFonts } from 'app/design/fonts/native-fonts';
+import { installGlobalErrorCapture } from 'app/lib/analytics/global-errors';
+import { AnalyticsErrorBoundary } from 'app/lib/analytics/error-boundary';
+
+// UNA's analytics: JS errors from here on (a fatal one is kept and sent on the next start).
+installGlobalErrorCapture();
 
 applyNativeFonts();
 
+/**
+ * The whole app failed to render: report it as fatal, offer to start it again. Required here,
+ * not imported, so the static pages don't load before the app does.
+ */
+const renderRootError = (error, reset) => require('app/lib/app-static').appStatic('page_error', { error, reset });
+
 SplashScreen.preventAutoHideAsync().catch(() => {});
 
 /** Absolute last resort: never leave the native splash up forever (Android hang). */
@@ -59,7 +70,9 @@ export function App() {
 		<SafeAreaProvider initialMetrics={initialWindowMetrics}>
 			<KeyboardProvider>
 				<GestureHandlerRootView style={{ flex: 1, backgroundColor }}>
-					{expoRootComponent}
+					<AnalyticsErrorBoundary fatal fallback={renderRootError}>
+						{expoRootComponent}
+					</AnalyticsErrorBoundary>
 					{showSplashScreen && <View style={{ position: 'absolute', top: 0, left: 0, right: 0, bottom: 0 }}><InitialScreen /></View>}
 				</GestureHandlerRootView>
 			</KeyboardProvider>

+ 2 - 0
neo/apps/next/app/[...path]/page.js

@@ -42,6 +42,8 @@ const getData = cache(async (path, searchParamsKey) => {
     const fetchHeaders = {
         cookie: cookieString,
         authorization: 'Bearer ' + (tenantUnaKey ?? UNA_API_KEY),
+        // The page is rendered for a NEO web viewer: UNA records site actions with this platform.
+        'x-neo-platform': 'web',
     };
 
     let l = tenantUnaUrl ?? UNA_URL;

+ 9 - 2
neo/apps/next/app/error.tsx

@@ -1,11 +1,18 @@
 "use client";
+import { useEffect } from 'react'
 import { appStatic } from 'app/lib/app-static'
+import { trackError } from 'app/lib/analytics'
 
-export default function GlobalError({error, reset}: {
+export default function ErrorPage({error, reset}: {
     error: Error & { digest?: string };
     reset: () => void;
 }) {
+    // Next catches render errors in its own boundaries: they never reach window listeners.
+    useEffect(() => {
+        trackError(error, { kind: 'react', fatal: true });
+    }, [error]);
+
     return (
         appStatic('page_error', {error: error, reset: reset})
     );
-}
+}

+ 74 - 4
neo/apps/next/app/global-error.tsx

@@ -1,11 +1,81 @@
 "use client";
-import { appStatic } from 'app/lib/app-static'
+import { useEffect, useState } from 'react'
+import { trackError } from 'app/lib/analytics'
 
-export default function GlobalError({error, reset}: {
+type Translations = Record<string, unknown>;
+
+/** The language NEO stores for the visitor (web layout settings), else the browser's. */
+const LANG_CODE_COOKIE = 'neo_lang_code';
+
+function getVisitorLanguage(): string {
+    const cookie = document.cookie.split('; ').find((row) => row.startsWith(`${LANG_CODE_COOKIE}=`));
+    let code = '';
+    try {
+        code = cookie ? decodeURIComponent(cookie.slice(LANG_CODE_COOKIE.length + 1)) : '';
+    } catch {
+        code = '';
+    }
+    return (code || navigator.language || 'en').toLowerCase().split('-')[0] || 'en';
+}
+
+/**
+ * Replaces the root layout when it fails, so it renders its own document and needs nothing
+ * the layout provides: no Providers (i18n, theme), no global styles. The text is English
+ * until the translations (the lazy chunk `changeI18nLanguage` loads too) arrive, then the
+ * visitor's language; the colours are the browser's light or dark system colours.
+ */
+export default function GlobalError({error, reset, retry}: {
     error: Error & { digest?: string };
     reset: () => void;
+    retry?: () => void;
 }) {
+    const [text, setText] = useState<{ lang: string; translations?: Translations }>({ lang: 'en' });
+
+    useEffect(() => {
+        trackError(error, { kind: 'react', fatal: true });
+    }, [error]);
+
+    useEffect(() => {
+        let active = true;
+        import('app/customization/translation')
+            .then(({ resources }) => {
+                const lang = getVisitorLanguage();
+                const bundle = (resources as Record<string, { translation?: Translations } | undefined>)[lang];
+                if (active && bundle?.translation) setText({ lang, translations: bundle.translation });
+            })
+            .catch(() => {
+                // the chunk failed to load: English stays
+            });
+        return () => {
+            active = false;
+        };
+    }, []);
+
+    const t = (key: string): string => {
+        const value = text.translations?.[key];
+        return typeof value === 'string' ? value : key;
+    };
+    const title = t('Something went wrong');
+
     return (
-        appStatic('page_error', {error: error, reset: reset})
+        <html lang={text.lang}>
+            <body style={{ margin: 0, colorScheme: 'light dark', background: 'Canvas', color: 'CanvasText', fontFamily: 'system-ui, -apple-system, sans-serif' }}>
+                <title>{title}</title>
+                <main style={{ boxSizing: 'border-box', minHeight: '100vh', padding: 16, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 16, textAlign: 'center' }}>
+                    <h1 style={{ margin: 0, fontSize: 24, lineHeight: 1.3 }}>{title}</h1>
+                    <p style={{ margin: 0, maxWidth: 480, fontSize: 16, lineHeight: 1.5 }}>
+                        {t('Something didn’t go as planned. Please try again or reload the page.')}
+                    </p>
+                    <div style={{ display: 'flex', flexWrap: 'wrap', gap: 12, justifyContent: 'center' }}>
+                        <button type="button" onClick={() => (retry ?? reset)()} style={{ minHeight: 44, padding: '0 20px', fontSize: 16, borderRadius: 8, cursor: 'pointer' }}>
+                            {t('Retry')}
+                        </button>
+                        <button type="button" onClick={() => window.location.reload()} style={{ minHeight: 44, padding: '0 20px', fontSize: 16, borderRadius: 8, cursor: 'pointer' }}>
+                            {t('Reload page')}
+                        </button>
+                    </div>
+                </main>
+            </body>
+        </html>
     );
-}
+}

+ 3 - 0
neo/apps/next/app/providers.js

@@ -13,6 +13,7 @@ import Subscriber from 'app/ui/molecules/system/subscriber'
 import InterfaceSounds from 'app/ui/molecules/system/interface-sounds'
 import { useLayoutSettings } from 'app/context/layout-settings'
 import { addI18nResources, changeI18nLanguage } from 'app/lib/i18n-resources'
+import { AnalyticsTracker } from 'app/lib/analytics'
 
 // The Google OAuth popup returns to a page of this app: close it and hand the
 // result to the opener. Here, not in the lazily loaded Google button, so it
@@ -100,7 +101,9 @@ export function Providers({ children, initialLang = 'en', initialResources }) {
                     <SpeedInsights />
                     {children}
                     <Subscriber />
+                    <AnalyticsTracker />
                     <InterfaceSounds />
+
                 </QueryClientProvider>
             </JotaiProvider>
         </I18nextProvider>

+ 18 - 0
neo/apps/next/instrumentation-client.ts

@@ -0,0 +1,18 @@
+import { installGlobalErrorCapture } from 'app/lib/analytics/global-errors';
+import { startScreenLoad } from 'app/lib/analytics/health';
+
+// Runs before hydration: errors from the first scripts on reach UNA's analytics. They wait in
+// app/lib/analytics/health until the tracker knows whether the site takes them.
+installGlobalErrorCapture();
+
+/** A router navigation starts: the time to its page rendered is its `screen_load`. */
+export function onRouterTransitionStart(url: string): void {
+    try {
+        const target = new URL(url, window.location.href);
+        // same page (a hash change, a refresh): nothing renders that the tracker would see
+        if (target.pathname === window.location.pathname && target.search === window.location.search) return;
+        startScreenLoad();
+    } catch {
+        // never break a navigation
+    }
+}

+ 38 - 0
neo/apps/next/lib/client-headers.test.ts

@@ -0,0 +1,38 @@
+// Run with `node --test apps/next/lib/client-headers.test.ts` from the NEO root (Node 24 strips the types).
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { dropForeignClientHeaders, resolveEdge } from './client-headers.ts';
+
+const sent = (): Headers =>
+    new Headers({
+        'x-forwarded-for': '203.0.113.77',
+        'x-real-ip': '203.0.113.78',
+        'cf-connecting-ip': '203.0.113.79',
+        'true-client-ip': '203.0.113.80',
+        'cf-ipcountry': 'KP',
+        'cf-ipcity': 'Pyongyang',
+        'x-vercel-ip-country': 'DE',
+        'x-vercel-ip-city': 'Berlin',
+        'cloudfront-viewer-country': 'FR',
+        'x-country-code': 'US',
+        'accept-language': 'en',
+    });
+
+const names = (headers: Headers): string[] => [...headers.keys()].sort();
+
+test('the edge comes from NEO_EDGE, then Vercel, then a proxy', () => {
+    assert.equal(resolveEdge({ NEO_EDGE: 'Cloudflare' }), 'cloudflare');
+    assert.equal(resolveEdge({ NEO_EDGE: 'none', VERCEL: '1' }), 'none');
+    assert.equal(resolveEdge({ VERCEL: '1' }), 'vercel');
+    assert.equal(resolveEdge({ NEO_EDGE: 'fastly' }), 'proxy');
+    assert.equal(resolveEdge({}), 'proxy');
+});
+
+test('only the edge headers reach UNA', () => {
+    assert.deepEqual(names(dropForeignClientHeaders(sent(), 'vercel')), ['accept-language', 'x-forwarded-for', 'x-real-ip', 'x-vercel-ip-city', 'x-vercel-ip-country']);
+    assert.deepEqual(names(dropForeignClientHeaders(sent(), 'cloudflare')), ['accept-language', 'cf-connecting-ip', 'cf-ipcity', 'cf-ipcountry', 'x-forwarded-for']);
+    assert.deepEqual(names(dropForeignClientHeaders(sent(), 'cloudfront')), ['accept-language', 'cloudfront-viewer-country', 'x-forwarded-for']);
+    assert.deepEqual(names(dropForeignClientHeaders(sent(), 'proxy')), ['accept-language', 'x-forwarded-for', 'x-real-ip']);
+    assert.deepEqual(names(dropForeignClientHeaders(sent(), 'none')), ['accept-language']);
+    assert.deepEqual(names(dropForeignClientHeaders(sent(), 'unknown')), ['accept-language']);
+});

+ 58 - 0
neo/apps/next/lib/client-headers.ts

@@ -0,0 +1,58 @@
+/**
+ * Request headers that tell UNA who the visitor is: the client address and the location a
+ * CDN adds. UNA reads them from requests the web proxy sends with the API key, so only the
+ * edge in front of NEO may set them. An edge sets its own headers and passes the rest
+ * through: behind Vercel a client can still add `CF-IPCountry`, and with nothing in front
+ * of Next (`next start` on a public port) a client's own `X-Forwarded-For` reaches UNA
+ * unchanged, since Next adds the socket address only when there is none.
+ *
+ * `NEO_EDGE` names the edge:
+ * - `vercel` (the default on Vercel): `X-Forwarded-For`, `X-Real-IP`, `X-Vercel-IP-*`
+ * - `cloudflare`: `X-Forwarded-For`, `CF-Connecting-IP`, `CF-IP*`
+ * - `cloudfront`: `X-Forwarded-For`, `CloudFront-Viewer-*`
+ * - `proxy` (the default elsewhere): `X-Forwarded-For` and `X-Real-IP` from a reverse proxy
+ *   or load balancer that appends the address it got the request from (nginx with
+ *   `$proxy_add_x_forwarded_for`, Caddy, Railway); no location
+ * - `none`: browsers reach Next directly, nothing is forwarded
+ */
+
+const ADDRESS = ['x-forwarded-for', 'x-real-ip', 'cf-connecting-ip', 'true-client-ip'];
+const LOCATION = ['x-country-code', 'x-appengine-country'];
+const LOCATION_PREFIXES = ['cf-ip', 'x-vercel-ip-', 'cloudfront-viewer-'];
+
+type EdgeRules = { names: string[]; prefixes: string[] };
+
+const EDGES: Record<string, EdgeRules> = {
+    vercel: { names: ['x-forwarded-for', 'x-real-ip'], prefixes: ['x-vercel-ip-'] },
+    cloudflare: { names: ['x-forwarded-for', 'cf-connecting-ip'], prefixes: ['cf-ip'] },
+    cloudfront: { names: ['x-forwarded-for'], prefixes: ['cloudfront-viewer-'] },
+    proxy: { names: ['x-forwarded-for', 'x-real-ip'], prefixes: [] },
+    none: { names: [], prefixes: [] },
+};
+
+/** The edge in front of NEO from the environment: `NEO_EDGE`, else `vercel` on Vercel, else `proxy`. */
+export function resolveEdge(env: Record<string, string | undefined> = {}): string {
+    const edge = String(env.NEO_EDGE || '').trim().toLowerCase();
+    if (Object.hasOwn(EDGES, edge)) return edge;
+    return env.VERCEL ? 'vercel' : 'proxy';
+}
+
+function isClientHeader(name: string): boolean {
+    return ADDRESS.includes(name) || LOCATION.includes(name) || LOCATION_PREFIXES.some((prefix) => name.startsWith(prefix));
+}
+
+function isEdgeHeader(name: string, edge: string): boolean {
+    const rules = EDGES[edge] ?? { names: [], prefixes: [] };
+    return rules.names.includes(name) || rules.prefixes.some((prefix) => name.startsWith(prefix));
+}
+
+/** Delete, in place, the address and location headers the edge didn't set. Returns the headers. */
+export function dropForeignClientHeaders(headers: Headers, edge: string): Headers {
+    const names: string[] = [];
+    headers.forEach((_value, name) => {
+        const lower = name.toLowerCase();
+        if (isClientHeader(lower) && !isEdgeHeader(lower, edge)) names.push(name);
+    });
+    for (const name of names) headers.delete(name);
+    return headers;
+}

Unele fișiere nu au fost afișate deoarece prea multe fișiere au fost modificate în acest diff