Skip to content

Commit 264c543

Browse files
etewiahclaude
andcommitted
Add Advanced SEO Tools: audit dashboard, alt-text management, verification
Implements comprehensive SEO tooling for site administrators: - SEO Audit Dashboard: New /site_admin/seo_audit page with overall SEO score (A-F grade), statistics for properties/pages/images, and quick-fix links - Image Alt-Text Management: Enhanced photo editing UI with status indicators, modal editing, AJAX updates, and automatic alt-text generation helper - Property SEO: Added Google Search Preview with live updates to property text editing forms for both sale and rental listings - Page SEO: Enhanced page settings with dedicated SEO card, search preview, character counters, and SEO tips - Search Engine Verification: Enabled Google Search Console and Bing Webmaster verification meta tags in all theme layouts with admin UI 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 3bd95b5 commit 264c543

File tree

17 files changed

+1214
-237
lines changed

17 files changed

+1214
-237
lines changed

app/controllers/site_admin/pages_controller.rb

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,11 @@ def set_page
7070
end
7171

7272
def page_params
73-
params.require(:pwb_page).permit(:slug, :visible, :show_in_top_nav, :show_in_footer, :sort_order_top_nav, :sort_order_footer)
73+
params.require(:pwb_page).permit(
74+
:slug, :visible, :show_in_top_nav, :show_in_footer,
75+
:sort_order_top_nav, :sort_order_footer,
76+
:seo_title, :meta_description
77+
)
7478
end
7579
end
7680
end

app/controllers/site_admin/props_controller.rb

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ class PropsController < SiteAdminController
99
before_action :set_realty_asset, only: [
1010
:edit_general, :edit_text, :edit_sale_rental, :edit_location,
1111
:edit_labels, :edit_photos, :upload_photos, :remove_photo,
12-
:reorder_photos, :update
12+
:reorder_photos, :update_photo_alt, :update
1313
]
1414

1515
def index
@@ -163,6 +163,21 @@ def reorder_photos
163163
end
164164
end
165165

