# frozen_string_literal: true module CommunityLanding class PageBuilder include Helpers SECTION_MAP = { "hero" => :render_hero, "stats" => :render_stats, "about" => :render_about, "participation" => :render_participation, "topics" => :render_topics, "groups" => :render_groups, "app_cta" => :render_app_cta, }.freeze def initialize(data:, css:, js:) @data = data @css = css @js = js @s = SiteSetting @styles = StyleBuilder.new(@s) end def build html = +"" html << render_head html << "\n" if @s.dynamic_background_enabled html << "
\n" end html << render_navbar # Reorderable sections order = (@s.section_order.presence rescue nil) || "hero|stats|about|participation|topics|groups|app_cta" order.split("|").map(&:strip).each do |section_id| method_name = SECTION_MAP[section_id] html << send(method_name) if method_name end html << render_footer_desc html << render_footer html << render_video_modal html << "\n" html << "\n" html end private # ── ── def render_head site_name = @s.title anim_class = @s.scroll_animation rescue "fade_up" anim_class = "none" if anim_class.blank? og_logo = logo_dark_url || logo_light_url base_url = Discourse.base_url # SEO overrides meta_desc = (@s.meta_description.presence rescue nil) || @s.hero_subtitle og_image = (@s.og_image_url.presence rescue nil) || og_logo favicon = (@s.favicon_url.presence rescue nil) html = +"" html << "\n\n\n" html << "\n" body_font = (@s.google_font_name.presence rescue nil) || "Outfit" title_font = (@s.title_font_name.presence rescue nil) font_families = [body_font] font_families << title_font if title_font && title_font != body_font font_params = font_families.map { |f| "family=#{f.gsub(' ', '+')}:wght@400;500;600;700;800;900" }.join("&") html << "\n" html << "\n" html << "\n" if (@s.fontawesome_enabled rescue false) html << "\n" end html << "\n" html << "\n" # Favicon if favicon ftype = case favicon.to_s.split(".").last.downcase when "svg" then "image/svg+xml" when "png" then "image/png" else "image/x-icon" end html << "\n" end # Title & meta html << "#{e(@s.hero_title)} | #{e(site_name)}\n" html << "\n" # Open Graph html << "\n" html << "\n" html << "\n" html << "\n" html << "\n" html << "\n" if og_image # Twitter card html << "\n" html << "\n" html << "\n" html << "\n" if og_image html << "\n" # JSON-LD structured data if (@s.json_ld_enabled rescue true) html << "\n" end html << "\n" html << @styles.color_overrides html << @styles.section_backgrounds # Font overrides font_css = +"" font_css << ":root { --cl-font-body: \"#{body_font}\", sans-serif;" if title_font font_css << " --cl-font-title: \"#{title_font}\", serif;" else font_css << " --cl-font-title: var(--cl-font-body);" end font_css << " }\n" html << "\n" # Custom CSS (injected last so it can override everything) custom_css = @s.custom_css.presence rescue nil if custom_css html << "\n" end html << "\n" html end # ── 1. NAVBAR ── def render_navbar site_name = @s.title signin_label = @s.navbar_signin_label.presence || "Sign In" join_label = @s.navbar_join_label.presence || "Get Started" navbar_bg = hex(@s.navbar_bg_color) rescue nil navbar_border = @s.navbar_border_style rescue "none" nav_style_parts = [] nav_style_parts << "--cl-nav-bg: #{navbar_bg}" if navbar_bg nav_style_parts << "--cl-nav-border: 1px #{navbar_border} var(--cl-border)" if navbar_border && navbar_border != "none" nav_style = nav_style_parts.any? ? " style=\"#{nav_style_parts.join('; ')}\"" : "" html = +"" html << "\n" html end # ── 2. HERO ── def render_hero hero_card = @s.hero_card_enabled rescue true hero_img_first = @s.hero_image_first rescue false hero_bg_img = (@s.hero_background_image_url.presence rescue nil) hero_border = @s.hero_border_style rescue "none" hero_min_h = @s.hero_min_height rescue 0 site_name = @s.title html = +"" # Build hero section style: bg image on the section itself + border/min-height hero_style_parts = [] hero_style_parts << "background-image: url('#{hero_bg_img}');" if hero_bg_img hero_style_parts << "border-bottom: 1px #{hero_border} var(--cl-border);" if hero_border.present? && hero_border != "none" hero_style_parts << "min-height: #{hero_min_h}px;" if hero_min_h.to_i > 0 hero_attr = hero_style_parts.any? ? " style=\"#{hero_style_parts.join(' ')}\"" : "" hero_classes = +"cl-hero" hero_classes << " cl-hero--card" if hero_card hero_classes << " cl-hero--image-first" if hero_img_first html << "
\n" html << "
\n
\n" # Accent word: 0 = last word (default), N = Nth word (1-indexed) title_words = @s.hero_title.to_s.split(" ") accent_idx = (@s.hero_accent_word rescue 0).to_i if title_words.length > 1 # Convert to 0-based index; 0 means last word target = accent_idx > 0 ? [accent_idx - 1, title_words.length - 1].min : title_words.length - 1 before = title_words[0...target] accent = title_words[target] after = title_words[(target + 1)..-1] || [] parts = +"" parts << "#{e(before.join(' '))} " if before.any? parts << "#{e(accent)}" parts << " #{e(after.join(' '))}" if after.any? html << "

