|
| 1 | +# frozen_string_literal: true |
| 2 | + |
| 3 | +module ApiPublic |
| 4 | + module V1 |
| 5 | + # Returns comprehensive localized page metadata for SEO and rendering |
| 6 | + # |
| 7 | + # This endpoint provides all data needed to render a page in a specific locale, |
| 8 | + # including SEO meta tags, Open Graph, Twitter Cards, JSON-LD structured data, |
| 9 | + # navigation info, and rendered page content. |
| 10 | + # |
| 11 | + # All text fields (title, meta_description, meta_keywords) are automatically |
| 12 | + # returned in the requested locale via Mobility translations. |
| 13 | + # |
| 14 | + class LocalizedPagesController < BaseController |
| 15 | + include ApiPublic::Cacheable |
| 16 | + include UrlLocalizationHelper |
| 17 | + |
| 18 | + def show |
| 19 | + setup_locale |
| 20 | + |
| 21 | + unless website_provisioned? |
| 22 | + render json: website_not_provisioned_error, status: :not_found |
| 23 | + return |
| 24 | + end |
| 25 | + |
| 26 | + page = Pwb::Current.website.pages.find_by(slug: params[:page_slug]) |
| 27 | + |
| 28 | + unless page |
| 29 | + render json: page_not_found_error, status: :not_found |
| 30 | + return |
| 31 | + end |
| 32 | + |
| 33 | + set_short_cache(max_age: 1.hour, etag_data: [page.id, page.updated_at, I18n.locale]) |
| 34 | + return if performed? |
| 35 | + |
| 36 | + render json: build_localized_page_response(page) |
| 37 | + end |
| 38 | + |
| 39 | + private |
| 40 | + |
| 41 | + def setup_locale |
| 42 | + locale = params[:locale] || I18n.default_locale |
| 43 | + I18n.locale = locale.to_sym |
| 44 | + end |
| 45 | + |
| 46 | + def website_provisioned? |
| 47 | + Pwb::Current.website.present? && Pwb::Current.website.pages.exists? |
| 48 | + end |
| 49 | + |
| 50 | + def website_not_provisioned_error |
| 51 | + { |
| 52 | + error: "Website not provisioned", |
| 53 | + message: "The website has not been provisioned with any pages.", |
| 54 | + code: "WEBSITE_NOT_PROVISIONED" |
| 55 | + } |
| 56 | + end |
| 57 | + |
| 58 | + def page_not_found_error |
| 59 | + { |
| 60 | + error: "Page not found", |
| 61 | + code: "PAGE_NOT_FOUND" |
| 62 | + } |
| 63 | + end |
| 64 | + |
| 65 | + def build_localized_page_response(page) |
| 66 | + website = Pwb::Current.website |
| 67 | + |
| 68 | + { |
| 69 | + id: page.id, |
| 70 | + slug: page.slug, |
| 71 | + # Mobility-translated fields - automatically return current locale's value |
| 72 | + title: page.seo_title.presence || page.page_title.presence || page.slug.titleize, |
| 73 | + meta_description: page.meta_description, |
| 74 | + meta_keywords: page.meta_keywords, |
| 75 | + # Canonical URL is generated dynamically based on locale |
| 76 | + canonical_url: build_canonical_url(page), |
| 77 | + last_modified: page.updated_at.iso8601, |
| 78 | + etag: generate_page_etag(page), |
| 79 | + cache_control: "public, max-age=3600", |
| 80 | + og: build_open_graph(page, website), |
| 81 | + twitter: build_twitter_card(page, website), |
| 82 | + json_ld: build_json_ld(page, website), |
| 83 | + breadcrumbs: build_breadcrumbs(page), |
| 84 | + alternate_locales: build_alternate_locales(page), |
| 85 | + html_elements: build_html_elements(page), |
| 86 | + sort_order_top_nav: page.sort_order_top_nav, |
| 87 | + show_in_top_nav: page.show_in_top_nav, |
| 88 | + sort_order_footer: page.sort_order_footer, |
| 89 | + show_in_footer: page.show_in_footer, |
| 90 | + visible: page.visible, |
| 91 | + page_contents: build_page_contents(page) |
| 92 | + } |
| 93 | + end |
| 94 | + |
| 95 | + def generate_page_etag(page) |
| 96 | + "\"#{Digest::MD5.hexdigest("#{page.id}-#{page.updated_at}-#{I18n.locale}")}\"" |
| 97 | + end |
| 98 | + |
| 99 | + def build_canonical_url(page) |
| 100 | + host = request.host_with_port |
| 101 | + protocol = request.protocol |
| 102 | + path = build_page_path(page, I18n.locale) |
| 103 | + |
| 104 | + "#{protocol}#{host}#{path}" |
| 105 | + end |
| 106 | + |
| 107 | + def build_page_path(page, locale) |
| 108 | + default_locale = default_website_locale |
| 109 | + |
| 110 | + if locale.to_s == default_locale.to_s |
| 111 | + "/p/#{page.slug}" |
| 112 | + else |
| 113 | + "/#{locale}/p/#{page.slug}" |
| 114 | + end |
| 115 | + end |
| 116 | + |
| 117 | + def default_website_locale |
| 118 | + Pwb::Current.website&.default_client_locale&.to_s || "en" |
| 119 | + end |
| 120 | + |
| 121 | + def build_open_graph(page, website) |
| 122 | + site_name = website.company_display_name.presence || |
| 123 | + website.agency&.display_name.presence || |
| 124 | + "Property Website" |
| 125 | + |
| 126 | + og = { |
| 127 | + "og:title" => page.seo_title.presence || page.page_title.presence || page.slug.titleize, |
| 128 | + "og:description" => page.meta_description.presence || website_default_description(website), |
| 129 | + "og:type" => "website", |
| 130 | + "og:url" => build_canonical_url(page), |
| 131 | + "og:site_name" => site_name |
| 132 | + } |
| 133 | + |
| 134 | + # Add og:image if available from page or website |
| 135 | + og_image = page_og_image(page) || website_logo(website) |
| 136 | + og["og:image"] = og_image if og_image.present? |
| 137 | + |
| 138 | + og.compact |
| 139 | + end |
| 140 | + |
| 141 | + def build_twitter_card(page, website) |
| 142 | + { |
| 143 | + "twitter:card" => "summary_large_image", |
| 144 | + "twitter:title" => page.seo_title.presence || page.page_title.presence || page.slug.titleize, |
| 145 | + "twitter:description" => page.meta_description.presence || website_default_description(website), |
| 146 | + "twitter:image" => page_og_image(page) || website_logo(website) |
| 147 | + }.compact |
| 148 | + end |
| 149 | + |
| 150 | + def build_json_ld(page, website) |
| 151 | + site_name = website.company_display_name.presence || |
| 152 | + website.agency&.display_name.presence || |
| 153 | + "Property Website" |
| 154 | + |
| 155 | + json_ld = { |
| 156 | + "@context" => "https://schema.org", |
| 157 | + "@type" => "WebPage", |
| 158 | + "name" => page.seo_title.presence || page.page_title.presence || page.slug.titleize, |
| 159 | + "description" => page.meta_description, |
| 160 | + "url" => build_canonical_url(page), |
| 161 | + "inLanguage" => I18n.locale.to_s, |
| 162 | + "datePublished" => page.created_at&.to_date&.iso8601, |
| 163 | + "dateModified" => page.updated_at&.to_date&.iso8601, |
| 164 | + "publisher" => build_publisher_json_ld(site_name, website) |
| 165 | + } |
| 166 | + |
| 167 | + json_ld.compact |
| 168 | + end |
| 169 | + |
| 170 | + def build_publisher_json_ld(site_name, website) |
| 171 | + publisher = { |
| 172 | + "@type" => "Organization", |
| 173 | + "name" => site_name |
| 174 | + } |
| 175 | + |
| 176 | + logo_url = website_logo(website) |
| 177 | + if logo_url.present? |
| 178 | + publisher["logo"] = { |
| 179 | + "@type" => "ImageObject", |
| 180 | + "url" => logo_url |
| 181 | + } |
| 182 | + end |
| 183 | + |
| 184 | + publisher |
| 185 | + end |
| 186 | + |
| 187 | + def build_breadcrumbs(page) |
| 188 | + locale = I18n.locale |
| 189 | + default_locale = default_website_locale |
| 190 | + |
| 191 | + home_url = locale.to_s == default_locale ? "/" : "/#{locale}/" |
| 192 | + page_url = build_page_path(page, locale) |
| 193 | + |
| 194 | + [ |
| 195 | + { "name" => I18n.t("breadcrumbs.home", default: "Home"), "url" => home_url }, |
| 196 | + { "name" => page.page_title.presence || page.slug.titleize, "url" => page_url } |
| 197 | + ] |
| 198 | + end |
| 199 | + |
| 200 | + def build_alternate_locales(page) |
| 201 | + website = Pwb::Current.website |
| 202 | + supported_locales = website.supported_locales || [default_website_locale] |
| 203 | + current_base_locale = normalize_locale_for_mobility(I18n.locale)&.to_s |
| 204 | + |
| 205 | + supported_locales.filter_map do |locale| |
| 206 | + # Normalize for comparison to handle "es" vs "es-MX" |
| 207 | + locale_base = normalize_locale_for_mobility(locale)&.to_s |
| 208 | + next if locale_base == current_base_locale |
| 209 | + |
| 210 | + # Use the base locale for the URL path |
| 211 | + path = build_page_path(page, locale_base || locale) |
| 212 | + url = "#{request.protocol}#{request.host_with_port}#{path}" |
| 213 | + |
| 214 | + { "locale" => locale_base || locale.to_s, "url" => url } |
| 215 | + end |
| 216 | + end |
| 217 | + |
| 218 | + def build_html_elements(page) |
| 219 | + # Return localized UI element labels for the page |
| 220 | + # These can be used by frontend frameworks that need pre-translated strings |
| 221 | + elements = [] |
| 222 | + |
| 223 | + # Page title element |
| 224 | + elements << { |
| 225 | + "element_class_id" => "page_title", |
| 226 | + "element_label" => build_element_translations(:page_title, page) |
| 227 | + } |
| 228 | + |
| 229 | + # Common form elements (if page has forms) |
| 230 | + elements << { |
| 231 | + "element_class_id" => "submit_button", |
| 232 | + "element_label" => build_i18n_translations("buttons.submit", "Submit") |
| 233 | + } |
| 234 | + |
| 235 | + elements << { |
| 236 | + "element_class_id" => "back_button", |
| 237 | + "element_label" => build_i18n_translations("buttons.back", "Back") |
| 238 | + } |
| 239 | + |
| 240 | + elements |
| 241 | + end |
| 242 | + |
| 243 | + def build_element_translations(attribute, page) |
| 244 | + translations = {} |
| 245 | + supported_locales = website_supported_locales |
| 246 | + |
| 247 | + supported_locales.each do |locale| |
| 248 | + # Normalize locale for Mobility (e.g., "en-US" → "en") |
| 249 | + mobility_locale = normalize_locale_for_mobility(locale) |
| 250 | + next unless mobility_locale |
| 251 | + |
| 252 | + Mobility.with_locale(mobility_locale) do |
| 253 | + value = page.send(attribute) |
| 254 | + translations[locale.to_s] = value if value.present? |
| 255 | + end |
| 256 | + end |
| 257 | + |
| 258 | + # Fallback to slug if no translations |
| 259 | + translations[I18n.default_locale.to_s] ||= page.slug.titleize if translations.empty? |
| 260 | + |
| 261 | + translations |
| 262 | + end |
| 263 | + |
| 264 | + def build_i18n_translations(key, default) |
| 265 | + translations = {} |
| 266 | + supported_locales = website_supported_locales |
| 267 | + |
| 268 | + supported_locales.each do |locale| |
| 269 | + # Use the full locale for I18n if available, otherwise use normalized |
| 270 | + i18n_locale = I18n.available_locales.map(&:to_s).include?(locale.to_s) ? locale : normalize_locale_for_mobility(locale) |
| 271 | + next unless i18n_locale |
| 272 | + |
| 273 | + translations[locale.to_s] = I18n.t(key, locale: i18n_locale, default: default) |
| 274 | + end |
| 275 | + |
| 276 | + translations |
| 277 | + end |
| 278 | + |
| 279 | + # Get website's supported locales |
| 280 | + def website_supported_locales |
| 281 | + Pwb::Current.website&.supported_locales || I18n.available_locales.map(&:to_s) |
| 282 | + end |
| 283 | + |
| 284 | + # Normalize regional locale codes to base codes for Mobility |
| 285 | + # e.g., "en-US" → :en, "es-MX" → :es |
| 286 | + def normalize_locale_for_mobility(locale) |
| 287 | + return nil if locale.blank? |
| 288 | + |
| 289 | + base_locale = locale.to_s.split('-').first.to_sym |
| 290 | + # Only return if it's a valid Mobility/I18n locale |
| 291 | + I18n.available_locales.include?(base_locale) ? base_locale : nil |
| 292 | + end |
| 293 | + |
| 294 | + def build_page_contents(page) |
| 295 | + return [] unless page.respond_to?(:ordered_visible_page_contents) |
| 296 | + |
| 297 | + page.ordered_visible_page_contents.map do |page_content| |
| 298 | + raw_html = page_content.is_rails_part ? nil : page_content.content&.raw |
| 299 | + localized_html = raw_html.present? ? localize_html_urls(raw_html) : nil |
| 300 | + |
| 301 | + { |
| 302 | + "page_part_key" => page_content.page_part_key, |
| 303 | + "sort_order" => page_content.sort_order, |
| 304 | + "visible" => page_content.visible_on_page, |
| 305 | + "is_rails_part" => page_content.is_rails_part || false, |
| 306 | + "rendered_html" => localized_html, |
| 307 | + "label" => page_content.label |
| 308 | + } |
| 309 | + end |
| 310 | + end |
| 311 | + |
| 312 | + # Helper methods for website data |
| 313 | + |
| 314 | + def website_default_description(website) |
| 315 | + website.default_meta_description.presence || "Find your dream property" |
| 316 | + end |
| 317 | + |
| 318 | + def website_logo(website) |
| 319 | + return nil unless website.respond_to?(:logo_url) |
| 320 | + website.logo_url.presence |
| 321 | + end |
| 322 | + |
| 323 | + def page_og_image(page) |
| 324 | + # Check if page has a custom OG image in its details or translations |
| 325 | + return nil unless page.respond_to?(:details) && page.details.is_a?(Hash) |
| 326 | + page.details["og_image_url"].presence |
| 327 | + end |
| 328 | + end |
| 329 | + end |
| 330 | +end |
0 commit comments