166+
def update_photo_alt
167+
photo = @prop.prop_photos.find(params[:photo_id])
168+
if photo.update(description: params[:description])
169+
respond_to do |format|
170+
format.html { redirect_to edit_photos_site_admin_prop_path(@prop), notice: 'Alt text updated successfully.' }
171+
format.json { render json: { success: true, description: photo.description } }
172+
end
173+
else
174+
respond_to do |format|
175+
format.html { redirect_to edit_photos_site_admin_prop_path(@prop), alert: 'Failed to update alt text.' }
176+
format.json { render json: { success: false, errors: photo.errors.full_messages }, status: :unprocessable_entity }
177+
end
178+
end
179+
end
180+
166181
def update
167182
ActiveRecord::Base.transaction do
168183
# Update the realty asset (physical property data)
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
# frozen_string_literal: true
2+
3+
module SiteAdmin
4+
# SEO Audit Dashboard Controller
5+
# Provides an overview of SEO health across properties and pages
6+
#
7+
# Features:
8+
# - Summary statistics for SEO completeness
9+
# - Lists of items missing SEO data
10+
# - Quick links to fix issues
11+
class SeoAuditController < SiteAdminController
12+
def index
13+
# Get all properties for this website
14+
@properties = Pwb::ListedProperty
15+
.where(website_id: current_website&.id)
16+
.order(created_at: :desc)
17+
18+
# Get all pages for this website
19+
@pages = Pwb::Page.where(website_id: current_website&.id).order(:slug)
20+
21+
# Calculate statistics
22+
calculate_property_stats
23+
calculate_page_stats
24+
calculate_image_stats
25+
26+
# Overall SEO score
27+
calculate_overall_score
28+
end
29+
30+
private
31+
32+
def calculate_property_stats
33+
total = @properties.count
34+
35+
# Count properties with SEO fields
36+
# We need to check through listings (sale_listing or rental_listing)
37+
properties_with_seo_title = 0
38+
properties_with_meta_desc = 0
39+
40+
@properties.each do |prop|
41+
has_seo_title = prop.respond_to?(:seo_title) && prop.seo_title.present?
42+
has_meta_desc = prop.respond_to?(:meta_description) && prop.meta_description.present?
43+
44+
properties_with_seo_title += 1 if has_seo_title || prop.title.present?
45+
properties_with_meta_desc += 1 if has_meta_desc || prop.description.present?
46+
end
47+
48+
@property_stats = {
49+
total: total,
50+
with_title: properties_with_seo_title,
51+
with_description: properties_with_meta_desc,
52+
missing_title: total - properties_with_seo_title,
53+
missing_description: total - properties_with_meta_desc,
54+
title_percentage: total.positive? ? (properties_with_seo_title * 100.0 / total).round : 0,
55+
description_percentage: total.positive? ? (properties_with_meta_desc * 100.0 / total).round : 0
56+
}
57+
58+
# Get list of properties missing SEO (limited for display)
59+
@properties_missing_seo = @properties.select do |p|
60+
title_missing = p.title.blank? && (!p.respond_to?(:seo_title) || p.seo_title.blank?)
61+
desc_missing = p.description.blank? && (!p.respond_to?(:meta_description) || p.meta_description.blank?)
62+
title_missing || desc_missing
63+
end.first(10)
64+
end
65+
66+
def calculate_page_stats
67+
total = @pages.count
68+
with_seo_title = @pages.count { |p| p.seo_title.present? || p.page_title.present? }
69+
with_meta_desc = @pages.count { |p| p.meta_description.present? }
70+
71+
@page_stats = {
72+
total: total,
73+
with_title: with_seo_title,
74+
with_description: with_meta_desc,
75+
missing_title: total - with_seo_title,
76+
missing_description: total - with_meta_desc,
77+
title_percentage: total.positive? ? (with_seo_title * 100.0 / total).round : 0,
78+
description_percentage: total.positive? ? (with_meta_desc * 100.0 / total).round : 0
79+
}
80+
81+
# Pages missing SEO
82+
@pages_missing_seo = @pages.select do |p|
83+
(p.seo_title.blank? && p.page_title.blank?) || p.meta_description.blank?
84+
end.first(10)
85+
end
86+
87+
def calculate_image_stats
88+
# Get all prop photos for this website's properties
89+
property_ids = @properties.pluck(:id)
90+
91+
# Count photos via RealtyAsset association
92+
total_photos = Pwb::PropPhoto
93+
.joins("INNER JOIN pwb_realty_assets ON pwb_prop_photos.realty_asset_id = pwb_realty_assets.id")
94+
.where(pwb_realty_assets: { website_id: current_website&.id })
95+
.count
96+
97+
photos_with_alt = Pwb::PropPhoto
98+
.joins("INNER JOIN pwb_realty_assets ON pwb_prop_photos.realty_asset_id = pwb_realty_assets.id")
99+
.where(pwb_realty_assets: { website_id: current_website&.id })
100+
.where.not(description: [nil, ''])
101+
.count
102+
103+
@image_stats = {
104+
total: total_photos,
105+
with_alt: photos_with_alt,
106+
missing_alt: total_photos - photos_with_alt,
107+
alt_percentage: total_photos.positive? ? (photos_with_alt * 100.0 / total_photos).round : 0
108+
}
109+
110+
# Properties with photos missing alt text (limited)
111+
@properties_with_missing_alt = Pwb::RealtyAsset
112+
.where(website_id: current_website&.id)
113+
.joins(:prop_photos)
114+
.where(pwb_prop_photos: { description: [nil, ''] })
115+
.distinct
116+
.limit(10)
117+
end
118+
119+
def calculate_overall_score
120+
# Weight: Properties 40%, Pages 30%, Images 30%
121+
property_score = (@property_stats[:title_percentage] + @property_stats[:description_percentage]) / 2.0
122+
page_score = (@page_stats[:title_percentage] + @page_stats[:description_percentage]) / 2.0
123+
image_score = @image_stats[:alt_percentage]
124+
125+
@overall_score = (property_score * 0.4 + page_score * 0.3 + image_score * 0.3).round
126+
127+
@score_grade = case @overall_score
128+
when 90..100 then { letter: 'A', color: 'green', message: 'Excellent SEO health!' }
129+
when 75..89 then { letter: 'B', color: 'blue', message: 'Good, but room for improvement' }
130+
when 60..74 then { letter: 'C', color: 'yellow', message: 'Needs attention' }
131+
when 40..59 then { letter: 'D', color: 'orange', message: 'Significant improvements needed' }
132+
else { letter: 'F', color: 'red', message: 'Critical SEO issues' }
133+
end
134+
end
135+
end
136+
end