#{parts}

\n" else html << "

#{e(@s.hero_title)}

\n" end html << "

#{e(@s.hero_subtitle)}

\n" primary_on = @s.hero_primary_button_enabled rescue true secondary_on = @s.hero_secondary_button_enabled rescue true primary_label = @s.hero_primary_button_label.presence || "View Latest Topics" primary_url = @s.hero_primary_button_url.presence || "/latest" secondary_label = @s.hero_secondary_button_label.presence || "Explore Our Spaces" secondary_url = @s.hero_secondary_button_url.presence || login_url if primary_on || secondary_on html << "
\n" html << "#{button_with_icon(primary_label)}\n" if primary_on html << "#{button_with_icon(secondary_label)}\n" if secondary_on html << "
\n" end # Hero creators (top 3 with gold/silver/bronze ranks) contributors = @data[:contributors] if (@s.contributors_enabled rescue false) && contributors&.any? top3 = contributors.first(3) rank_colors = ["#FFD700", "#C0C0C0", "#CD7F32"] creators_title = @s.contributors_title.presence || "Top Creators" show_title = @s.contributors_title_enabled rescue true count_label = @s.contributors_count_label.presence || "" show_count_label = @s.contributors_count_label_enabled rescue true alignment = @s.contributors_alignment rescue "center" pill_max_w = @s.contributors_pill_max_width rescue 340 align_class = alignment == "left" ? " cl-hero__creators--left" : "" html << "
\n" html << "

#{e(creators_title)}

