All checks were successful
deploy / deploy (push) Successful in 1m19s
Click any row in pages, sources, countries, or devices tables to filter the entire dashboard by that dimension. Active filters show as dismissible chips. Filters thread through all queries including previous-period deltas. 1050 tests. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
771 lines
24 KiB
Elixir
771 lines
24 KiB
Elixir
defmodule BerrypodWeb.Admin.Analytics do
|
||
use BerrypodWeb, :live_view
|
||
|
||
alias Berrypod.Analytics
|
||
alias Berrypod.Cart
|
||
|
||
@periods %{
|
||
"today" => 0,
|
||
"7d" => 6,
|
||
"30d" => 29,
|
||
"12m" => 364
|
||
}
|
||
|
||
@filterable_dimensions ~w(pathname referrer_source country_code browser os screen_size)
|
||
|
||
@impl true
|
||
def mount(_params, _session, socket) do
|
||
{:ok,
|
||
socket
|
||
|> assign(:page_title, "Analytics")
|
||
|> assign(:period, "30d")
|
||
|> assign(:tab, "pages")
|
||
|> assign(:filters, %{})
|
||
|> 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
|
||
|
||
def handle_event("add_filter", %{"dimension" => dim, "value" => val}, socket)
|
||
when dim in @filterable_dimensions do
|
||
filters = Map.put(socket.assigns.filters, String.to_existing_atom(dim), val)
|
||
|
||
{:noreply,
|
||
socket
|
||
|> assign(:filters, filters)
|
||
|> load_analytics(socket.assigns.period)}
|
||
end
|
||
|
||
def handle_event("remove_filter", %{"dimension" => dim}, socket)
|
||
when dim in @filterable_dimensions do
|
||
filters = Map.delete(socket.assigns.filters, String.to_existing_atom(dim))
|
||
|
||
{:noreply,
|
||
socket
|
||
|> assign(:filters, filters)
|
||
|> load_analytics(socket.assigns.period)}
|
||
end
|
||
|
||
def handle_event("clear_filters", _params, socket) do
|
||
{:noreply,
|
||
socket
|
||
|> assign(:filters, %{})
|
||
|> load_analytics(socket.assigns.period)}
|
||
end
|
||
|
||
# ── Data loading ──
|
||
|
||
defp load_analytics(socket, period) do
|
||
range = date_range(period)
|
||
prev_range = previous_date_range(period)
|
||
filters = socket.assigns.filters
|
||
|
||
trend_data =
|
||
if period == "today",
|
||
do: Analytics.visitors_by_hour(range, filters),
|
||
else: Analytics.visitors_by_date(range, filters)
|
||
|
||
visitors = Analytics.count_visitors(range, filters)
|
||
pageviews = Analytics.count_pageviews(range, filters)
|
||
bounce_rate = Analytics.bounce_rate(range, filters)
|
||
avg_duration = Analytics.avg_duration(range, filters)
|
||
|
||
prev_visitors = Analytics.count_visitors(prev_range, filters)
|
||
prev_pageviews = Analytics.count_pageviews(prev_range, filters)
|
||
prev_bounce_rate = Analytics.bounce_rate(prev_range, filters)
|
||
prev_avg_duration = Analytics.avg_duration(prev_range, filters)
|
||
|
||
socket
|
||
|> assign(:visitors, visitors)
|
||
|> assign(:pageviews, pageviews)
|
||
|> assign(:bounce_rate, bounce_rate)
|
||
|> assign(:avg_duration, avg_duration)
|
||
|> assign(:visitors_delta, compute_delta(visitors, prev_visitors))
|
||
|> assign(:pageviews_delta, compute_delta(pageviews, prev_pageviews))
|
||
|> assign(:bounce_rate_delta, compute_delta(bounce_rate, prev_bounce_rate))
|
||
|> assign(:avg_duration_delta, compute_delta(avg_duration, prev_avg_duration))
|
||
|> assign(:trend_data, trend_data)
|
||
|> assign(:trend_mode, if(period == "today", do: :hourly, else: :daily))
|
||
|> assign(:top_pages, Analytics.top_pages(range, filters: filters))
|
||
|> assign(:top_sources, Analytics.top_sources(range, filters: filters))
|
||
|> assign(:top_referrers, Analytics.top_referrers(range, filters: filters))
|
||
|> assign(:top_countries, Analytics.top_countries(range, filters: filters))
|
||
|> assign(:browsers, Analytics.device_breakdown(range, :browser, filters))
|
||
|> assign(:oses, Analytics.device_breakdown(range, :os, filters))
|
||
|> assign(:screen_sizes, Analytics.device_breakdown(range, :screen_size, filters))
|
||
|> assign(:funnel, Analytics.funnel(range, filters))
|
||
|> assign(:revenue, Analytics.total_revenue(range, filters))
|
||
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
|
||
|
||
defp previous_date_range(period) do
|
||
days = Map.fetch!(@periods, period)
|
||
today = Date.utc_today()
|
||
duration = days + 1
|
||
current_start = Date.add(today, -days)
|
||
prev_start = Date.add(current_start, -duration)
|
||
|
||
{DateTime.new!(prev_start, ~T[00:00:00], "Etc/UTC"),
|
||
DateTime.new!(current_start, ~T[00:00:00], "Etc/UTC")}
|
||
end
|
||
|
||
defp compute_delta(0, 0), do: nil
|
||
defp compute_delta(_current, 0), do: :new
|
||
defp compute_delta(current, previous), do: round((current - previous) / previous * 100)
|
||
|
||
# ── 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>
|
||
|
||
<%!-- Active filters --%>
|
||
<div
|
||
:if={@filters != %{}}
|
||
id="analytics-filters"
|
||
style="display: flex; gap: 0.5rem; flex-wrap: wrap; align-items: center; margin-top: 1rem;"
|
||
>
|
||
<.filter_chip
|
||
:for={{dim, val} <- @filters}
|
||
dimension={dim}
|
||
value={val}
|
||
/>
|
||
<button
|
||
:if={map_size(@filters) > 1}
|
||
phx-click="clear_filters"
|
||
class="admin-btn admin-btn-sm"
|
||
style="font-size: 0.75rem;"
|
||
>
|
||
Clear all
|
||
</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"
|
||
delta={@visitors_delta}
|
||
/>
|
||
<.stat_card
|
||
label="Total pageviews"
|
||
value={format_number(@pageviews)}
|
||
icon="hero-eye"
|
||
delta={@pageviews_delta}
|
||
/>
|
||
<.stat_card
|
||
label="Bounce rate"
|
||
value={"#{@bounce_rate}%"}
|
||
icon="hero-arrow-uturn-left"
|
||
delta={@bounce_rate_delta}
|
||
invert
|
||
/>
|
||
<.stat_card
|
||
label="Visit duration"
|
||
value={format_duration(@avg_duration)}
|
||
icon="hero-clock"
|
||
delta={@avg_duration_delta}
|
||
/>
|
||
</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={@trend_data} mode={@trend_mode} />
|
||
</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
|
||
attr :delta, :any, default: nil
|
||
attr :invert, :boolean, default: false
|
||
|
||
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>
|
||
<div style="display: flex; align-items: baseline; gap: 0.5rem;">
|
||
<p style="font-size: 1.5rem; font-weight: 700;">{@value}</p>
|
||
<.delta_badge :if={@delta != nil} delta={@delta} invert={@invert} />
|
||
</div>
|
||
<p style="font-size: 0.875rem; color: color-mix(in oklch, var(--color-base-content) 60%, transparent);">
|
||
{@label}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
"""
|
||
end
|
||
|
||
attr :delta, :any, required: true
|
||
attr :invert, :boolean, required: true
|
||
|
||
defp delta_badge(%{delta: :new} = assigns) do
|
||
~H"""
|
||
<span style="font-size: 0.75rem; font-weight: 500; color: color-mix(in oklch, var(--color-base-content) 50%, transparent); white-space: nowrap;">
|
||
new
|
||
</span>
|
||
"""
|
||
end
|
||
|
||
defp delta_badge(assigns) do
|
||
{color, arrow} =
|
||
cond do
|
||
assigns.delta > 0 && !assigns.invert -> {"var(--t-status-success, #22c55e)", "↑"}
|
||
assigns.delta > 0 && assigns.invert -> {"var(--t-status-error, #ef4444)", "↑"}
|
||
assigns.delta < 0 && !assigns.invert -> {"var(--t-status-error, #ef4444)", "↓"}
|
||
assigns.delta < 0 && assigns.invert -> {"var(--t-status-success, #22c55e)", "↓"}
|
||
true -> {"color-mix(in oklch, var(--color-base-content) 40%, transparent)", "–"}
|
||
end
|
||
|
||
assigns = assign(assigns, color: color, arrow: arrow)
|
||
|
||
~H"""
|
||
<span style={"font-size: 0.75rem; font-weight: 500; color: #{@color}; white-space: nowrap;"}>
|
||
{@arrow} {if abs(@delta) > 999, do: ">999%", else: "#{abs(@delta)}%"}
|
||
</span>
|
||
"""
|
||
end
|
||
|
||
# ── Filter chip ──
|
||
|
||
attr :dimension, :atom, required: true
|
||
attr :value, :string, required: true
|
||
|
||
defp filter_chip(assigns) do
|
||
assigns = assign(assigns, label: filter_label(assigns.dimension, assigns.value))
|
||
|
||
~H"""
|
||
<span style="display: inline-flex; align-items: center; gap: 0.25rem; padding: 0.25rem 0.5rem; font-size: 0.75rem; background: var(--color-base-200, #e5e5e5); border-radius: 0.25rem;">
|
||
{@label}
|
||
<button
|
||
phx-click="remove_filter"
|
||
phx-value-dimension={@dimension}
|
||
style="cursor: pointer; opacity: 0.6; line-height: 1;"
|
||
aria-label={"Remove #{@label} filter"}
|
||
>
|
||
<.icon name="hero-x-mark" class="size-3" />
|
||
</button>
|
||
</span>
|
||
"""
|
||
end
|
||
|
||
defp filter_label(:pathname, val), do: "Page: #{val}"
|
||
defp filter_label(:referrer_source, val), do: "Source: #{val}"
|
||
defp filter_label(:country_code, val), do: "Country: #{country_name(val)}"
|
||
defp filter_label(:browser, val), do: "Browser: #{val}"
|
||
defp filter_label(:os, val), do: "OS: #{val}"
|
||
defp filter_label(:screen_size, val), do: "Screen: #{val}"
|
||
|
||
# ── Bar chart (HTML/CSS bars with readable labels) ──
|
||
|
||
attr :data, :list, required: true
|
||
attr :mode, :atom, required: true
|
||
|
||
defp bar_chart(assigns) do
|
||
data = assigns.data
|
||
max_val = data |> Enum.map(& &1.visitors) |> Enum.max(fn -> 1 end)
|
||
|
||
scale_max = nice_max(max_val)
|
||
scale_mid = div(scale_max, 2)
|
||
bar_count = max(length(data), 1)
|
||
# No gap for dense charts (12m), small gap for sparse (today/7d)
|
||
bar_gap = if bar_count > 60, do: 0, else: 1
|
||
|
||
bars =
|
||
data
|
||
|> Enum.with_index()
|
||
|> Enum.map(fn {entry, i} ->
|
||
visitors = entry.visitors
|
||
height_pct = if scale_max > 0, do: visitors / scale_max * 100, else: 0
|
||
|
||
label =
|
||
case assigns.mode do
|
||
:hourly -> "#{entry.hour}:00"
|
||
:daily -> format_date_label(entry.date)
|
||
end
|
||
|
||
%{height_pct: height_pct, label: label, visitors: visitors, index: i}
|
||
end)
|
||
|
||
# X-axis labels
|
||
x_labels =
|
||
cond do
|
||
# 12m: show month names at month boundaries
|
||
bar_count > 60 ->
|
||
data
|
||
|> Enum.with_index()
|
||
|> Enum.chunk_by(fn {entry, _i} ->
|
||
date = parse_date(entry.date)
|
||
{date.year, date.month}
|
||
end)
|
||
|> Enum.map(fn [{entry, i} | _] ->
|
||
date = parse_date(entry.date)
|
||
%{label: Calendar.strftime(date, "%b"), index: i}
|
||
end)
|
||
# Skip first partial month if it starts mid-month
|
||
|> then(fn labels ->
|
||
case labels do
|
||
[first | rest] ->
|
||
date = parse_date(Enum.at(data, first.index).date)
|
||
if date.day > 7, do: rest, else: labels
|
||
|
||
_ ->
|
||
labels
|
||
end
|
||
end)
|
||
|
||
# Hourly: 6 evenly spaced labels
|
||
assigns.mode == :hourly ->
|
||
step = bar_count / 6
|
||
Enum.filter(bars, fn bar -> rem(bar.index, max(round(step), 1)) == 0 end)
|
||
|
||
# 30d: weekly intervals
|
||
bar_count > 14 ->
|
||
Enum.filter(bars, fn bar -> rem(bar.index, 7) == 0 end)
|
||
|
||
# 7d: every bar gets a label
|
||
true ->
|
||
bars
|
||
end
|
||
|
||
assigns =
|
||
assign(assigns,
|
||
bars: bars,
|
||
scale_max: scale_max,
|
||
scale_mid: scale_mid,
|
||
x_labels: x_labels,
|
||
bar_gap: bar_gap
|
||
)
|
||
|
||
~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>
|
||
<div
|
||
:if={@data != []}
|
||
id="analytics-chart"
|
||
phx-hook="ChartTooltip"
|
||
style="display: grid; grid-template-columns: auto 1fr; gap: 0 0.5rem; position: relative;"
|
||
>
|
||
<%!-- Tooltip --%>
|
||
<div
|
||
data-tooltip
|
||
style="display: none; position: absolute; top: -1.75rem; z-index: 10; padding: 0.25rem 0.5rem; font-size: 0.75rem; font-weight: 500; white-space: nowrap; background: var(--color-base-content, #1e1e1e); color: var(--color-base-100, #fff); border-radius: 0.25rem; pointer-events: none; font-variant-numeric: tabular-nums;"
|
||
>
|
||
</div>
|
||
<%!-- Row 1: Y-axis labels + chart --%>
|
||
<div
|
||
class="analytics-y-labels"
|
||
style="display: flex; flex-direction: column; justify-content: space-between; align-items: flex-end; height: 8rem;"
|
||
>
|
||
<span>{format_number(@scale_max)}</span>
|
||
<span>{format_number(@scale_mid)}</span>
|
||
<span>0</span>
|
||
</div>
|
||
<div style="position: relative; height: 8rem;">
|
||
<%!-- Gridlines --%>
|
||
<div style="position: absolute; top: 50%; left: 0; right: 0; border-top: 1px dashed color-mix(in oklch, var(--color-base-content) 12%, transparent);">
|
||
</div>
|
||
<div style="position: absolute; bottom: 0; left: 0; right: 0; border-top: 1px solid color-mix(in oklch, var(--color-base-content) 15%, transparent);">
|
||
</div>
|
||
<%!-- Bars container --%>
|
||
<div
|
||
data-bars
|
||
style={"display: flex; align-items: flex-end; height: 100%; gap: #{@bar_gap}px;"}
|
||
>
|
||
<div
|
||
:for={bar <- @bars}
|
||
data-label={bar.label}
|
||
data-visitors={bar.visitors}
|
||
style={"flex: 1; height: #{max(bar.height_pct, 0.5)}%; background: var(--color-primary, #4f46e5); opacity: 0.8; border-radius: 1px 1px 0 0; min-width: 0;"}
|
||
>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<%!-- Row 2: empty cell + X-axis labels --%>
|
||
<div></div>
|
||
<div
|
||
class="analytics-x-labels"
|
||
style="display: flex; justify-content: space-between; padding-top: 0.25rem;"
|
||
>
|
||
<span :for={bar <- @x_labels}>{bar.label}</span>
|
||
</div>
|
||
</div>
|
||
"""
|
||
end
|
||
|
||
defp format_date_label(date) when is_binary(date) do
|
||
case Date.from_iso8601(date) do
|
||
{:ok, d} -> Calendar.strftime(d, "%b %d")
|
||
_ -> date
|
||
end
|
||
end
|
||
|
||
defp format_date_label(date), do: to_string(date)
|
||
|
||
defp parse_date(date) when is_binary(date) do
|
||
case Date.from_iso8601(date) do
|
||
{:ok, d} -> d
|
||
_ -> Date.utc_today()
|
||
end
|
||
end
|
||
|
||
defp parse_date(%Date{} = date), do: date
|
||
|
||
defp nice_max(0), do: 10
|
||
|
||
defp nice_max(val) do
|
||
magnitude = :math.pow(10, floor(:math.log10(val)))
|
||
step = magnitude / 2
|
||
ceil(val / step) * trunc(step)
|
||
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, filter: {:pathname, :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, filter: {:referrer_source, :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
|
||
rows =
|
||
Enum.map(assigns.top_countries, fn c ->
|
||
Map.put(c, :display_name, country_name(c.country_code))
|
||
end)
|
||
|
||
assigns = assign(assigns, :country_rows, rows)
|
||
|
||
~H"""
|
||
<h3 style="font-size: 0.875rem; font-weight: 600; margin-bottom: 0.75rem;">Countries</h3>
|
||
<.detail_table
|
||
rows={@country_rows}
|
||
empty_message="No country data yet"
|
||
columns={[
|
||
%{label: "Country", key: :display_name, filter: {:country_code, :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, filter: {:browser, :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, filter: {:os, :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, filter: {:screen_size, :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;"}
|
||
>
|
||
<%= if col[:filter] do %>
|
||
<span
|
||
class="admin-link"
|
||
style="cursor: pointer;"
|
||
phx-click="add_filter"
|
||
phx-value-dimension={elem(col.filter, 0)}
|
||
phx-value-value={Map.get(row, elem(col.filter, 1))}
|
||
>
|
||
{Map.get(row, col.key)}
|
||
</span>
|
||
<% else %>
|
||
{Map.get(row, col.key)}
|
||
<% end %>
|
||
</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}
|
||
]
|
||
|
||
top_count = assigns.funnel.product_views
|
||
|
||
conversion_rate =
|
||
if top_count > 0,
|
||
do: Float.round(assigns.funnel.purchases / top_count * 100, 1),
|
||
else: 0.0
|
||
|
||
steps_with_rates =
|
||
steps
|
||
|> Enum.with_index()
|
||
|> Enum.map(fn {{label, count}, i} ->
|
||
overall_rate = if top_count > 0, do: Float.round(count / top_count * 100, 1), else: 0.0
|
||
width_pct = if top_count > 0, do: max(count / top_count * 100, 5), else: 5
|
||
|
||
%{label: label, count: count, overall_rate: overall_rate, width_pct: width_pct, index: i}
|
||
end)
|
||
|
||
assigns = assign(assigns, steps: steps_with_rates, conversion_rate: conversion_rate)
|
||
|
||
~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;">
|
||
{format_number(step.count)}
|
||
</span>
|
||
</div>
|
||
<span
|
||
:if={step.index > 0}
|
||
style="font-size: 0.8125rem; font-weight: 600;"
|
||
>
|
||
{step.overall_rate}%
|
||
</span>
|
||
</div>
|
||
<div style="margin-top: 0.75rem; font-size: 0.875rem; display: flex; gap: 0.5rem; flex-wrap: wrap;">
|
||
<span style="font-weight: 600;">{@conversion_rate}% overall conversion</span>
|
||
<span
|
||
:if={@revenue > 0}
|
||
style="color: color-mix(in oklch, var(--color-base-content) 60%, transparent);"
|
||
>
|
||
· Revenue: {Cart.format_price(@revenue)}
|
||
</span>
|
||
</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
|