Skip to content
This repository was archived by the owner on Jul 25, 2025. It is now read-only.

Commit 7ee3119

Browse files
committed
Add FAQs feature: create FAQ model, update sitemap, and enhance navigation
- Introduced FAQ model with attributes: question, answer, slug, category, and meta_image_url. - Updated sitemap index to include links to FAQs. - Enhanced navigation helper to add a link for Financial FAQs. - Updated routes to include FAQs resource with a custom path.
1 parent 3c91f91 commit 7ee3119

12 files changed

Lines changed: 394 additions & 2 deletions

File tree

app/avo/resources/faq.rb

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
class Avo::Resources::Faq < Avo::BaseResource
2+
self.find_record_method = -> {
3+
query.find_by_slug(id)
4+
}
5+
6+
self.includes = []
7+
# self.search = {
8+
# query: -> { query.ransack(id_eq: params[:q], m: "or").result(distinct: false) }
9+
# }
10+
11+
def fields
12+
field :id, as: :id
13+
field :question, as: :text
14+
field :answer, as: :easy_mde
15+
field :slug, as: :text
16+
field :category, as: :select, options: Faq::CATEGORIES
17+
end
18+
end

app/controllers/faqs_controller.rb

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
# FaqsController handles requests related to FAQ objects.
2+
# It provides functionality for listing and showing individual FAQs with search and filtering capabilities.
3+
class FaqsController < ApplicationController
4+
include Pagy::Backend
5+
6+
# GET /faqs
7+
# Lists all FAQs, optionally filtered by search query and/or category.
8+
#
9+
# @param q [String] Optional search query to filter FAQs by question or answer
10+
# @param category [String] Optional category to filter FAQs
11+
# @return [Array<Faq>] Collection of FAQ objects
12+
#
13+
# @example
14+
# GET /faqs
15+
# GET /faqs?q=investment
16+
# GET /faqs?category=retirement
17+
# GET /faqs?q=budget&category=planning
18+
def index
19+
@query = params[:q]
20+
@selected_category = params[:category]
21+
22+
@faqs = Faq.alphabetical
23+
24+
# Apply search if query parameter is present
25+
@faqs = @faqs.search(@query) if @query.present?
26+
27+
# Apply category filtering if parameter is present
28+
@faqs = @faqs.by_category(@selected_category) if @selected_category.present?
29+
30+
# Get all available categories for filtering
31+
@categories = Faq.categories
32+
33+
# Add pagination
34+
@pagy, @faqs = pagy(@faqs, items: 12)
35+
36+
respond_to do |format|
37+
format.html
38+
format.json { render json: @faqs }
39+
end
40+
end
41+
42+
# GET /faqs/:id
43+
# Displays a specific FAQ based on its slug.
44+
#
45+
# @param id [String] The slug of the FAQ to display
46+
# @return [Faq] The requested FAQ object
47+
#
48+
# @example
49+
# GET /faqs/what-is-compound-interest
50+
def show
51+
@faq = Faq.find_by(slug: params[:id])
52+
53+
unless @faq
54+
redirect_to faqs_path, alert: "FAQ not found"
55+
return
56+
end
57+
58+
# Get related FAQs from the same category
59+
@related_faqs = if @faq.category.present?
60+
Faq.by_category(@faq.category)
61+
.where.not(id: @faq.id)
62+
.alphabetical
63+
.limit(5)
64+
else
65+
Faq.where.not(id: @faq.id)
66+
.recent
67+
.limit(5)
68+
end
69+
70+
respond_to do |format|
71+
format.html
72+
format.json { render json: @faq }
73+
end
74+
end
75+
end

app/controllers/pages_controller.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ def privacy
3535
def sitemap_index
3636
@terms = Term.all
3737
@articles = Article.all.order(publish_at: :desc).where("publish_at <= ?", Time.now)
38+
@faqs = Faq.all.order(:question)
3839
@tools = Tool.all
3940

4041
@exchange_rate_currencies = Tool::Presenter::ExchangeRateCalculator.new.currency_options