\n" if show_title top3.each_with_index do |user, idx| avatar_url = user.avatar_template.to_s.gsub("{size}", "120") activity_count = user.attributes["post_count"].to_i rescue 0 rank_color = rank_colors[idx] count_prefix = show_count_label && count_label.present? ? "#{e(count_label)} " : "" pill_style_parts = ["--rank-color: #{rank_color}"] pill_style_parts << "max-width: #{pill_max_w}px" if pill_max_w.to_i != 340 html << "\n" html << "Ranked ##{idx + 1}\n" html << "\"#{e(user.username)}\"\n" html << "
\n" html << "@#{e(user.username)}\n" html << "#{count_prefix}#{activity_count}\n" html << "
\n" html << "
\n" end html << "
\n" end html << "
\n" hero_multi = (@s.hero_multiple_images_enabled rescue false) if hero_multi hero_image_urls_raw = (@s.hero_image_urls.presence rescue nil) else hero_image_urls_raw = (@s.hero_image_url.presence rescue nil) end if (@s.hero_video_url_enabled rescue false) hero_video = (@s.hero_video_url.presence rescue nil) else hero_video = (@s.hero_video_upload.presence rescue nil) end blur_attr = (@s.hero_video_blur_on_hover rescue true) ? " data-blur-hover=\"true\"" : "" has_images = false if hero_image_urls_raw if hero_multi urls = hero_image_urls_raw.split(/[|\n\r]+/).map(&:strip).reject(&:empty?).first(5) else urls = [hero_image_urls_raw.strip] end if urls.any? has_images = true img_max_h = @s.hero_image_max_height rescue 500 html << "
\n" html << "\"#{e(site_name)}\"\n" if hero_video html << "\n" end html << "
\n" end end if hero_video && !has_images html << "
\n" html << "\n" html << "
\n" end html << "
\n" html end # ── 3. STATS ── def render_stats return "" unless (@s.stats_enabled rescue true) stats = @data[:stats] stats_title = @s.stats_title.presence || "Premium Stats" show_title = @s.stats_title_enabled rescue true border = @s.stats_border_style rescue "none" min_h = @s.stats_min_height rescue 0 icon_shape = @s.stat_icon_shape rescue "circle" card_style = @s.stat_card_style rescue "rectangle" round_nums = @s.stat_round_numbers rescue false show_labels = @s.stat_labels_enabled rescue true html = +"" html << "
\n" html << "

#{button_with_icon(stats_title)}

\n" if show_title html << "
\n" html << stat_card(Icons::STAT_MEMBERS_SVG, stats[:members], @s.stat_members_label, icon_shape, card_style, round_nums, show_labels) html << stat_card(Icons::STAT_TOPICS_SVG, stats[:topics], @s.stat_topics_label, icon_shape, card_style, round_nums, show_labels) html << stat_card(Icons::STAT_POSTS_SVG, stats[:posts], @s.stat_posts_label, icon_shape, card_style, round_nums, show_labels) html << stat_card(Icons::STAT_LIKES_SVG, stats[:likes], @s.stat_likes_label, icon_shape, card_style, round_nums, show_labels) html << stat_card(Icons::STAT_CHATS_SVG, stats[:chats], @s.stat_chats_label, icon_shape, card_style, round_nums, show_labels) html << "
\n
\n" html end # ── 4. ABOUT — split layout: image left on gradient, text right ── def render_about return "" unless @s.about_enabled about_body = @s.about_body.presence || "" about_image = @s.about_image_url.presence about_role = @s.about_role.presence || @s.title about_heading_on = @s.about_heading_enabled rescue true about_heading = @s.about_heading.presence || "About Community" about_bg_img = @s.about_background_image_url.presence border = @s.about_border_style rescue "none" min_h = @s.about_min_height rescue 0 html = +"" html << "
\n" html << "
\n" # Left side — image on gradient background html << "
\n" if about_image html << "\"#{e(@s.about_title)}\"\n" end html << "
\n" # Right side — text content html << "
\n" html << "

#{button_with_icon(about_heading)}

\n" if about_heading_on html << Icons::QUOTE_SVG html << "
#{about_body}
\n" if about_body.present? html << "
\n" html << "
\n" html << "#{e(@s.about_title)}\n" html << "#{e(about_role)}\n" html << "
\n" html << "
\n" html << "
\n
\n" html end # ── 5b. PARTICIPATION ── def render_participation return "" unless (@s.participation_enabled rescue true) contributors = @data[:contributors] hero_contributors_on = (@s.contributors_enabled rescue false) if hero_contributors_on # Hero shows top 3, participation shows 4–10 return "" unless contributors&.length.to_i > 3 candidates = contributors[3..9] || [] else # Hero contributors disabled, participation shows 1–10 return "" unless contributors&.any? candidates = contributors[0..9] || [] end bio_max = (@s.participation_bio_max_length rescue 150).to_i users_with_bio = candidates.select { |u| u.user_profile&.bio_excerpt.present? rescue false } return "" if users_with_bio.empty? show_title = @s.participation_title_enabled rescue true title_text = @s.participation_title.presence || "Participation" border = @s.participation_border_style rescue "none" min_h = @s.participation_min_height rescue 0 topics_label = @s.participation_topics_label.presence || "Topics" posts_label = @s.participation_posts_label.presence || "Posts" likes_label = @s.participation_likes_label.presence || "Likes" html = +"" html << "
\n" html << "

