mirror of
https://github.com/we-promise/sure.git
synced 2026-09-03 05:41:18 +00:00
* fix(dashboard): fit money-flow month labels and put its figures on scale Two problems in the Money In / Out widget. The month labels collided on a phone. The axis rendered whatever `short_month_year` produced with no regard for the space each band actually has, and that format is only short in some locales: "Mar 2026" in English, but "Mar de 2026" in ca/es/pt. Measured on a 390px viewport in Catalan, the labels are 62-71px against a 56px band step — five overlapping pairs, worst overlap 15px. English is not immune, just further from the edge: it clears by 2px at 390px and overlaps four pairs at 360px. The chart now measures what it rendered and steps down until it fits: the full label, then an abbreviated month, then every other tick with the highlighted month always kept. Measuring beats assuming a character width, which changes with locale, font and zoom. The income and expense amounts also carried no size class, so they inherited the 16px base. That is off the scale the dashboard uses — `text-sm` for a repeated row, `text-lg` for a section total, `text-3xl` for a hero figure — and left each amount larger than the label sitting next to it. They are now `text-sm`, matching both their own labels and the equivalent amount in the outflows widget. The balance keeps `text-lg`: that matches the balance sheet's group total, and with the rows corrected it now reads as the summary of the two beneath it rather than one more oversized number. * fix(dashboard): take the thinned-label parity from the highlighted month The thinning tier kept every even index plus the highlighted month. That month sits last — build_money_flow_data counts down to the selected month — so at six bars it kept 0, 2, 4 and 5, leaving the final pair one step apart: exactly the spacing the code had just measured as too tight. Take the parity from the highlighted index instead, so that month is part of the pattern rather than an exception to it. At 240px it now keeps 1, 3, 5 (Abr, Jun, Ago) evenly spaced with the selected month still shown, against 0, 2, 4, 5 before.
506 lines
21 KiB
Ruby
506 lines
21 KiB
Ruby
class PagesController < ApplicationController
|
|
include Periodable
|
|
|
|
# Per-widget dashboard layout guardrails. Deterministic defaults the masonry
|
|
# packer reads; users may override a grow widget's height via presets.
|
|
# col_span: "single" | "full" (full spans both columns in 2-col mode)
|
|
# grow: true for charts that should fill an allotted height,
|
|
# false for content-sized widgets (tables, stat grids)
|
|
# min_height: floor in px
|
|
DASHBOARD_SECTION_LAYOUTS = {
|
|
# Width-toggleable but full by default: the feed is much shorter than any
|
|
# other single-width widget, so defaulting to half leaves a grid hole the
|
|
# masonry can't backfill (dense placement needs a later card short enough
|
|
# to fit beside it, and none is). Users who pair it manually can go half.
|
|
"insights_feed" => { col_span: "full", grow: false, min_height: 0, width_toggle: true },
|
|
"cashflow_sankey" => { col_span: "full", grow: false, min_height: 384, width_toggle: true },
|
|
"money_flow" => { col_span: "single", grow: false, min_height: 0, width_toggle: true },
|
|
"outflows_donut" => { col_span: "single", grow: false, min_height: 0 },
|
|
"investment_summary" => { col_span: "single", grow: false, min_height: 0, width_toggle: true },
|
|
"net_worth_chart" => { col_span: "single", grow: true, min_height: 208, width_toggle: true },
|
|
"balance_sheet" => { col_span: "single", grow: false, min_height: 0, width_toggle: true }
|
|
}.freeze
|
|
|
|
# Number of consecutive months (ending at the selected month) shown as
|
|
# bars in the "money_flow" dashboard widget.
|
|
MONEY_FLOW_CHART_MONTHS = 6
|
|
|
|
# Selectable height presets (px) for grow widgets.
|
|
DASHBOARD_HEIGHT_PRESETS = { "compact" => 208, "auto" => 288, "tall" => 416 }.freeze
|
|
DEFAULT_HEIGHT_PRESET = "auto"
|
|
|
|
skip_authentication only: %i[redis_configuration_error privacy terms]
|
|
before_action :ensure_intro_guest!, only: :intro
|
|
|
|
def dashboard
|
|
if Current.user&.ui_layout_intro?
|
|
redirect_to chats_path and return
|
|
end
|
|
|
|
@balance_sheet = Current.family.balance_sheet
|
|
@investment_statement = Current.family.investment_statement
|
|
@accounts = Current.user.accessible_accounts.visible.with_attached_logo
|
|
|
|
family_currency = Current.family.currency
|
|
|
|
# Use IncomeStatement for all cashflow data (now includes categorized trades)
|
|
income_statement = Current.family.income_statement
|
|
income_totals = income_statement.income_totals(period: @period)
|
|
expense_totals = income_statement.expense_totals(period: @period)
|
|
net_totals = income_statement.net_category_totals(period: @period)
|
|
|
|
@cashflow_sankey_data = build_cashflow_sankey_data(net_totals, income_totals, expense_totals, family_currency)
|
|
@outflows_data = build_outflows_donut_data(net_totals)
|
|
# Preview-gated: skip the query outright rather than loading rows the
|
|
# section won't be built from.
|
|
@feed_insights = preview_features_enabled? ? Current.family.insights.visible.ordered.limit(Insight::FEED_LIMIT) : Insight.none
|
|
|
|
@money_flow_accounts = income_statement.eligible_accounts
|
|
@money_flow_month = money_flow_month_param
|
|
@money_flow_account_ids = money_flow_account_ids_param
|
|
@money_flow_data = build_money_flow_data(income_statement, @money_flow_month, @money_flow_account_ids)
|
|
|
|
@dashboard_sections = build_dashboard_sections
|
|
|
|
@breadcrumbs = [ [ t("breadcrumbs.home"), root_path ], [ t("breadcrumbs.dashboard"), nil ] ]
|
|
end
|
|
|
|
def intro
|
|
@breadcrumbs = [ [ t("breadcrumbs.home"), chats_path ], [ t("breadcrumbs.intro"), nil ] ]
|
|
end
|
|
|
|
def update_preferences
|
|
if Current.user.update_dashboard_preferences(preferences_params)
|
|
head :ok
|
|
else
|
|
head :unprocessable_entity
|
|
end
|
|
end
|
|
|
|
def changelog
|
|
@breadcrumbs = [ [ t("breadcrumbs.home"), root_path ], [ t("breadcrumbs.changelog"), nil ] ]
|
|
@release_notes = github_provider.fetch_latest_release_notes
|
|
|
|
# Fallback if no release notes are available
|
|
if @release_notes.nil?
|
|
@release_notes = {
|
|
avatar: "https://github.com/we-promise.png",
|
|
username: "we-promise",
|
|
name: t("pages.release_notes_unavailable.name"),
|
|
published_at: Date.current,
|
|
body: t("pages.release_notes_unavailable.body_html")
|
|
}
|
|
end
|
|
|
|
render layout: "settings"
|
|
end
|
|
|
|
def feedback
|
|
@breadcrumbs = [ [ t("breadcrumbs.home"), root_path ], [ t("breadcrumbs.feedback"), nil ] ]
|
|
render layout: "settings"
|
|
end
|
|
|
|
def redis_configuration_error
|
|
render layout: "blank"
|
|
end
|
|
|
|
def privacy
|
|
render layout: "blank"
|
|
end
|
|
|
|
def terms
|
|
render layout: "blank"
|
|
end
|
|
|
|
private
|
|
def preferences_params
|
|
prefs = params.require(:preferences)
|
|
{}.tap do |permitted|
|
|
permitted["collapsed_sections"] = prefs[:collapsed_sections].to_unsafe_h if prefs[:collapsed_sections].respond_to?(:to_unsafe_h)
|
|
permitted["section_order"] = prefs[:section_order] if prefs[:section_order].is_a?(Array)
|
|
permitted["dashboard_section_layout"] = prefs[:dashboard_section_layout].to_unsafe_h if prefs[:dashboard_section_layout].respond_to?(:to_unsafe_h)
|
|
end
|
|
end
|
|
|
|
# Preview-gated, and omitted from the section list entirely rather than
|
|
# left in it with `visible: false`. Dropping it here means the two
|
|
# downstream behaviors fall out for free: the saved-order lookup finds
|
|
# nothing to map, and the insights_feed unshift special-case never fires.
|
|
def insights_feed_section
|
|
return nil unless preview_features_enabled?
|
|
|
|
{
|
|
key: "insights_feed",
|
|
title: "pages.dashboard.insights_feed.title",
|
|
partial: "pages/dashboard/insights_feed",
|
|
layout: section_layout("insights_feed"),
|
|
locals: { insights: @feed_insights },
|
|
visible: @feed_insights.any?,
|
|
collapsible: true
|
|
}
|
|
end
|
|
|
|
def build_dashboard_sections
|
|
all_sections = [
|
|
insights_feed_section,
|
|
{
|
|
key: "cashflow_sankey",
|
|
title: "pages.dashboard.cashflow_sankey.title",
|
|
partial: "pages/dashboard/cashflow_sankey",
|
|
layout: section_layout("cashflow_sankey"),
|
|
locals: { sankey_data: @cashflow_sankey_data, period: @period },
|
|
visible: @accounts.any?,
|
|
collapsible: true
|
|
},
|
|
{
|
|
key: "money_flow",
|
|
title: "pages.dashboard.money_flow.title",
|
|
partial: "pages/dashboard/money_flow",
|
|
layout: section_layout("money_flow"),
|
|
locals: { money_flow_data: @money_flow_data, accounts: @money_flow_accounts, col_span: section_layout("money_flow")[:col_span] },
|
|
visible: @accounts.any?,
|
|
collapsible: true
|
|
},
|
|
{
|
|
key: "outflows_donut",
|
|
title: "pages.dashboard.outflows_donut.title",
|
|
partial: "pages/dashboard/outflows_donut",
|
|
layout: section_layout("outflows_donut"),
|
|
locals: { outflows_data: @outflows_data, period: @period },
|
|
visible: @accounts.any? && @outflows_data[:categories].present?,
|
|
collapsible: true
|
|
},
|
|
{
|
|
key: "investment_summary",
|
|
title: "pages.dashboard.investment_summary.title",
|
|
partial: "pages/dashboard/investment_summary",
|
|
layout: section_layout("investment_summary"),
|
|
locals: { investment_statement: @investment_statement, period: @period },
|
|
visible: @accounts.any? && @investment_statement.investment_accounts.any?,
|
|
collapsible: true
|
|
},
|
|
{
|
|
key: "net_worth_chart",
|
|
title: "pages.dashboard.net_worth_chart.title",
|
|
partial: "pages/dashboard/net_worth_chart",
|
|
layout: section_layout("net_worth_chart"),
|
|
locals: { balance_sheet: @balance_sheet, period: @period },
|
|
visible: @accounts.any?,
|
|
collapsible: true
|
|
},
|
|
{
|
|
key: "balance_sheet",
|
|
title: "pages.dashboard.balance_sheet.title",
|
|
partial: "pages/dashboard/balance_sheet",
|
|
layout: section_layout("balance_sheet"),
|
|
locals: { balance_sheet: @balance_sheet },
|
|
visible: @accounts.any?,
|
|
collapsible: true
|
|
}
|
|
].compact
|
|
|
|
# Order sections according to user preference
|
|
section_order = Current.user.dashboard_section_order
|
|
ordered_sections = section_order.map do |key|
|
|
all_sections.find { |s| s[:key] == key }
|
|
end.compact
|
|
|
|
# Add any new sections that aren't in the saved order (future-proofing).
|
|
# The insights feed leads instead of appending: it's a proactive surface,
|
|
# and appending would bury it below the fold for every family with a
|
|
# saved order. Users can still drag it back down — that choice persists.
|
|
all_sections.each do |section|
|
|
next if ordered_sections.include?(section)
|
|
|
|
if section[:key] == "insights_feed"
|
|
ordered_sections.unshift(section)
|
|
else
|
|
ordered_sections << section
|
|
end
|
|
end
|
|
|
|
ordered_sections
|
|
end
|
|
|
|
# Resolves a section's layout guardrails, applying the user's height preset
|
|
# override (falling back to the deterministic default) for grow widgets.
|
|
def section_layout(key)
|
|
base = DASHBOARD_SECTION_LAYOUTS.fetch(key, { col_span: "single", grow: false, min_height: 0, width_toggle: false })
|
|
preset = Current.user.dashboard_section_height(key)
|
|
preset = DEFAULT_HEIGHT_PRESET unless DASHBOARD_HEIGHT_PRESETS.key?(preset)
|
|
|
|
col_span = base[:col_span]
|
|
if base[:width_toggle]
|
|
user_span = Current.user.dashboard_section_width(key)
|
|
col_span = user_span if %w[single full].include?(user_span)
|
|
end
|
|
|
|
base.merge(col_span: col_span, height_preset: preset, height_px: DASHBOARD_HEIGHT_PRESETS.fetch(preset))
|
|
end
|
|
|
|
def github_provider
|
|
Provider::Registry.get_provider(:github)
|
|
end
|
|
|
|
def build_cashflow_sankey_data(net_totals, income_totals, expense_totals, currency)
|
|
nodes = []
|
|
links = []
|
|
node_indices = {}
|
|
|
|
add_node = ->(unique_key, display_name, value, percentage, color) {
|
|
node_indices[unique_key] ||= begin
|
|
nodes << { id: unique_key, name: display_name, value: value.to_f.round(2), percentage: percentage.to_f.round(1), color: color }
|
|
nodes.size - 1
|
|
end
|
|
}
|
|
|
|
total_income = net_totals.total_net_income.to_f.round(2)
|
|
total_expense = net_totals.total_net_expense.to_f.round(2)
|
|
|
|
# Central Cash Flow node
|
|
cash_flow_idx = add_node.call("cash_flow_node", "Cash Flow", total_income, 100.0, "var(--color-success)")
|
|
|
|
# Build netted subcategory data from raw totals
|
|
net_subcategories_by_parent = build_net_subcategories(expense_totals, income_totals)
|
|
|
|
# Process net income categories (flow: subcategory -> parent -> cash_flow)
|
|
process_net_category_nodes(
|
|
categories: net_totals.net_income_categories,
|
|
total: total_income,
|
|
prefix: "income",
|
|
net_subcategories_by_parent: net_subcategories_by_parent,
|
|
add_node: add_node,
|
|
links: links,
|
|
cash_flow_idx: cash_flow_idx,
|
|
flow_direction: :inbound
|
|
)
|
|
|
|
# Process net expense categories (flow: cash_flow -> parent -> subcategory)
|
|
process_net_category_nodes(
|
|
categories: net_totals.net_expense_categories,
|
|
total: total_expense,
|
|
prefix: "expense",
|
|
net_subcategories_by_parent: net_subcategories_by_parent,
|
|
add_node: add_node,
|
|
links: links,
|
|
cash_flow_idx: cash_flow_idx,
|
|
flow_direction: :outbound
|
|
)
|
|
|
|
# Surplus/Deficit
|
|
net = (total_income - total_expense).round(2)
|
|
if net.positive?
|
|
percentage = total_income.zero? ? 0 : (net / total_income * 100).round(1)
|
|
idx = add_node.call("surplus_node", "Surplus", net, percentage, "var(--color-success)")
|
|
links << { source: cash_flow_idx, target: idx, value: net, color: "var(--color-success)", percentage: percentage }
|
|
end
|
|
|
|
{ nodes: nodes, links: links, currency_symbol: Money::Currency.new(currency).symbol }
|
|
end
|
|
|
|
# Nets subcategory expense and income totals, grouped by parent_id.
|
|
# Returns { parent_id => [ { category:, total: net_amount }, ... ] }
|
|
# Only includes subcategories with positive net (same direction as parent).
|
|
def build_net_subcategories(expense_totals, income_totals)
|
|
expense_subs = expense_totals.category_totals
|
|
.select { |ct| ct.category.parent_id.present? }
|
|
.index_by { |ct| ct.category.id }
|
|
|
|
income_subs = income_totals.category_totals
|
|
.select { |ct| ct.category.parent_id.present? }
|
|
.index_by { |ct| ct.category.id }
|
|
|
|
all_sub_ids = (expense_subs.keys + income_subs.keys).uniq
|
|
result = {}
|
|
|
|
all_sub_ids.each do |sub_id|
|
|
exp_ct = expense_subs[sub_id]
|
|
inc_ct = income_subs[sub_id]
|
|
exp_total = exp_ct&.total || 0
|
|
inc_total = inc_ct&.total || 0
|
|
net = exp_total - inc_total
|
|
category = exp_ct&.category || inc_ct&.category
|
|
|
|
next if net.zero?
|
|
|
|
parent_id = category.parent_id
|
|
result[parent_id] ||= []
|
|
result[parent_id] << { category: category, total: net.abs, net_direction: net > 0 ? :expense : :income }
|
|
end
|
|
|
|
result
|
|
end
|
|
|
|
# Builds sankey nodes/links for net categories with subcategory hierarchy.
|
|
# Subcategories matching the parent's flow direction are shown as children.
|
|
# Subcategories with opposite net direction appear on the OTHER side of the
|
|
# sankey (handled when the other side calls this method).
|
|
#
|
|
# flow_direction: :inbound (subcategory -> parent -> cash_flow) for income
|
|
# :outbound (cash_flow -> parent -> subcategory) for expenses
|
|
def process_net_category_nodes(categories:, total:, prefix:, net_subcategories_by_parent:, add_node:, links:, cash_flow_idx:, flow_direction:)
|
|
matching_direction = flow_direction == :inbound ? :income : :expense
|
|
|
|
categories.each do |ct|
|
|
val = ct.total.to_f.round(2)
|
|
next if val.zero?
|
|
|
|
percentage = total.zero? ? 0 : (val / total * 100).round(1)
|
|
color = ct.category.color.presence || Category::UNCATEGORIZED_COLOR
|
|
node_key = "#{prefix}_#{ct.category.id || ct.category.name}"
|
|
|
|
all_subs = ct.category.id ? (net_subcategories_by_parent[ct.category.id] || []) : []
|
|
same_side_subs = all_subs.select { |s| s[:net_direction] == matching_direction }
|
|
|
|
# Also check if any subcategory has opposite direction — those will be
|
|
# rendered by the OTHER side's call to this method, linked to cash_flow
|
|
# directly (they appear as independent nodes on the opposite side).
|
|
opposite_subs = all_subs.select { |s| s[:net_direction] != matching_direction }
|
|
|
|
if same_side_subs.any?
|
|
parent_idx = add_node.call(node_key, ct.category.name, val, percentage, color)
|
|
|
|
if flow_direction == :inbound
|
|
links << { source: parent_idx, target: cash_flow_idx, value: val, color: color, percentage: percentage }
|
|
else
|
|
links << { source: cash_flow_idx, target: parent_idx, value: val, color: color, percentage: percentage }
|
|
end
|
|
|
|
same_side_subs.each do |sub|
|
|
sub_val = sub[:total].to_f.round(2)
|
|
sub_pct = val.zero? ? 0 : (sub_val / val * 100).round(1)
|
|
sub_color = sub[:category].color.presence || color
|
|
sub_key = "#{prefix}_sub_#{sub[:category].id}"
|
|
sub_idx = add_node.call(sub_key, sub[:category].name, sub_val, sub_pct, sub_color)
|
|
|
|
if flow_direction == :inbound
|
|
links << { source: sub_idx, target: parent_idx, value: sub_val, color: sub_color, percentage: sub_pct }
|
|
else
|
|
links << { source: parent_idx, target: sub_idx, value: sub_val, color: sub_color, percentage: sub_pct }
|
|
end
|
|
end
|
|
else
|
|
idx = add_node.call(node_key, ct.category.name, val, percentage, color)
|
|
|
|
if flow_direction == :inbound
|
|
links << { source: idx, target: cash_flow_idx, value: val, color: color, percentage: percentage }
|
|
else
|
|
links << { source: cash_flow_idx, target: idx, value: val, color: color, percentage: percentage }
|
|
end
|
|
end
|
|
|
|
# Render opposite-direction subcategories as standalone nodes on this side,
|
|
# linked directly to cash_flow. They represent subcategory surplus/deficit
|
|
# that goes against the parent's overall direction.
|
|
opposite_prefix = flow_direction == :inbound ? "expense" : "income"
|
|
opposite_subs.each do |sub|
|
|
sub_val = sub[:total].to_f.round(2)
|
|
sub_pct = total.zero? ? 0 : (sub_val / total * 100).round(1)
|
|
sub_color = sub[:category].color.presence || color
|
|
sub_key = "#{opposite_prefix}_sub_#{sub[:category].id}"
|
|
sub_idx = add_node.call(sub_key, sub[:category].name, sub_val, sub_pct, sub_color)
|
|
|
|
# Opposite direction: if parent is outbound (expense), this sub is inbound (income)
|
|
if flow_direction == :inbound
|
|
links << { source: cash_flow_idx, target: sub_idx, value: sub_val, color: sub_color, percentage: sub_pct }
|
|
else
|
|
links << { source: sub_idx, target: cash_flow_idx, value: sub_val, color: sub_color, percentage: sub_pct }
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
def build_outflows_donut_data(net_totals)
|
|
currency_symbol = Money::Currency.new(net_totals.currency).symbol
|
|
total = net_totals.total_net_expense
|
|
|
|
categories = net_totals.net_expense_categories
|
|
.reject { |ct| ct.total.zero? }
|
|
.sort_by { |ct| -ct.total }
|
|
.map do |ct|
|
|
{
|
|
id: ct.category.id,
|
|
name: ct.category.name,
|
|
amount: ct.total.to_f.round(2),
|
|
currency: ct.currency,
|
|
percentage: ct.weight.round(1),
|
|
color: ct.category.color.presence || Category::UNCATEGORIZED_COLOR,
|
|
icon: ct.category.lucide_icon,
|
|
clickable: !ct.category.other_investments?
|
|
}
|
|
end
|
|
|
|
{ categories: categories, total: total.to_f.round(2), currency: net_totals.currency, currency_symbol: currency_symbol }
|
|
end
|
|
|
|
def money_flow_month_param
|
|
current_month = Date.current.beginning_of_month
|
|
month = Date.strptime(params[:money_flow_month], "%Y-%m-%d").beginning_of_month
|
|
# Clamp future months: build_money_flow_data caps each bar's end_date at
|
|
# Date.current, which would otherwise be earlier than a future month's
|
|
# start_date and blow up Period.custom's date-range validation.
|
|
month > current_month ? current_month : month
|
|
rescue ArgumentError, TypeError
|
|
current_month
|
|
end
|
|
|
|
# nil means "all accessible accounts" (the widget's default, unfiltered state)
|
|
def money_flow_account_ids_param
|
|
ids = Array(params[:money_flow_account_ids]).reject(&:blank?)
|
|
eligible_ids = @money_flow_accounts.map { |a| a.id.to_s }
|
|
ids &= eligible_ids
|
|
ids.presence
|
|
end
|
|
|
|
def build_money_flow_data(income_statement, selected_month, account_ids)
|
|
months = (MONEY_FLOW_CHART_MONTHS - 1).downto(0).map { |i| selected_month - i.months }
|
|
|
|
selected_period = nil
|
|
selected_totals = nil
|
|
|
|
bars = months.map do |month_start|
|
|
# Cap at today so an in-progress month (most commonly the current one)
|
|
# doesn't report totals for its not-yet-arrived days.
|
|
end_date = [ month_start.end_of_month, Date.current ].min
|
|
period = Period.custom(start_date: month_start, end_date: end_date)
|
|
totals = income_statement.totals_for(period, account_ids: account_ids)
|
|
|
|
if month_start == selected_month
|
|
selected_period = period
|
|
selected_totals = totals
|
|
end
|
|
|
|
{
|
|
date: month_start,
|
|
label: I18n.l(month_start, format: :short_month_year),
|
|
# Fallback for the axis when the full label does not fit its band.
|
|
# `short_month_year` is only short in some locales — "Mar 2026" in
|
|
# English, but "Mar de 2026" in ca/es/pt, which is wide enough to
|
|
# collide with its neighbours on a phone. The bar chart measures the
|
|
# rendered labels and drops to this when they overlap.
|
|
short_label: I18n.l(month_start, format: "%b"),
|
|
income: totals.income_money.amount.to_f.round(2),
|
|
expense: totals.expense_money.amount.to_f.round(2),
|
|
highlighted: month_start == selected_month,
|
|
partial: end_date < month_start.end_of_month
|
|
}
|
|
end
|
|
|
|
{
|
|
bars: bars,
|
|
period: selected_period,
|
|
month: selected_month,
|
|
income: selected_totals.income_money,
|
|
expense: selected_totals.expense_money,
|
|
balance: selected_totals.income_money - selected_totals.expense_money,
|
|
account_ids: account_ids
|
|
}
|
|
end
|
|
|
|
def ensure_intro_guest!
|
|
return if Current.user&.guest?
|
|
|
|
redirect_to root_path, alert: t("pages.intro.not_authorized", default: "Intro is only available to guest users.")
|
|
end
|
|
end
|