add privacy-first analytics with progressive event collection
All checks were successful
deploy / deploy (push) Successful in 3m20s
All checks were successful
deploy / deploy (push) Successful in 3m20s
Three-layer pipeline: Plug for all HTTP requests (no JS needed), LiveView hook for SPA navigations, JS hook for screen width. ETS-backed buffer batches writes to SQLite every 10s. Daily-rotating salt for visitor hashing. Includes admin dashboard with date ranges, visitor trends, top pages, sources, devices, and e-commerce conversion funnel. Oban cron for 12-month data retention. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
100
lib/berrypod_web/analytics_hook.ex
Normal file
100
lib/berrypod_web/analytics_hook.ex
Normal file
@@ -0,0 +1,100 @@
|
||||
defmodule BerrypodWeb.AnalyticsHook do
|
||||
@moduledoc """
|
||||
LiveView on_mount hook for analytics — Layer 2 of the progressive pipeline.
|
||||
|
||||
Reads analytics data from the session (set by the Plugs.Analytics plug) and
|
||||
tracks subsequent SPA navigations via handle_params. Skips the initial mount
|
||||
since the plug already recorded that pageview.
|
||||
|
||||
Also handles the `analytics:screen` event from the JS hook (Layer 3) to
|
||||
capture screen width for device classification.
|
||||
"""
|
||||
|
||||
import Phoenix.Component, only: [assign: 3]
|
||||
import Phoenix.LiveView, only: [attach_hook: 4, connected?: 1]
|
||||
|
||||
alias Berrypod.Analytics
|
||||
|
||||
def on_mount(:track, _params, session, socket) do
|
||||
socket =
|
||||
socket
|
||||
|> assign(:analytics_visitor_hash, session["analytics_visitor_hash"])
|
||||
|> assign(:analytics_browser, session["analytics_browser"])
|
||||
|> assign(:analytics_os, session["analytics_os"])
|
||||
|> assign(:analytics_referrer, session["analytics_referrer"])
|
||||
|> assign(:analytics_referrer_source, session["analytics_referrer_source"])
|
||||
|> assign(:analytics_utm_source, session["analytics_utm_source"])
|
||||
|> assign(:analytics_utm_medium, session["analytics_utm_medium"])
|
||||
|> assign(:analytics_utm_campaign, session["analytics_utm_campaign"])
|
||||
|> assign(:analytics_screen_size, session["analytics_screen_size"])
|
||||
|> assign(:analytics_country_code, session["country_code"])
|
||||
|> assign(:analytics_initial_mount, true)
|
||||
|
||||
socket =
|
||||
if connected?(socket) and socket.assigns.analytics_visitor_hash do
|
||||
socket
|
||||
|> attach_hook(:analytics_params, :handle_params, &handle_analytics_params/3)
|
||||
|> attach_hook(:analytics_events, :handle_event, &handle_analytics_event/3)
|
||||
else
|
||||
socket
|
||||
end
|
||||
|
||||
{:cont, socket}
|
||||
end
|
||||
|
||||
defp handle_analytics_params(_params, uri, socket) do
|
||||
# Skip the initial mount — the plug already recorded this pageview
|
||||
if socket.assigns.analytics_initial_mount do
|
||||
{:cont, assign(socket, :analytics_initial_mount, false)}
|
||||
else
|
||||
# Skip if admin user is browsing
|
||||
if admin?(socket) do
|
||||
{:cont, socket}
|
||||
else
|
||||
pathname = URI.parse(uri).path
|
||||
|
||||
Analytics.track_pageview(%{
|
||||
pathname: pathname,
|
||||
visitor_hash: socket.assigns.analytics_visitor_hash,
|
||||
referrer: socket.assigns.analytics_referrer,
|
||||
referrer_source: socket.assigns.analytics_referrer_source,
|
||||
utm_source: socket.assigns.analytics_utm_source,
|
||||
utm_medium: socket.assigns.analytics_utm_medium,
|
||||
utm_campaign: socket.assigns.analytics_utm_campaign,
|
||||
country_code: socket.assigns.analytics_country_code,
|
||||
screen_size: socket.assigns.analytics_screen_size,
|
||||
browser: socket.assigns.analytics_browser,
|
||||
os: socket.assigns.analytics_os
|
||||
})
|
||||
|
||||
# Clear referrer/UTMs after first SPA navigation — they only apply to the entry
|
||||
{:cont,
|
||||
socket
|
||||
|> assign(:analytics_referrer, nil)
|
||||
|> assign(:analytics_referrer_source, nil)
|
||||
|> assign(:analytics_utm_source, nil)
|
||||
|> assign(:analytics_utm_medium, nil)
|
||||
|> assign(:analytics_utm_campaign, nil)}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defp handle_analytics_event("analytics:screen", %{"width" => width}, socket)
|
||||
when is_integer(width) do
|
||||
screen_size = classify_screen(width)
|
||||
{:cont, assign(socket, :analytics_screen_size, screen_size)}
|
||||
end
|
||||
|
||||
defp handle_analytics_event(_event, _params, socket), do: {:cont, socket}
|
||||
|
||||
defp classify_screen(width) when width < 768, do: "mobile"
|
||||
defp classify_screen(width) when width < 1024, do: "tablet"
|
||||
defp classify_screen(_width), do: "desktop"
|
||||
|
||||
defp admin?(socket) do
|
||||
case socket.assigns[:current_scope] do
|
||||
%{user: %{}} -> true
|
||||
_ -> false
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -62,6 +62,14 @@
|
||||
<.icon name="hero-home" class="size-5" /> Dashboard
|
||||
</.link>
|
||||
</li>
|
||||
<li>
|
||||
<.link
|
||||
navigate={~p"/admin/analytics"}
|
||||
class={admin_nav_active?(@current_path, "/admin/analytics")}
|
||||
>
|
||||
<.icon name="hero-chart-bar" class="size-5" /> Analytics
|
||||
</.link>
|
||||
</li>
|
||||
<li>
|
||||
<.link
|
||||
navigate={~p"/admin/orders"}
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
<.shop_flash_group flash={@flash} />
|
||||
<div id="analytics-init" phx-hook="AnalyticsInit" phx-update="ignore" style="display:none"></div>
|
||||
{@inner_content}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
defmodule BerrypodWeb.CheckoutController do
|
||||
use BerrypodWeb, :controller
|
||||
|
||||
alias Berrypod.Cart
|
||||
alias Berrypod.{Analytics, Cart}
|
||||
alias Berrypod.Orders
|
||||
alias Berrypod.Shipping
|
||||
|
||||
@@ -16,6 +16,7 @@ defmodule BerrypodWeb.CheckoutController do
|
||||
|> put_flash(:error, "Your basket is empty")
|
||||
|> redirect(to: ~p"/cart")
|
||||
else
|
||||
track_checkout_start(conn)
|
||||
create_checkout(conn, hydrated)
|
||||
end
|
||||
end
|
||||
@@ -126,4 +127,15 @@ defmodule BerrypodWeb.CheckoutController do
|
||||
end
|
||||
|
||||
defp maybe_add_option(options, _result, _name, _min, _max), do: options
|
||||
|
||||
defp track_checkout_start(conn) do
|
||||
visitor_hash = get_session(conn, "analytics_visitor_hash")
|
||||
|
||||
if visitor_hash do
|
||||
Analytics.track_event("checkout_start", %{
|
||||
pathname: "/checkout",
|
||||
visitor_hash: visitor_hash
|
||||
})
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
459
lib/berrypod_web/live/admin/analytics.ex
Normal file
459
lib/berrypod_web/live/admin/analytics.ex
Normal file
@@ -0,0 +1,459 @@
|
||||
defmodule BerrypodWeb.Admin.Analytics do
|
||||
use BerrypodWeb, :live_view
|
||||
|
||||
alias Berrypod.Analytics
|
||||
alias Berrypod.Cart
|
||||
|
||||
@periods %{
|
||||
"today" => 0,
|
||||
"7d" => 6,
|
||||
"30d" => 29,
|
||||
"12m" => 364
|
||||
}
|
||||
|
||||
@impl true
|
||||
def mount(_params, _session, socket) do
|
||||
{:ok,
|
||||
socket
|
||||
|> assign(:page_title, "Analytics")
|
||||
|> assign(:period, "30d")
|
||||
|> assign(:tab, "pages")
|
||||
|> load_analytics("30d")}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("change_period", %{"period" => period}, socket)
|
||||
when is_map_key(@periods, period) do
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:period, period)
|
||||
|> load_analytics(period)}
|
||||
end
|
||||
|
||||
def handle_event("change_tab", %{"tab" => tab}, socket)
|
||||
when tab in ["pages", "sources", "countries", "devices", "funnel"] do
|
||||
{:noreply, assign(socket, :tab, tab)}
|
||||
end
|
||||
|
||||
# ── Data loading ──
|
||||
|
||||
defp load_analytics(socket, period) do
|
||||
range = date_range(period)
|
||||
|
||||
socket
|
||||
|> assign(:visitors, Analytics.count_visitors(range))
|
||||
|> assign(:pageviews, Analytics.count_pageviews(range))
|
||||
|> assign(:bounce_rate, Analytics.bounce_rate(range))
|
||||
|> assign(:avg_duration, Analytics.avg_duration(range))
|
||||
|> assign(:visitors_by_date, Analytics.visitors_by_date(range))
|
||||
|> assign(:top_pages, Analytics.top_pages(range))
|
||||
|> assign(:top_sources, Analytics.top_sources(range))
|
||||
|> assign(:top_referrers, Analytics.top_referrers(range))
|
||||
|> assign(:top_countries, Analytics.top_countries(range))
|
||||
|> assign(:browsers, Analytics.device_breakdown(range, :browser))
|
||||
|> assign(:oses, Analytics.device_breakdown(range, :os))
|
||||
|> assign(:screen_sizes, Analytics.device_breakdown(range, :screen_size))
|
||||
|> assign(:funnel, Analytics.funnel(range))
|
||||
|> assign(:revenue, Analytics.total_revenue(range))
|
||||
end
|
||||
|
||||
defp date_range(period) do
|
||||
days = Map.fetch!(@periods, period)
|
||||
today = Date.utc_today()
|
||||
start_date = Date.add(today, -days)
|
||||
end_date = Date.add(today, 1)
|
||||
|
||||
{DateTime.new!(start_date, ~T[00:00:00], "Etc/UTC"),
|
||||
DateTime.new!(end_date, ~T[00:00:00], "Etc/UTC")}
|
||||
end
|
||||
|
||||
# ── Render ──
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<.header>Analytics</.header>
|
||||
|
||||
<%!-- Period selector --%>
|
||||
<div class="analytics-periods" style="display: flex; gap: 0.25rem; margin-top: 1rem;">
|
||||
<button
|
||||
:for={period <- ["today", "7d", "30d", "12m"]}
|
||||
phx-click="change_period"
|
||||
phx-value-period={period}
|
||||
class={["admin-btn admin-btn-sm", @period == period && "admin-btn-primary"]}
|
||||
>
|
||||
{period_label(period)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<%!-- Stat cards --%>
|
||||
<div class="admin-stats-grid" style="margin-top: 1.5rem;">
|
||||
<.stat_card label="Unique visitors" value={format_number(@visitors)} icon="hero-users" />
|
||||
<.stat_card label="Total pageviews" value={format_number(@pageviews)} icon="hero-eye" />
|
||||
<.stat_card label="Bounce rate" value={"#{@bounce_rate}%"} icon="hero-arrow-uturn-left" />
|
||||
<.stat_card label="Visit duration" value={format_duration(@avg_duration)} icon="hero-clock" />
|
||||
</div>
|
||||
|
||||
<%!-- Visitor trend chart --%>
|
||||
<div class="admin-card" style="margin-top: 1.5rem; padding: 1rem;">
|
||||
<h3 style="font-size: 0.875rem; font-weight: 600; margin-bottom: 0.75rem;">
|
||||
Visitors over time
|
||||
</h3>
|
||||
<.bar_chart data={@visitors_by_date} />
|
||||
</div>
|
||||
|
||||
<%!-- Detail tabs --%>
|
||||
<div style="display: flex; gap: 0.25rem; margin-top: 1.5rem; flex-wrap: wrap;">
|
||||
<button
|
||||
:for={
|
||||
tab <- [
|
||||
{"pages", "Pages"},
|
||||
{"sources", "Sources"},
|
||||
{"countries", "Countries"},
|
||||
{"devices", "Devices"},
|
||||
{"funnel", "Funnel"}
|
||||
]
|
||||
}
|
||||
phx-click="change_tab"
|
||||
phx-value-tab={elem(tab, 0)}
|
||||
class={["admin-btn admin-btn-sm", @tab == elem(tab, 0) && "admin-btn-primary"]}
|
||||
>
|
||||
{elem(tab, 1)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<%!-- Tab content --%>
|
||||
<div class="admin-card" style="margin-top: 0.75rem; padding: 1rem;">
|
||||
<.tab_content tab={@tab} {assigns} />
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
# ── Stat card component ──
|
||||
|
||||
attr :label, :string, required: true
|
||||
attr :value, :any, required: true
|
||||
attr :icon, :string, required: true
|
||||
|
||||
defp stat_card(assigns) do
|
||||
~H"""
|
||||
<div class="admin-card">
|
||||
<div style="display: flex; align-items: center; gap: 0.75rem; padding: 1rem;">
|
||||
<div style="background: var(--color-base-200, #e5e5e5); border-radius: 0.5rem; padding: 0.5rem;">
|
||||
<.icon name={@icon} class="size-5" />
|
||||
</div>
|
||||
<div>
|
||||
<p style="font-size: 1.5rem; font-weight: 700;">{@value}</p>
|
||||
<p style="font-size: 0.875rem; color: color-mix(in oklch, var(--color-base-content) 60%, transparent);">
|
||||
{@label}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
# ── Bar chart (server-rendered SVG) ──
|
||||
|
||||
attr :data, :list, required: true
|
||||
|
||||
defp bar_chart(assigns) do
|
||||
data = assigns.data
|
||||
max_val = data |> Enum.map(& &1.visitors) |> Enum.max(fn -> 1 end)
|
||||
chart_height = 120
|
||||
bar_count = max(length(data), 1)
|
||||
|
||||
bars =
|
||||
data
|
||||
|> Enum.with_index()
|
||||
|> Enum.map(fn {%{date: date, visitors: visitors}, i} ->
|
||||
bar_height = if max_val > 0, do: visitors / max_val * chart_height, else: 0
|
||||
bar_width = max(800 / bar_count - 2, 1)
|
||||
x = i * (800 / bar_count) + 1
|
||||
|
||||
%{
|
||||
x: x,
|
||||
y: chart_height - bar_height,
|
||||
width: bar_width,
|
||||
height: max(bar_height, 1),
|
||||
date: date,
|
||||
visitors: visitors
|
||||
}
|
||||
end)
|
||||
|
||||
assigns = assign(assigns, bars: bars, chart_height: chart_height)
|
||||
|
||||
~H"""
|
||||
<div
|
||||
:if={@data == []}
|
||||
style="text-align: center; padding: 2rem; color: color-mix(in oklch, var(--color-base-content) 40%, transparent);"
|
||||
>
|
||||
No data for this period
|
||||
</div>
|
||||
<svg
|
||||
:if={@data != []}
|
||||
viewBox={"0 0 800 #{@chart_height}"}
|
||||
style="width: 100%; height: auto; max-height: 160px;"
|
||||
aria-label="Visitor trend chart"
|
||||
>
|
||||
<rect
|
||||
:for={bar <- @bars}
|
||||
x={bar.x}
|
||||
y={bar.y}
|
||||
width={bar.width}
|
||||
height={bar.height}
|
||||
rx="2"
|
||||
fill="var(--color-primary, #4f46e5)"
|
||||
opacity="0.8"
|
||||
>
|
||||
<title>{bar.date}: {bar.visitors} visitors</title>
|
||||
</rect>
|
||||
</svg>
|
||||
"""
|
||||
end
|
||||
|
||||
# ── Tab content ──
|
||||
|
||||
defp tab_content(%{tab: "pages"} = assigns) do
|
||||
~H"""
|
||||
<h3 style="font-size: 0.875rem; font-weight: 600; margin-bottom: 0.75rem;">Top pages</h3>
|
||||
<.detail_table
|
||||
rows={@top_pages}
|
||||
empty_message="No page data yet"
|
||||
columns={[
|
||||
%{label: "Page", key: :pathname},
|
||||
%{label: "Visitors", key: :visitors, align: :right},
|
||||
%{label: "Pageviews", key: :pageviews, align: :right}
|
||||
]}
|
||||
/>
|
||||
"""
|
||||
end
|
||||
|
||||
defp tab_content(%{tab: "sources"} = assigns) do
|
||||
~H"""
|
||||
<h3 style="font-size: 0.875rem; font-weight: 600; margin-bottom: 0.75rem;">Top sources</h3>
|
||||
<.detail_table
|
||||
rows={@top_sources}
|
||||
empty_message="No referrer data yet"
|
||||
columns={[
|
||||
%{label: "Source", key: :source},
|
||||
%{label: "Visitors", key: :visitors, align: :right}
|
||||
]}
|
||||
/>
|
||||
<h3 style="font-size: 0.875rem; font-weight: 600; margin: 1.5rem 0 0.75rem;">Top referrers</h3>
|
||||
<.detail_table
|
||||
rows={@top_referrers}
|
||||
empty_message="No referrer data yet"
|
||||
columns={[
|
||||
%{label: "Referrer", key: :referrer},
|
||||
%{label: "Visitors", key: :visitors, align: :right}
|
||||
]}
|
||||
/>
|
||||
"""
|
||||
end
|
||||
|
||||
defp tab_content(%{tab: "countries"} = assigns) do
|
||||
~H"""
|
||||
<h3 style="font-size: 0.875rem; font-weight: 600; margin-bottom: 0.75rem;">Countries</h3>
|
||||
<.detail_table
|
||||
rows={Enum.map(@top_countries, fn c -> %{c | country_code: country_name(c.country_code)} end)}
|
||||
empty_message="No country data yet"
|
||||
columns={[
|
||||
%{label: "Country", key: :country_code},
|
||||
%{label: "Visitors", key: :visitors, align: :right}
|
||||
]}
|
||||
/>
|
||||
"""
|
||||
end
|
||||
|
||||
defp tab_content(%{tab: "devices"} = assigns) do
|
||||
~H"""
|
||||
<h3 style="font-size: 0.875rem; font-weight: 600; margin-bottom: 0.75rem;">Browsers</h3>
|
||||
<.detail_table
|
||||
rows={@browsers}
|
||||
empty_message="No browser data yet"
|
||||
columns={[
|
||||
%{label: "Browser", key: :name},
|
||||
%{label: "Visitors", key: :visitors, align: :right}
|
||||
]}
|
||||
/>
|
||||
<h3 style="font-size: 0.875rem; font-weight: 600; margin: 1.5rem 0 0.75rem;">
|
||||
Operating systems
|
||||
</h3>
|
||||
<.detail_table
|
||||
rows={@oses}
|
||||
empty_message="No OS data yet"
|
||||
columns={[
|
||||
%{label: "OS", key: :name},
|
||||
%{label: "Visitors", key: :visitors, align: :right}
|
||||
]}
|
||||
/>
|
||||
<h3 style="font-size: 0.875rem; font-weight: 600; margin: 1.5rem 0 0.75rem;">Screen sizes</h3>
|
||||
<.detail_table
|
||||
rows={@screen_sizes}
|
||||
empty_message="No screen data yet"
|
||||
columns={[
|
||||
%{label: "Size", key: :name},
|
||||
%{label: "Visitors", key: :visitors, align: :right}
|
||||
]}
|
||||
/>
|
||||
"""
|
||||
end
|
||||
|
||||
defp tab_content(%{tab: "funnel"} = assigns) do
|
||||
~H"""
|
||||
<h3 style="font-size: 0.875rem; font-weight: 600; margin-bottom: 0.75rem;">
|
||||
Conversion funnel
|
||||
</h3>
|
||||
<.funnel_chart funnel={@funnel} revenue={@revenue} />
|
||||
"""
|
||||
end
|
||||
|
||||
# ── Detail table ──
|
||||
|
||||
attr :rows, :list, required: true
|
||||
attr :columns, :list, required: true
|
||||
attr :empty_message, :string, default: "No data"
|
||||
|
||||
defp detail_table(assigns) do
|
||||
~H"""
|
||||
<div
|
||||
:if={@rows == []}
|
||||
style="text-align: center; padding: 1.5rem; color: color-mix(in oklch, var(--color-base-content) 40%, transparent);"
|
||||
>
|
||||
{@empty_message}
|
||||
</div>
|
||||
<table :if={@rows != []} class="admin-table" style="width: 100%;">
|
||||
<thead>
|
||||
<tr>
|
||||
<th
|
||||
:for={col <- @columns}
|
||||
style={col[:align] == :right && "text-align: right;"}
|
||||
>
|
||||
{col.label}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr :for={row <- @rows}>
|
||||
<td
|
||||
:for={col <- @columns}
|
||||
style={col[:align] == :right && "text-align: right; font-variant-numeric: tabular-nums;"}
|
||||
>
|
||||
{Map.get(row, col.key)}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
"""
|
||||
end
|
||||
|
||||
# ── Funnel chart ──
|
||||
|
||||
attr :funnel, :map, required: true
|
||||
attr :revenue, :integer, required: true
|
||||
|
||||
defp funnel_chart(assigns) do
|
||||
steps = [
|
||||
{"Product views", assigns.funnel.product_views},
|
||||
{"Add to cart", assigns.funnel.add_to_carts},
|
||||
{"Checkout", assigns.funnel.checkouts},
|
||||
{"Purchase", assigns.funnel.purchases}
|
||||
]
|
||||
|
||||
max_val = steps |> Enum.map(&elem(&1, 1)) |> Enum.max(fn -> 1 end)
|
||||
|
||||
steps_with_rates =
|
||||
steps
|
||||
|> Enum.with_index()
|
||||
|> Enum.map(fn {{label, count}, i} ->
|
||||
prev_count = if i > 0, do: elem(Enum.at(steps, i - 1), 1), else: count
|
||||
rate = if prev_count > 0, do: round(count / prev_count * 100), else: 0
|
||||
width_pct = if max_val > 0, do: max(count / max_val * 100, 5), else: 5
|
||||
|
||||
%{label: label, count: count, rate: rate, width_pct: width_pct, index: i}
|
||||
end)
|
||||
|
||||
assigns = assign(assigns, steps: steps_with_rates)
|
||||
|
||||
~H"""
|
||||
<div
|
||||
:if={@funnel.product_views == 0}
|
||||
style="text-align: center; padding: 1.5rem; color: color-mix(in oklch, var(--color-base-content) 40%, transparent);"
|
||||
>
|
||||
No funnel data yet
|
||||
</div>
|
||||
<div :if={@funnel.product_views > 0} style="display: flex; flex-direction: column; gap: 0.5rem;">
|
||||
<div :for={step <- @steps} style="display: flex; align-items: center; gap: 0.75rem;">
|
||||
<div style="width: 7rem; font-size: 0.8125rem; text-align: right; flex-shrink: 0;">
|
||||
{step.label}
|
||||
</div>
|
||||
<div style={"flex: 0 0 #{step.width_pct}%; height: 2rem; background: var(--color-primary, #4f46e5); border-radius: 0.25rem; opacity: #{1 - step.index * 0.15}; display: flex; align-items: center; padding-left: 0.5rem;"}>
|
||||
<span style="font-size: 0.75rem; font-weight: 600; color: white;">
|
||||
{step.count}
|
||||
</span>
|
||||
</div>
|
||||
<span
|
||||
:if={step.index > 0}
|
||||
style="font-size: 0.75rem; color: color-mix(in oklch, var(--color-base-content) 60%, transparent);"
|
||||
>
|
||||
{step.rate}%
|
||||
</span>
|
||||
</div>
|
||||
<div :if={@revenue > 0} style="margin-top: 0.5rem; font-size: 0.875rem; font-weight: 600;">
|
||||
Revenue: {Cart.format_price(@revenue)}
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
# ── Helpers ──
|
||||
|
||||
defp period_label("today"), do: "Today"
|
||||
defp period_label("7d"), do: "7 days"
|
||||
defp period_label("30d"), do: "30 days"
|
||||
defp period_label("12m"), do: "12 months"
|
||||
|
||||
defp format_number(n) when n >= 1_000_000, do: "#{Float.round(n / 1_000_000, 1)}M"
|
||||
defp format_number(n) when n >= 1_000, do: "#{Float.round(n / 1_000, 1)}k"
|
||||
defp format_number(n), do: to_string(n)
|
||||
|
||||
defp format_duration(seconds) when seconds < 60, do: "#{seconds}s"
|
||||
|
||||
defp format_duration(seconds) do
|
||||
mins = div(seconds, 60)
|
||||
secs = rem(seconds, 60)
|
||||
"#{mins}m #{secs}s"
|
||||
end
|
||||
|
||||
@country_names %{
|
||||
"GB" => "United Kingdom",
|
||||
"US" => "United States",
|
||||
"CA" => "Canada",
|
||||
"AU" => "Australia",
|
||||
"DE" => "Germany",
|
||||
"FR" => "France",
|
||||
"NL" => "Netherlands",
|
||||
"IE" => "Ireland",
|
||||
"AT" => "Austria",
|
||||
"BE" => "Belgium",
|
||||
"IT" => "Italy",
|
||||
"ES" => "Spain",
|
||||
"PT" => "Portugal",
|
||||
"SE" => "Sweden",
|
||||
"NO" => "Norway",
|
||||
"DK" => "Denmark",
|
||||
"FI" => "Finland",
|
||||
"PL" => "Poland",
|
||||
"CH" => "Switzerland",
|
||||
"NZ" => "New Zealand",
|
||||
"JP" => "Japan",
|
||||
"IN" => "India",
|
||||
"BR" => "Brazil",
|
||||
"MX" => "Mexico"
|
||||
}
|
||||
|
||||
defp country_name(code) do
|
||||
Map.get(@country_names, code, code)
|
||||
end
|
||||
end
|
||||
@@ -1,7 +1,7 @@
|
||||
defmodule BerrypodWeb.Shop.CheckoutSuccess do
|
||||
use BerrypodWeb, :live_view
|
||||
|
||||
alias Berrypod.Orders
|
||||
alias Berrypod.{Analytics, Orders}
|
||||
|
||||
@impl true
|
||||
def mount(%{"session_id" => session_id}, _session, socket) do
|
||||
@@ -12,6 +12,15 @@ defmodule BerrypodWeb.Shop.CheckoutSuccess do
|
||||
Phoenix.PubSub.subscribe(Berrypod.PubSub, "order:#{order.id}:status")
|
||||
end
|
||||
|
||||
# Track purchase event
|
||||
if order && connected?(socket) && socket.assigns[:analytics_visitor_hash] do
|
||||
Analytics.track_event("purchase", %{
|
||||
pathname: "/checkout/success",
|
||||
visitor_hash: socket.assigns.analytics_visitor_hash,
|
||||
revenue: order.total
|
||||
})
|
||||
end
|
||||
|
||||
# Clear the cart after successful checkout
|
||||
socket =
|
||||
if order && connected?(socket) do
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
defmodule BerrypodWeb.Shop.ProductShow do
|
||||
use BerrypodWeb, :live_view
|
||||
|
||||
alias Berrypod.Cart
|
||||
alias Berrypod.{Analytics, Cart}
|
||||
alias Berrypod.Images.Optimizer
|
||||
alias Berrypod.Products
|
||||
alias Berrypod.Products.{Product, ProductImage}
|
||||
@@ -41,6 +41,13 @@ defmodule BerrypodWeb.Shop.ProductShow do
|
||||
display_price = variant_price(selected_variant, product)
|
||||
gallery_images = filter_gallery_images(all_images, selected_options["Color"])
|
||||
|
||||
if connected?(socket) and socket.assigns[:analytics_visitor_hash] do
|
||||
Analytics.track_event("product_view", %{
|
||||
pathname: "/products/#{slug}",
|
||||
visitor_hash: socket.assigns.analytics_visitor_hash
|
||||
})
|
||||
end
|
||||
|
||||
socket =
|
||||
socket
|
||||
|> assign(:page_title, product.title)
|
||||
@@ -177,6 +184,13 @@ defmodule BerrypodWeb.Shop.ProductShow do
|
||||
if variant do
|
||||
cart = Cart.add_item(socket.assigns.raw_cart, variant.id, socket.assigns.quantity)
|
||||
|
||||
if socket.assigns[:analytics_visitor_hash] do
|
||||
Analytics.track_event("add_to_cart", %{
|
||||
pathname: "/products/#{socket.assigns.product.slug}",
|
||||
visitor_hash: socket.assigns.analytics_visitor_hash
|
||||
})
|
||||
end
|
||||
|
||||
socket =
|
||||
socket
|
||||
|> BerrypodWeb.CartHook.broadcast_and_update(cart)
|
||||
|
||||
112
lib/berrypod_web/plugs/analytics.ex
Normal file
112
lib/berrypod_web/plugs/analytics.ex
Normal file
@@ -0,0 +1,112 @@
|
||||
defmodule BerrypodWeb.Plugs.Analytics do
|
||||
@moduledoc """
|
||||
Plug that records a pageview event on every shop HTTP request.
|
||||
|
||||
This is Layer 1 of the progressive analytics pipeline — it fires on every
|
||||
GET request regardless of whether JS is enabled. Computes a privacy-friendly
|
||||
visitor hash, parses the user agent, extracts referrer and UTM params, and
|
||||
buffers the event for batch writing to SQLite.
|
||||
|
||||
Also stores analytics data in the session so the LiveView hook (Layer 2)
|
||||
can read it for subsequent SPA navigations.
|
||||
"""
|
||||
|
||||
import Plug.Conn
|
||||
|
||||
alias Berrypod.Analytics
|
||||
alias Berrypod.Analytics.{Salt, UAParser, Referrer}
|
||||
|
||||
def init(opts), do: opts
|
||||
|
||||
def call(%{method: "GET"} = conn, _opts) do
|
||||
ua = get_req_header(conn, "user-agent") |> List.first("")
|
||||
{browser, os} = UAParser.parse(ua)
|
||||
|
||||
# Skip bots
|
||||
if browser == "Bot" do
|
||||
conn
|
||||
else
|
||||
# Skip if the logged-in admin is browsing
|
||||
if admin?(conn) do
|
||||
prepare_session(conn, ua, browser, os)
|
||||
else
|
||||
record_and_prepare(conn, ua, browser, os)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def call(conn, _opts), do: conn
|
||||
|
||||
defp record_and_prepare(conn, ua, browser, os) do
|
||||
visitor_hash = Salt.hash_visitor(conn.remote_ip, ua)
|
||||
{referrer, referrer_source} = extract_referrer(conn)
|
||||
utm = extract_utms(conn)
|
||||
country_code = get_session(conn, "country_code")
|
||||
screen_size = get_session(conn, "analytics_screen_size")
|
||||
|
||||
Analytics.track_pageview(%{
|
||||
pathname: conn.request_path,
|
||||
visitor_hash: visitor_hash,
|
||||
referrer: referrer,
|
||||
referrer_source: referrer_source,
|
||||
utm_source: utm.source,
|
||||
utm_medium: utm.medium,
|
||||
utm_campaign: utm.campaign,
|
||||
country_code: country_code,
|
||||
screen_size: screen_size,
|
||||
browser: browser,
|
||||
os: os
|
||||
})
|
||||
|
||||
conn
|
||||
|> put_session("analytics_visitor_hash", visitor_hash)
|
||||
|> put_session("analytics_browser", browser)
|
||||
|> put_session("analytics_os", os)
|
||||
|> put_session("analytics_referrer", referrer)
|
||||
|> put_session("analytics_referrer_source", referrer_source)
|
||||
|> put_session("analytics_utm_source", utm.source)
|
||||
|> put_session("analytics_utm_medium", utm.medium)
|
||||
|> put_session("analytics_utm_campaign", utm.campaign)
|
||||
end
|
||||
|
||||
# Store session data for the hook even when we skip recording for admins
|
||||
defp prepare_session(conn, ua, browser, os) do
|
||||
visitor_hash = Salt.hash_visitor(conn.remote_ip, ua)
|
||||
|
||||
conn
|
||||
|> put_session("analytics_visitor_hash", visitor_hash)
|
||||
|> put_session("analytics_browser", browser)
|
||||
|> put_session("analytics_os", os)
|
||||
end
|
||||
|
||||
defp admin?(conn) do
|
||||
case conn.assigns[:current_scope] do
|
||||
%{user: %{}} -> true
|
||||
_ -> false
|
||||
end
|
||||
end
|
||||
|
||||
defp extract_referrer(conn) do
|
||||
referrer_url = get_req_header(conn, "referer") |> List.first()
|
||||
{referrer, source} = Referrer.parse(referrer_url)
|
||||
|
||||
# Don't count self-referrals
|
||||
host = conn.host
|
||||
|
||||
if referrer && referrer == host do
|
||||
{nil, nil}
|
||||
else
|
||||
{referrer, source}
|
||||
end
|
||||
end
|
||||
|
||||
defp extract_utms(conn) do
|
||||
params = conn.query_params || %{}
|
||||
|
||||
%{
|
||||
source: Map.get(params, "utm_source"),
|
||||
medium: Map.get(params, "utm_medium"),
|
||||
campaign: Map.get(params, "utm_campaign")
|
||||
}
|
||||
end
|
||||
end
|
||||
@@ -37,6 +37,7 @@ defmodule BerrypodWeb.Router do
|
||||
pipeline :shop do
|
||||
plug :put_root_layout, html: {BerrypodWeb.Layouts, :shop_root}
|
||||
plug BerrypodWeb.Plugs.LoadTheme
|
||||
plug BerrypodWeb.Plugs.Analytics
|
||||
end
|
||||
|
||||
pipeline :admin do
|
||||
@@ -63,7 +64,8 @@ defmodule BerrypodWeb.Router do
|
||||
{BerrypodWeb.ThemeHook, :mount_theme},
|
||||
{BerrypodWeb.ThemeHook, :require_site_live},
|
||||
{BerrypodWeb.CartHook, :mount_cart},
|
||||
{BerrypodWeb.SearchHook, :mount_search}
|
||||
{BerrypodWeb.SearchHook, :mount_search},
|
||||
{BerrypodWeb.AnalyticsHook, :track}
|
||||
] do
|
||||
live "/", Shop.Home, :index
|
||||
live "/about", Shop.Content, :about
|
||||
@@ -170,6 +172,7 @@ defmodule BerrypodWeb.Router do
|
||||
{BerrypodWeb.AdminLayoutHook, :assign_current_path}
|
||||
] do
|
||||
live "/", Admin.Dashboard, :index
|
||||
live "/analytics", Admin.Analytics, :index
|
||||
live "/orders", Admin.Orders, :index
|
||||
live "/orders/:id", Admin.OrderShow, :show
|
||||
live "/products", Admin.Products, :index
|
||||
|
||||
Reference in New Issue
Block a user