#{button_with_icon(title_text)}

\n" if show_title stagger_class = @s.staggered_reveal_enabled ? " cl-stagger" : "" html << "
\n" users_with_bio.each do |user| avatar_url = user.avatar_template.to_s.gsub("{size}", "120") bio_raw = user.user_profile.bio_excerpt.to_s bio_text = bio_raw.length > bio_max ? "#{bio_raw[0...bio_max]}..." : bio_raw join_date = user.created_at.strftime("Joined %b %Y") rescue "Member" location = (user.user_profile&.location.presence rescue nil) meta_line = location ? "#{join_date} · #{e(location)}" : join_date topic_count = (user.user_stat&.topic_count.to_i rescue 0) post_count = (user.user_stat&.post_count.to_i rescue 0) likes_received = (user.user_stat&.likes_received.to_i rescue 0) html << "
\n" html << "
\n" html << "
#{Icons::QUOTE_SVG}
\n" html << "

#{e(bio_text)}

\n" html << "
\n" html << "
\n" html << participation_stat(topic_count, topics_label, Icons::PART_TOPICS_SVG) html << participation_stat(post_count, posts_label, Icons::PART_POSTS_SVG) html << participation_stat(likes_received, likes_label, Icons::PART_LIKES_SVG) html << "
\n" html << "
\n" html << "\"#{e(user.username)}\"\n" html << "
\n" html << "@#{e(user.username)}\n" html << "#{e(meta_line)}\n" html << "
\n" html << "
\n" html << "
\n" end html << "
\n
\n" html end # ── 5. TRENDING DISCUSSIONS ── def render_topics topics = @data[:topics] return "" unless @s.topics_enabled && topics&.any? border = @s.topics_border_style rescue "none" min_h = @s.topics_min_height rescue 0 show_title = @s.topics_title_enabled rescue true html = +"" html << "
\n" html << "

#{button_with_icon(@s.topics_title)}

\n" if show_title stagger_class = @s.staggered_reveal_enabled ? " cl-stagger" : "" html << "\n
\n" html end # ── 7. COMMUNITY SPACES + FAQ ── def render_groups groups = @data[:groups] faq_on = (@s.faq_enabled rescue false) has_groups = @s.groups_enabled && groups&.any? return "" unless has_groups || faq_on show_title = @s.groups_title_enabled rescue true show_desc = @s.groups_show_description rescue true desc_max = (@s.groups_description_max_length rescue 100).to_i min_h = @s.splits_min_height rescue 0 html = +"" groups_bg_img = (@s.splits_background_image_url.presence rescue nil) section_style_parts = [] section_style_parts << "background: url('#{groups_bg_img}') center/cover no-repeat;" if groups_bg_img section_style_parts << "min-height: #{min_h}px;" if min_h.to_i > 0 section_attr = section_style_parts.any? ? " style=\"#{section_style_parts.join(' ')}\"" : "" html << "
\n" if has_groups && faq_on # ── Split layout: both titles at same level ── html << "
\n" # Left column: Groups html << "
\n" html << "

#{button_with_icon(@s.groups_title)}

\n" if show_title html << render_groups_grid(groups, show_desc, desc_max) html << "
\n" # Right column: FAQ html << "
\n" html << render_faq html << "
\n" html << "
\n" elsif has_groups # ── Groups only (full width) ── html << "

#{button_with_icon(@s.groups_title)}