app/helpers/pwb/images_helper.rb

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,36 @@ module ImagesHelper
66
# Set to "lazy" for below-the-fold images, "eager" for critical images
77
DEFAULT_LOADING = "lazy"
88

9+
# Get the alt text for a photo, falling back to description or generic text
10+
# @param photo [Object] A photo model (PropPhoto, ContentPhoto, WebsitePhoto)
11+
# @param fallback [String] Fallback text if no description available
12+
# @return [String] Alt text for the image
13+
def photo_alt_text(photo, fallback: "Image")
14+
return fallback unless photo
15+
16+
# Use description if available (stored alt-text)
17+
if photo.respond_to?(:description) && photo.description.present?
18+
return photo.description
19+
end
20+
21+
# For prop photos, try to get a descriptive fallback from the property
22+
if photo.respond_to?(:realty_asset) && photo.realty_asset.present?
23+
asset = photo.realty_asset
24+
# Build a descriptive alt from available data
25+
parts = []
26+
parts << asset.prop_type_key&.humanize if asset.respond_to?(:prop_type_key) && asset.prop_type_key.present?
27+
parts << "in #{asset.city}" if asset.respond_to?(:city) && asset.city.present?
28+
return "#{parts.join(' ')} - property photo" if parts.any?
29+
end
30+
31+
# For content photos, use content title
32+
if photo.respond_to?(:content) && photo.content&.respond_to?(:title) && photo.content.title.present?
33+
return photo.content.title
34+
end
35+
36+
fallback
37+
end
38+
939
# Generate background-image CSS style for a photo
1040
# @param photo [Object] A photo model (PropPhoto, ContentPhoto, WebsitePhoto)
1141
# @param options [Hash] Options including :gradient for overlay
@@ -31,6 +61,7 @@ def bg_image(photo, options = {})
3161
# - :lazy [Boolean] Enable lazy loading (default: true)
3262
# - :eager [Boolean] Disable lazy loading for above-the-fold images
3363
# - :fetchpriority [String] Set fetch priority ("high", "low", "auto")
64+
# - :alt [String] Alt text (defaults to photo description if not provided)
3465
# @return [String, nil] Image tag or nil if no image
3566
def opt_image_tag(photo, options = {})
3667
return nil unless photo
@@ -42,6 +73,9 @@ def opt_image_tag(photo, options = {})
4273
_quality = options.delete(:quality) # Reserved for future CDN usage
4374
_crop = options.delete(:crop) # Reserved for future CDN usage
4475

76+
# Set alt text from photo description if not explicitly provided
77+
options[:alt] ||= photo_alt_text(photo, fallback: "Property photo")
78+
4579
# Handle lazy loading - default to lazy unless eager is specified
4680
eager = options.delete(:eager)
4781
lazy = options.delete(:lazy)
@@ -233,10 +267,14 @@ def generate_responsive_srcset(photo, format: :jpeg)
233267
# - :lazy [Boolean] Enable lazy loading (default: true)
234268
# - :eager [Boolean] Disable lazy loading for above-the-fold images
235269
# - :fetchpriority [String] Set fetch priority ("high", "low", "auto")
270+
# - :alt [String] Alt text (defaults to photo description if not provided)
236271
# @return [String, nil] Image tag or nil if no image
237272
def photo_image_tag(photo, variant_options: nil, **html_options)
238273
return nil unless photo
239274

275+
# Set alt text from photo description if not explicitly provided
276+
html_options[:alt] ||= photo_alt_text(photo, fallback: "Property photo")
277+
240278
# Handle lazy loading - default to lazy unless eager is specified
241279
eager = html_options.delete(:eager)
242280
lazy = html_options.delete(:lazy)

app/helpers/seo_helper.rb

Lines changed: 19 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -84,41 +84,24 @@ def favicon_tags
8484
safe_join(tags, "\n")
8585
end
8686