app/helpers/navigation_helper.rb

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,10 @@ def header_nav_resources_links
3838
text: "Financial Terms",
3939
path: terms_path
4040
},
41+
{
42+
text: "Financial FAQs",
43+
path: faqs_path
44+
},
4145
{
4246
text: "Join Community",
4347
path: "https://link.maybe.co/discord"
@@ -96,6 +100,10 @@ def footer_resources
96100
text: "Financial Terms",
97101
path: terms_path
98102
},
103+
{
104+
text: "Financial FAQs",
105+
path: faqs_path
106+
},
99107
{
100108
text: "Join Community",
101109
path: "https://link.maybe.co/discord"

app/models/faq.rb

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
# == Schema Information
2+
#
3+
# Table name: faqs
4+
#
5+
# id :integer not null, primary key
6+
# question :string
7+
# answer :text
8+
# slug :string
9+
# category :string
10+
# meta_image_url :string
11+
# created_at :datetime not null
12+
# updated_at :datetime not null
13+
#
14+
# Indexes
15+
#
16+
# index_faqs_on_category (category)
17+
# index_faqs_on_slug (slug) UNIQUE
18+
#
19+
20+
class Faq < ApplicationRecord
21+
include MetaImage
22+
23+
# Constants for category options
24+
CATEGORIES = [
25+
"Getting Started",
26+
"Budgeting",
27+
"Investing",
28+
"Retirement",
29+
"Taxes",
30+
"Banking",
31+
"Credit & Debt",
32+
"Insurance"
33+
].freeze
34+
35+
# Validations
36+
validates :question, presence: true
37+
validates :answer, presence: true
38+
validates :slug, presence: true, uniqueness: true
39+
validates :category, inclusion: { in: CATEGORIES }, allow_blank: true
40+
41+
# Scopes
42+
scope :by_category, ->(category) { where(category: category) }
43+
scope :alphabetical, -> { order(:question) }
44+
scope :recent, -> { order(created_at: :desc) }
45+
46+
# Class methods
47+
def self.random_sample(count, exclude:)
48+
where.not(id: exclude.id).order(Arel.sql("RANDOM()")).limit(count)
49+
end
50+
51+
def self.search(query)
52+
return all if query.blank?
53+
54+
where("question ILIKE :query OR answer ILIKE :query", query: "%#{query}%")
55+
end
56+
57+
def self.categories
58+
distinct.pluck(:category).compact.sort
59+
end
60+
61+
# Instance methods
62+
def to_param
63+
slug
64+
end
65+
66+
private
67+
68+
def create_meta_image
69+
super(question)
70+
end
71+
end

app/views/faqs/index.html.erb

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
<%
2+
title "Financial FAQs"
3+
description "Get answers to frequently asked questions about personal finance, investing, budgeting, and wealth management."
4+
%>
5+
6+
<div class="max-w-6xl mx-auto">
7+
<div class="w-full mx-auto max-w-[450px] mb-10">
8+
<div class="gap-x-2 mb-6 text-center">
9+
<%= image_tag("maybe-logo-glow-4x.png", alt: "Maybe Logo", style:"width: 115px; margin-left: -22px;", class: "mb-2 inline") %>
10+
<h1 class="text-6xl text-gray-800 leading-[calc(4rem+8px)] font-medium mb-2 tracking-[-0.02em]">Financial FAQs</h1>
11+
<p class="text-xl leading-[1.4] text-gray-400">Get answers to frequently asked questions about personal finance, investing, budgeting, and wealth management.</p>
12+
</div>
13+
14+
<%= form_with method: :get, html: {class: "relative mb-4 max-w-[450px] mx-auto"}, data: { controller: "auto-submit-form", turbo_frame: "faqs_list" } do |f| %>
15+
<%= f.text_field :q, value: @query,
16+
class: "w-full py-2.5 px-3 pl-10 bg-white border border-gray-300 shadow-xs rounded-xl text-base leading-[1.4]",
17+
placeholder: "Search FAQs...",
18+
data: { auto_submit_form_target: "auto" } %>
19+
<%= lucide_icon "search", class: "absolute top-3 left-3 w-5 h-5 text-gray-500 pointer-events-none" %>
20+
<% end %>
21+
22+
<!-- Category Filter -->
23+
<% if @categories.any? %>
24+
<div class="mb-8">
25+
<div class="flex flex-wrap gap-2 justify-center">
26+
<%= link_to "All", faqs_path,
27+
class: "px-4 py-2 rounded-full text-sm font-medium transition-colors #{@selected_category.blank? ? 'bg-gray-900 text-white' : 'bg-gray-200 text-gray-700 hover:bg-gray-300'}" %>
28+
<% @categories.each do |category| %>
29+
<%= link_to category, faqs_path(category: category),
30+
class: "px-4 py-2 rounded-full text-sm font-medium transition-colors #{@selected_category == category ? 'bg-gray-900 text-white' : 'bg-gray-200 text-gray-700 hover:bg-gray-300'}" %>
31+
<% end %>
32+
</div>
33+
</div>
34+
<% end %>
35+
</div>
36+
37+
<turbo-frame id="faqs_list" target="_top">
38+
<% if @faqs.any? %>
39+
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 mb-8">
40+
<% @faqs.each do |faq| %>
41+
<%= link_to faq_path(faq), class: "block bg-white p-6 rounded-lg border shadow-xs hover:shadow-sm transition-shadow duration-200" do %>
42+
<h3 class="text-lg font-semibold mb-3 text-gray-900 leading-tight">
43+
<%= faq.question %>
44+
</h3>
45+
<% if faq.category.present? %>
46+
<div class="mb-3">
47+
<span class="inline-block px-3 py-1 text-xs font-medium bg-gray-100 text-gray-600 rounded-full">
48+
<%= faq.category %>
49+
</span>
50+
</div>
51+
<% end %>
52+
<p class="text-sm text-gray-600 leading-relaxed">
53+
<%= truncate(strip_tags(faq.answer), length: 120, separator: ' ') %>
54+
</p>
55+
<div class="mt-4 flex items-center text-sm font-medium text-gray-900">
56+
Read answer
57+
<%= lucide_icon "chevron-right", class: "ml-1 w-4 h-4" %>
58+
</div>
59+
<% end %>
60+
<% end %>
61+
</div>
62+
63+
<!-- Pagination -->
64+
<% if @pagy.pages > 1 %>
65+
<div class="flex justify-center mt-12">
66+
<%== pagy_nav(@pagy) %>
67+
</div>
68+
<% end %>
69+
<% else %>
70+
<div class="py-56 mx-auto text-center max-w-96">
71+
<%= lucide_icon "search-x", class: "w-6 h-6 mx-auto text-gray-500" %>
72+
<p class="mt-4 text-sm font-medium">No FAQs found</p>
73+
<% if @query.present? %>
74+
<p class="mt-2 text-sm text-gray-500">We didn't find any FAQs matching "<%= @query %>".</p>
75+
<% elsif @selected_category.present? %>
76+
<p class="mt-2 text-sm text-gray-500">No FAQs found in the "<%= @selected_category %>" category.</p>
77+
<% else %>
78+
<p class="mt-2 text-sm text-gray-500">No FAQs available yet.</p>
79+
<% end %>
80+
</div>
81+
<% end %>
82+
</turbo-frame>
83+
</div>

app/views/faqs/show.html.erb

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
<%
2+
title "#{@faq.question} - Financial FAQs"
3+
description "#{strip_tags(@faq.answer)[0...300]}..."
4+
meta_image @faq.meta_image_url if @faq.meta_image_url.present?
5+
6+
content_for :schema_data do
7+
faq_schema = {
8+
"@context": "https://schema.org",
9+
"@type": "QAPage",
10+
"mainEntity": {
11+
"@type": "Question",
12+
"name": @faq.question,
13+
"text": @faq.question,
14+
"answerCount": 1,
15+
"dateCreated": @faq.created_at.iso8601,
16+
"acceptedAnswer": {
17+
"@type": "Answer",
18+
"text": strip_tags(@faq.answer),
19+
"dateCreated": @faq.created_at.iso8601
20+
}
21+
}
22+
}
23+
24+
concat(tag.script(faq_schema.to_json.html_safe, type: "application/ld+json"))
25+
end
26+
%>
27+
28+
<div class="max-w-6xl mt-4 mx-auto tracking-[0.02em] prose">
29+
<p class="text-sm">
30+
<span class="text-gray-500">
31+
<%= link_to "Financial FAQs", faqs_path, class: "text-gray-500 no-underline font-medium" %>
32+
<% if @faq.category.present? %>
33+
/ <%= @faq.category %>
34+
<% end %>
35+
/
36+
</span>
37+
<%= truncate(@faq.question, length: 50) %>
38+
</p>
39+
40+
<article class="flex flex-col md:flex-row gap-14">
41+
<div class="w-full md:w-2/3 lg:max-w-[680px]">
42+
<h1 class="text-5xl leading-[3.5rem] m-0 font-medium tracking-tight">
43+
<%= @faq.question %>
44+
</h1>
45+
46+
<% if @faq.category.present? %>
47+
<div class="mt-4 mb-6">
48+
<%= link_to faqs_path(category: @faq.category), class: "inline-block px-4 py-2 bg-gray-100 text-gray-700 rounded-full text-sm font-medium hover:bg-gray-200 transition-colors no-underline" do %>
49+
<%= @faq.category %>
50+
<% end %>
51+
</div>
52+
<% end %>
53+
54+
<div class="text-gray-600">
55+
<%= markdown @faq.answer %>
56+
</div>
57+
</div>
58+
59+
<div class="w-full md:w-1/3 lg:max-w-[410px]">
60+
<% if @related_faqs.any? %>
61+
<h2 class="text-xl leading-[2rem] m-0 tracking-[-0.01rem] mb-4 font-medium">Related FAQs</h2>
62+
<div class="grid gap-y-[6px] mb-8">
63+
<% @related_faqs.each do |related_faq| %>
64+
<%= link_to faq_path(related_faq), class: "no-underline bg-white flex flex-row py-2.5 px-3 border shadow-xs rounded-lg hover:bg-gray-200/50" do %>
65+
<span class="text-sm leading-relaxed"><%= truncate(related_faq.question, length: 60) %></span>
66+
<%= lucide_icon "chevron-right", class: "h-5 w-5 ml-auto text-gray-500 flex-shrink-0" %>
67+
<% end %>
68+
<% end %>
69+
</div>
70+
<% end %>
71+
72+
<h2 class="text-xl leading-[2rem] m-0 tracking-[-0.01rem] mb-4 font-medium">Explore more</h2>
73+
<div class="grid gap-y-[6px] mb-8">
74+
<%= link_to faqs_path, class: "no-underline bg-white flex flex-row py-2.5 px-3 border shadow-xs rounded-lg hover:bg-gray-200/50" do %>
75+
<span>All Financial FAQs</span>
76+
<%= lucide_icon "arrow-right", class: "h-5 w-5 ml-auto text-gray-500" %>
77+
<% end %>
78+
79+
<%= link_to terms_path, class: "no-underline bg-white flex flex-row py-2.5 px-3 border shadow-xs rounded-lg hover:bg-gray-200/50" do %>
80+
<span>Financial Terms</span>
81+
<%= lucide_icon "arrow-right", class: "h-5 w-5 ml-auto text-gray-500" %>
82+
<% end %>
83+
84+
<%= link_to articles_path, class: "no-underline bg-white flex flex-row py-2.5 px-3 border shadow-xs rounded-lg hover:bg-gray-200/50" do %>
85+
<span>Financial Articles</span>
86+
<%= lucide_icon "arrow-right", class: "h-5 w-5 ml-auto text-gray-500" %>
87+
<% end %>
88+
</div>
89+
90+
<h2 class="text-xl leading-[2rem] m-0 tracking-[-0.01rem] mb-4 font-medium">Share this FAQ</h2>
91+
<%= render "shared/share_buttons" %>
92+
</div>
93+
</article>
94+
</div>

app/views/pages/sitemap_index.xml.erb

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,14 @@
4141
<loc>https://maybefinance.com/articles/<%= article.slug %></loc>
4242
</url>
4343
<% end %>
44+
<url>
45+
<loc>https://maybefinance.com/financial-faqs</loc>
46+
</url>
47+
<% @faqs.each do |faq| %>
48+
<url>
49+
<loc>https://maybefinance.com/financial-faqs/<%= faq.slug %></loc>
50+
</url>
51+
<% end %>
4452
<url>
4553
<loc>https://maybefinance.com/tools</loc>
4654
</url>
@@ -58,4 +66,3 @@
5866
<% end %>
5967
<% end %>
6068
</urlset>
61-

0 commit comments

Comments
 (0)