\n" if show_title html << "
\n" html << render_groups_grid(groups, show_desc, desc_max) html << "
\n" else # ── FAQ only (full width) ── html << render_faq end html << "
\n" html end def render_groups_grid(groups, show_desc, desc_max) stagger_class = @s.staggered_reveal_enabled ? " cl-stagger" : "" html = +"" html << "
\n" groups.each do |group| display_name = group.full_name.presence || group.name.tr("_-", " ").gsub(/\b\w/, &:upcase) hue = group.name.bytes.sum % 360 sat = 55 + (group.name.bytes.first.to_i % 15) light = 45 + (group.name.bytes.last.to_i % 12) icon_color = "hsl(#{hue}, #{sat}%, #{light}%)" desc_text = nil if show_desc raw_bio = (group.bio_raw.to_s.strip rescue "") if raw_bio.present? plain = raw_bio.gsub(/<[^>]*>/, "").strip desc_text = plain.length > desc_max ? "#{plain[0...desc_max]}..." : plain end end html << "\n" html << "
" if group.flair_url.present? html << "\"\"" else html << "#{group.name[0].upcase}" end html << "
\n" html << "
\n" html << "#{e(display_name)}\n" html << "#{group.user_count} members\n" html << "

#{e(desc_text)}

\n" if desc_text html << "
\n" html << "
\n" end html << "
\n" html end def render_faq faq_title_on = @s.faq_title_enabled rescue true faq_title = @s.faq_title.presence || "Frequently Asked Questions" faq_raw = @s.faq_items.presence rescue nil html = +"" html << "

#{button_with_icon(faq_title)}

\n" if faq_title_on html << "
\n" if faq_raw begin items = JSON.parse(faq_raw) items.each do |item| q = item["q"].to_s a = item["a"].to_s next if q.blank? html << "
\n" html << "#{e(q)}\n" html << "
#{a}
\n" html << "
\n" end rescue JSON::ParserError # Invalid JSON — silently skip end end html << "
\n" html end # ── 8. APP CTA ── def render_app_cta return "" unless @s.show_app_ctas && (@s.ios_app_url.present? || @s.android_app_url.present?) badge_h = @s.app_badge_height rescue 45 badge_style = @s.app_badge_style rescue "rounded" app_image = @s.app_cta_image_url.presence ios_custom = @s.ios_app_badge_image_url.presence rescue nil android_custom = @s.android_app_badge_image_url.presence rescue nil border = @s.app_cta_border_style rescue "none" min_h = @s.app_cta_min_height rescue 0 html = +"" html << "
\n" html << "
\n
\n" html << "

#{button_with_icon(@s.app_cta_headline)}

\n" html << "

#{e(@s.app_cta_subtext)}

\n" if @s.app_cta_subtext.present? html << "
\n" html << app_badge(:ios, @s.ios_app_url, ios_custom, badge_h, badge_style) if @s.ios_app_url.present? html << app_badge(:android, @s.android_app_url, android_custom, badge_h, badge_style) if @s.android_app_url.present? html << "
\n
\n" if app_image html << "
\n\"App\n
\n" end html << "
\n
\n" html end # ── 9. FOOTER DESCRIPTION ── def render_footer_desc return "" unless @s.footer_description.present? html = +"" html << "\n" html end # ── 10. FOOTER ── def render_footer site_name = @s.title footer_border = @s.footer_border_style rescue "solid" style_parts = [] style_parts << "border-top: 1px #{footer_border} var(--cl-border);" if footer_border && footer_border != "none" style_attr = style_parts.any? ? " style=\"#{style_parts.join(' ')}\"" : "" html = +"" html << "\n" html end # ── Shared helpers ── def stat_card(icon_svg, count, label, icon_shape = "circle", card_style = "rectangle", round_numbers = false, show_label = true) shape_class = icon_shape == "rounded" ? "cl-stat-icon--rounded" : "cl-stat-icon--circle" style_class = "cl-stat-card--#{card_style}" round_attr = round_numbers ? ' data-round="true"' : '' label_html = show_label ? "#{e(label)}\n" : "" "
\n" \ "
#{icon_svg}
\n" \ "
\n" \ "0\n" \ "#{label_html}" \ "
\n" \ "
\n" end def app_badge(platform, url, custom_img, badge_h, badge_style) label = platform == :ios ? "App Store" : "Google Play" icon = platform == :ios ? Icons::IOS_BADGE_SVG : Icons::ANDROID_BADGE_SVG style_class = case badge_style when "pill" then "cl-app-badge--pill" when "square" then "cl-app-badge--square" else "cl-app-badge--rounded" end if custom_img "" \ "\"#{label}\"" \ "\n" else "" \ "#{icon}" \ "#{label}" \ "\n" end end def render_video_modal return "" unless (@s.hero_video_url.presence rescue nil) html = +"" html << "
\n" html << "
\n" html << "
\n" html << "\n" html << "
\n" html << "
\n" html << "
\n" html end def render_social_icons icons = { social_twitter_url: Icons::SOCIAL_TWITTER_SVG, social_facebook_url: Icons::SOCIAL_FACEBOOK_SVG, social_instagram_url: Icons::SOCIAL_INSTAGRAM_SVG, social_youtube_url: Icons::SOCIAL_YOUTUBE_SVG, social_tiktok_url: Icons::SOCIAL_TIKTOK_SVG, social_github_url: Icons::SOCIAL_GITHUB_SVG, } links = +"" icons.each do |setting, svg| url = (@s.public_send(setting).presence rescue nil) next unless url links << "#{svg}\n" end return "" if links.empty? "
#{links}
\n" end def theme_toggle "\n" end def render_json_ld(site_name, base_url, logo_url) org = { "@type" => "Organization", "name" => site_name, "url" => base_url } org["logo"] = logo_url if logo_url website = { "@type" => "WebSite", "name" => site_name, "url" => base_url } { "@context" => "https://schema.org", "@graph" => [org, website] }.to_json end def login_url "/login" end # ── Logo memoization ── def logo_dark_url return @logo_dark_url if defined?(@logo_dark_url) dark = @s.logo_dark_url.presence light = @s.logo_light_url.presence if dark.nil? && light.nil? dark = @s.respond_to?(:logo_url) ? @s.logo_url.presence : nil end @logo_dark_url = dark end def logo_light_url return @logo_light_url if defined?(@logo_light_url) @logo_light_url = @s.logo_light_url.presence end def has_logo? logo_dark_url.present? || logo_light_url.present? end def logo_height @logo_height ||= (@s.logo_height rescue 30) end def title_style(setting_name) size = (@s.public_send(setting_name) rescue 0).to_i size > 0 ? " style=\"font-size: #{size}px\"" : "" end def participation_stat(count, raw_label, default_svg) fa_enabled = (@s.fontawesome_enabled rescue false) # Parse "icon | Label" format — if present, use FA icon instead of default SVG if fa_enabled && raw_label.include?("|") parts = raw_label.split("|", 2).map(&:strip) left, right = parts if left.match?(/\A[\w-]+\z/) && left.length < 30 icon_html = "" label = right elsif right.match?(/\A[\w-]+\z/) && right.length < 30 icon_html = "" label = left else icon_html = default_svg label = raw_label end else icon_html = default_svg label = raw_label end "
" \ "#{icon_html}#{count}" \ "#{e(label)}" \ "
\n" end def button_with_icon(raw_label) fa_enabled = (@s.fontawesome_enabled rescue false) return e(raw_label) unless fa_enabled && raw_label.include?("|") parts = raw_label.split("|", 2).map(&:strip) # Try to detect which side is the icon name (no spaces, short) vs label text left, right = parts if left.match?(/\A[\w-]+\z/) && left.length < 30 # "iconname | Label" — icon before icon_html = "" "#{icon_html} #{e(right)}" elsif right.match?(/\A[\w-]+\z/) && right.length < 30 # "Label | iconname" — icon after icon_html = "" "#{e(left)} #{icon_html}" else e(raw_label) end end end end