87-
# TODO: Search Engine Verification - DISABLED FOR PERFORMANCE
88-
# ============================================================
89-
# This feature is implemented but disabled until proper testing.
90-
# The admin UI section is also hidden.
91-
#
92-
# To re-enable:
93-
# 1. Uncomment this method
94-
# 2. Uncomment the call in seo_meta_tags below
95-
# 3. Uncomment <%= verification_meta_tags %> in theme layouts
96-
# 4. Uncomment the admin UI in _seo_tab.html.erb
97-
# 5. Run specs: bundle exec rspec spec/helpers/seo_helper_spec.rb
98-
#
99-
# See: docs/TODO_SEARCH_ENGINE_VERIFICATION.md
100-
#
101-
# def verification_meta_tags
102-
# return nil unless current_website.present?
103-
#
104-
# social_media = current_website.social_media || {}
105-
# tags = []
106-
#
107-
# # Google Search Console verification
108-
# google_verification = social_media['google_site_verification'].presence
109-
# tags << tag.meta(name: 'google-site-verification', content: google_verification) if google_verification
110-
#
111-
# # Bing Webmaster Tools verification
112-
# bing_verification = social_media['bing_site_verification'].presence
113-
# tags << tag.meta(name: 'msvalidate.01', content: bing_verification) if bing_verification
114-
#
115-
# return nil if tags.empty?
116-
# safe_join(tags, "\n")
117-
# end
118-
#
119-
# Stub method to prevent errors while feature is disabled
87+
# Search Engine Verification Meta Tags
88+
# Generates meta tags for Google Search Console and Bing Webmaster Tools verification
12089
def verification_meta_tags
121-
nil
90+
return nil unless current_website.present?
91+
92+
social_media = current_website.social_media || {}
93+
tags = []
94+
95+
# Google Search Console verification
96+
google_verification = social_media['google_site_verification'].presence
97+
tags << tag.meta(name: 'google-site-verification', content: google_verification) if google_verification
98+
99+
# Bing Webmaster Tools verification
100+
bing_verification = social_media['bing_site_verification'].presence
101+
tags << tag.meta(name: 'msvalidate.01', content: bing_verification) if bing_verification
102+
103+
return nil if tags.empty?
104+
safe_join(tags, "\n")
122105
end
123106

124107
# Generate all meta tags
@@ -128,9 +111,8 @@ def seo_meta_tags
128111
# Favicon tags
129112
tags << favicon_tags
130113

131-
# TODO: Uncomment when re-enabling search engine verification
132-
# See: docs/TODO_SEARCH_ENGINE_VERIFICATION.md
133-
# tags << verification_meta_tags
114+
# Search engine verification meta tags (Google, Bing)
115+
tags << verification_meta_tags
134116

135117
# Basic meta tags
136118
tags << tag.meta(name: 'description', content: seo_description) if seo_description.present?

app/themes/barcelona/views/layouts/pwb/application.html.erb

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,7 @@
66
<meta name="robots" content="index, follow">
77
<title><%= yield(:page_title) %></title>
88
<%= favicon_tags %>
9-
<%# TODO: Uncomment when re-enabling search engine verification %>
10-
<%# See: docs/TODO_SEARCH_ENGINE_VERIFICATION.md %>
11-
<%# verification_meta_tags %>
9+
<%= verification_meta_tags %>
1210
<%= yield(:page_head) %>
1311

1412
<%# Preload LCP image for faster Largest Contentful Paint %>

app/themes/bologna/views/layouts/pwb/application.html.erb

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,7 @@
66
<meta name="robots" content="index, follow">
77
<title><%= yield(:page_title) %></title>
88
<%= favicon_tags %>
9-
<%# TODO: Uncomment when re-enabling search engine verification %>
10-
<%# See: docs/TODO_SEARCH_ENGINE_VERIFICATION.md %>
11-
<%# verification_meta_tags %>
9+
<%= verification_meta_tags %>
1210
<%= yield(:page_head) %>
1311

1412
<%# Preload LCP image for faster Largest Contentful Paint %>

app/themes/brisbane/views/layouts/pwb/application.html.erb

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,7 @@
66
<meta name="robots" content="index, follow">
77
<title><%= yield(:page_title) %></title>
88
<%= favicon_tags %>
9-
<%# TODO: Uncomment when re-enabling search engine verification %>
10-
<%# See: docs/TODO_SEARCH_ENGINE_VERIFICATION.md %>
11-
<%# verification_meta_tags %>
9+
<%= verification_meta_tags %>
1210
<%= yield(:page_head) %>
1311

1412
<%# Preload LCP image for faster Largest Contentful Paint %>

0 commit comments

Comments
 (0)