# frozen_string_literal: true module CommunityLanding class PageBuilder include Helpers 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 html << render_hero html << render_stats html << render_about html << render_topics html << render_groups html << render_app_cta 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 html = +"" html << "\n\n\n" html << "\n" html << "\n" html << "\n" html << "\n" html << "\n" html << "\n" html << "#{e(@s.hero_title)} | #{e(site_name)}\n" html << "\n" html << "\n" html << "\n" html << "\n" html << "\n" if og_logo html << "\n" html << "\n" html << "\n" html << @styles.color_overrides html << @styles.section_backgrounds 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_bg_img = @s.hero_background_image_url.presence 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(' ')}\"" : "" html << "
\n" html << "
\n
\n" title_words = @s.hero_title.to_s.split(" ") if title_words.length > 1 html << "

#{e(title_words[0..-2].join(' '))} #{e(title_words.last)}

\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 << "#{e(primary_label)}\n" if primary_on html << "#{e(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 pill_bg = hex(@s.contributors_pill_bg_color) rescue nil 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.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 pill_style_parts << "background: #{pill_bg}" if pill_bg 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_image_urls_raw = @s.hero_image_urls.presence hero_video = @s.hero_video_url.presence rescue nil blur_attr = (@s.hero_video_blur_on_hover rescue true) ? " data-blur-hover=\"true\"" : "" has_images = false if hero_image_urls_raw urls = hero_image_urls_raw.split("|").map(&:strip).reject(&:empty?).first(5) 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 << "

#{e(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 << "

#{e(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 # ── 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 << "

#{e(@s.topics_title)}

\n" if show_title stagger_class = @s.staggered_reveal_enabled ? " cl-stagger" : "" html << "\n
\n" html end # ── 7. COMMUNITY SPACES ── def render_groups groups = @data[:groups] return "" unless @s.groups_enabled && groups&.any? border = @s.groups_border_style rescue "none" min_h = @s.groups_min_height rescue 0 show_title = @s.groups_title_enabled rescue true html = +"" html << "
\n" html << "

#{e(@s.groups_title)}

\n" if show_title stagger_class = @s.staggered_reveal_enabled ? " cl-stagger" : "" 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}%)" 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 << "
\n" html << "
\n" end html << "
\n
\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 << "

#{e(@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 theme_toggle "\n" 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 end end