2026-02-22 12:50:55 +00:00
|
|
|
|
defmodule BerrypodWeb.Admin.Analytics do
|
|
|
|
|
|
use BerrypodWeb, :live_view
|
|
|
|
|
|
|
|
|
|
|
|
alias Berrypod.Analytics
|
|
|
|
|
|
alias Berrypod.Cart
|
|
|
|
|
|
|
|
|
|
|
|
@periods %{
|
|
|
|
|
|
"today" => 0,
|
|
|
|
|
|
"7d" => 6,
|
|
|
|
|
|
"30d" => 29,
|
|
|
|
|
|
"12m" => 364
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-23 13:46:34 +00:00
|
|
|
|
@filterable_dimensions ~w(pathname referrer_source country_code browser os screen_size)
|
|
|
|
|
|
|
2026-02-22 12:50:55 +00:00
|
|
|
|
@impl true
|
|
|
|
|
|
def mount(_params, _session, socket) do
|
|
|
|
|
|
{:ok,
|
|
|
|
|
|
socket
|
|
|
|
|
|
|> assign(:page_title, "Analytics")
|
|
|
|
|
|
|> assign(:period, "30d")
|
|
|
|
|
|
|> assign(:tab, "pages")
|
2026-02-23 13:46:34 +00:00
|
|
|
|
|> assign(:filters, %{})
|
2026-02-22 12:50:55 +00:00
|
|
|
|
|> 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
|
|
|
|
|
|
|
2026-02-23 13:46:34 +00:00
|
|
|
|
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
|
|
|
|
|
|
|
2026-02-22 12:50:55 +00:00
|
|
|
|
# ── Data loading ──
|
|
|
|
|
|
|
|
|
|
|
|
defp load_analytics(socket, period) do
|
|
|
|
|
|
range = date_range(period)
|
2026-02-23 01:01:25 +00:00
|
|
|
|
prev_range = previous_date_range(period)
|
2026-02-23 13:46:34 +00:00
|
|
|
|
filters = socket.assigns.filters
|
2026-02-22 12:50:55 +00:00
|
|
|
|
|
2026-02-22 23:28:35 +00:00
|
|
|
|
trend_data =
|
|
|
|
|
|
if period == "today",
|
2026-02-23 13:46:34 +00:00
|
|
|
|
do: Analytics.visitors_by_hour(range, filters),
|
|
|
|
|
|
else: Analytics.visitors_by_date(range, filters)
|
2026-02-22 23:28:35 +00:00
|
|
|
|
|
2026-02-23 13:46:34 +00:00
|
|
|
|
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)
|
2026-02-23 01:01:25 +00:00
|
|
|
|
|
2026-02-23 13:46:34 +00:00
|
|
|
|
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)
|
2026-02-23 01:01:25 +00:00
|
|
|
|
|
2026-02-22 12:50:55 +00:00
|
|
|
|
socket
|
2026-02-23 01:01:25 +00:00
|
|
|
|
|> 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))
|
2026-02-22 23:28:35 +00:00
|
|
|
|
|> assign(:trend_data, trend_data)
|
|
|
|
|
|
|> assign(:trend_mode, if(period == "today", do: :hourly, else: :daily))
|
2026-02-23 13:46:34 +00:00
|
|
|
|
|> 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))
|
2026-02-22 12:50:55 +00:00
|
|
|
|
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
|
|
|
|
|
|
|
2026-02-23 01:01:25 +00:00
|
|
|
|
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)
|
|
|
|
|
|
|
2026-02-22 12:50:55 +00:00
|
|
|
|
# ── 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>
|
|
|
|
|
|
|
2026-02-23 13:46:34 +00:00
|
|
|
|
<%!-- 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>
|
|
|
|
|
|
|
2026-02-22 12:50:55 +00:00
|
|
|
|
<%!-- Stat cards --%>
|
|
|
|
|
|
<div class="admin-stats-grid" style="margin-top: 1.5rem;">
|
2026-02-23 01:01:25 +00:00
|
|
|
|
<.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}
|
|
|
|
|
|
/>
|
2026-02-22 12:50:55 +00:00
|
|
|
|
</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>
|
2026-02-22 23:28:35 +00:00
|
|
|
|
<.bar_chart data={@trend_data} mode={@trend_mode} />
|
2026-02-22 12:50:55 +00:00
|
|
|
|
</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
|
2026-02-23 01:01:25 +00:00
|
|
|
|
attr :delta, :any, default: nil
|
|
|
|
|
|
attr :invert, :boolean, default: false
|
2026-02-22 12:50:55 +00:00
|
|
|
|
|
|
|
|
|
|
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>
|
2026-02-23 01:01:25 +00:00
|
|
|
|
<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>
|
2026-02-22 12:50:55 +00:00
|
|
|
|
<p style="font-size: 0.875rem; color: color-mix(in oklch, var(--color-base-content) 60%, transparent);">
|
|
|
|
|
|
{@label}
|
|
|
|
|
|
</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
"""
|
|
|
|
|
|
end
|
|
|
|
|
|
|
2026-02-23 01:01:25 +00:00
|
|
|
|
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
|
|
|
|
|
|
|
2026-02-23 13:46:34 +00:00
|
|
|
|
# ── 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}"
|
|
|
|
|
|
|
2026-02-22 23:28:35 +00:00
|
|
|
|
# ── Bar chart (HTML/CSS bars with readable labels) ──
|
2026-02-22 12:50:55 +00:00
|
|
|
|
|
|
|
|
|
|
attr :data, :list, required: true
|
2026-02-22 23:28:35 +00:00
|
|
|
|
attr :mode, :atom, required: true
|
2026-02-22 12:50:55 +00:00
|
|
|
|
|
|
|
|
|
|
defp bar_chart(assigns) do
|
|
|
|
|
|
data = assigns.data
|
|
|
|
|
|
max_val = data |> Enum.map(& &1.visitors) |> Enum.max(fn -> 1 end)
|
2026-02-22 23:28:35 +00:00
|
|
|
|
|
|
|
|
|
|
scale_max = nice_max(max_val)
|
|
|
|
|
|
scale_mid = div(scale_max, 2)
|
2026-02-22 12:50:55 +00:00
|
|
|
|
bar_count = max(length(data), 1)
|
2026-02-22 23:28:35 +00:00
|
|
|
|
# No gap for dense charts (12m), small gap for sparse (today/7d)
|
|
|
|
|
|
bar_gap = if bar_count > 60, do: 0, else: 1
|
2026-02-22 12:50:55 +00:00
|
|
|
|
|
|
|
|
|
|
bars =
|
|
|
|
|
|
data
|
|
|
|
|
|
|> Enum.with_index()
|
2026-02-22 23:28:35 +00:00
|
|
|
|
|> 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}
|
2026-02-22 12:50:55 +00:00
|
|
|
|
end)
|
|
|
|
|
|
|
2026-02-23 01:01:25 +00:00
|
|
|
|
# X-axis labels
|
2026-02-22 23:28:35 +00:00
|
|
|
|
x_labels =
|
2026-02-23 01:01:25 +00:00
|
|
|
|
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
|
2026-02-22 23:28:35 +00:00
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
|
|
assigns =
|
|
|
|
|
|
assign(assigns,
|
|
|
|
|
|
bars: bars,
|
|
|
|
|
|
scale_max: scale_max,
|
|
|
|
|
|
scale_mid: scale_mid,
|
|
|
|
|
|
x_labels: x_labels,
|
|
|
|
|
|
bar_gap: bar_gap
|
|
|
|
|
|
)
|
2026-02-22 12:50:55 +00:00
|
|
|
|
|
|
|
|
|
|
~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>
|
2026-02-23 01:01:25 +00:00
|
|
|
|
<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>
|
2026-02-22 23:28:35 +00:00
|
|
|
|
<%!-- 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;"
|
2026-02-22 12:50:55 +00:00
|
|
|
|
>
|
2026-02-22 23:28:35 +00:00
|
|
|
|
<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 --%>
|
2026-02-23 01:01:25 +00:00
|
|
|
|
<div
|
|
|
|
|
|
data-bars
|
|
|
|
|
|
style={"display: flex; align-items: flex-end; height: 100%; gap: #{@bar_gap}px;"}
|
|
|
|
|
|
>
|
2026-02-22 23:28:35 +00:00
|
|
|
|
<div
|
|
|
|
|
|
:for={bar <- @bars}
|
2026-02-23 01:01:25 +00:00
|
|
|
|
data-label={bar.label}
|
|
|
|
|
|
data-visitors={bar.visitors}
|
2026-02-22 23:28:35 +00:00
|
|
|
|
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>
|
2026-02-22 12:50:55 +00:00
|
|
|
|
"""
|
|
|
|
|
|
end
|
|
|
|
|
|
|
2026-02-22 23:28:35 +00:00
|
|
|
|
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)
|
|
|
|
|
|
|
2026-02-23 01:01:25 +00:00
|
|
|
|
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
|
|
|
|
|
|
|
2026-02-22 23:28:35 +00:00
|
|
|
|
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
|
|
|
|
|
|
|
2026-02-22 12:50:55 +00:00
|
|
|
|
# ── 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={[
|
2026-02-23 13:46:34 +00:00
|
|
|
|
%{label: "Page", key: :pathname, filter: {:pathname, :pathname}},
|
2026-02-22 12:50:55 +00:00
|
|
|
|
%{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={[
|
2026-02-23 13:46:34 +00:00
|
|
|
|
%{label: "Source", key: :source, filter: {:referrer_source, :source}},
|
2026-02-22 12:50:55 +00:00
|
|
|
|
%{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
|
2026-02-23 13:46:34 +00:00
|
|
|
|
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)
|
|
|
|
|
|
|
2026-02-22 12:50:55 +00:00
|
|
|
|
~H"""
|
|
|
|
|
|
<h3 style="font-size: 0.875rem; font-weight: 600; margin-bottom: 0.75rem;">Countries</h3>
|
|
|
|
|
|
<.detail_table
|
2026-02-23 13:46:34 +00:00
|
|
|
|
rows={@country_rows}
|
2026-02-22 12:50:55 +00:00
|
|
|
|
empty_message="No country data yet"
|
|
|
|
|
|
columns={[
|
2026-02-23 13:46:34 +00:00
|
|
|
|
%{label: "Country", key: :display_name, filter: {:country_code, :country_code}},
|
2026-02-22 12:50:55 +00:00
|
|
|
|
%{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={[
|
2026-02-23 13:46:34 +00:00
|
|
|
|
%{label: "Browser", key: :name, filter: {:browser, :name}},
|
2026-02-22 12:50:55 +00:00
|
|
|
|
%{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={[
|
2026-02-23 13:46:34 +00:00
|
|
|
|
%{label: "OS", key: :name, filter: {:os, :name}},
|
2026-02-22 12:50:55 +00:00
|
|
|
|
%{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={[
|
2026-02-23 13:46:34 +00:00
|
|
|
|
%{label: "Size", key: :name, filter: {:screen_size, :name}},
|
2026-02-22 12:50:55 +00:00
|
|
|
|
%{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;"}
|
|
|
|
|
|
>
|
2026-02-23 13:46:34 +00:00
|
|
|
|
<%= 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 %>
|
2026-02-22 12:50:55 +00:00
|
|
|
|
</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}
|
|
|
|
|
|
]
|
|
|
|
|
|
|
2026-02-22 22:30:24 +00:00
|
|
|
|
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
|
2026-02-22 12:50:55 +00:00
|
|
|
|
|
|
|
|
|
|
steps_with_rates =
|
|
|
|
|
|
steps
|
|
|
|
|
|
|> Enum.with_index()
|
|
|
|
|
|
|> Enum.map(fn {{label, count}, i} ->
|
2026-02-22 22:30:24 +00:00
|
|
|
|
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
|
2026-02-22 12:50:55 +00:00
|
|
|
|
|
2026-02-22 22:30:24 +00:00
|
|
|
|
%{label: label, count: count, overall_rate: overall_rate, width_pct: width_pct, index: i}
|
2026-02-22 12:50:55 +00:00
|
|
|
|
end)
|
|
|
|
|
|
|
2026-02-22 22:30:24 +00:00
|
|
|
|
assigns = assign(assigns, steps: steps_with_rates, conversion_rate: conversion_rate)
|
2026-02-22 12:50:55 +00:00
|
|
|
|
|
|
|
|
|
|
~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;">
|
2026-02-22 22:30:24 +00:00
|
|
|
|
{format_number(step.count)}
|
2026-02-22 12:50:55 +00:00
|
|
|
|
</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<span
|
|
|
|
|
|
:if={step.index > 0}
|
2026-02-22 22:30:24 +00:00
|
|
|
|
style="font-size: 0.8125rem; font-weight: 600;"
|
2026-02-22 12:50:55 +00:00
|
|
|
|
>
|
2026-02-22 22:30:24 +00:00
|
|
|
|
{step.overall_rate}%
|
2026-02-22 12:50:55 +00:00
|
|
|
|
</span>
|
|
|
|
|
|
</div>
|
2026-02-22 22:30:24 +00:00
|
|
|
|
<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>
|
2026-02-22 12:50:55 +00:00
|
|
|
|
</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
|