From ef8a34e33aee158f95b4c559fdce35f666d45910 Mon Sep 17 00:00:00 2001 From: George Cushen Date: Wed, 18 Feb 2026 19:21:54 +0000 Subject: [PATCH] feat: convert remaining Landing Page blocks from Go HTML to React --- modules/blox/blox/cta-card/block.html | 178 --------- modules/blox/blox/cta-card/client.jsx | 25 ++ modules/blox/blox/cta-card/component.jsx | 128 +++++++ .../blox/blox/cta-image-paragraph/block.html | 151 -------- .../blox/blox/cta-image-paragraph/client.jsx | 25 ++ .../blox/cta-image-paragraph/component.jsx | 140 +++++++ modules/blox/blox/features/block.html | 52 --- modules/blox/blox/features/client.jsx | 25 ++ modules/blox/blox/features/component.jsx | 81 +++++ modules/blox/blox/hero/block.html | 117 ------ modules/blox/blox/hero/component.jsx | 16 +- .../blox/blox/shared/js/components/Icon.jsx | 13 +- modules/blox/blox/stats/block.html | 341 ------------------ modules/blox/blox/stats/client.jsx | 31 ++ modules/blox/blox/stats/component.jsx | 300 +++++++++++++++ modules/blox/blox/testimonials/block.html | 148 -------- modules/blox/blox/testimonials/client.jsx | 25 ++ modules/blox/blox/testimonials/component.jsx | 108 ++++++ .../layouts/_partials/blox/hero/wrapper.html | 32 -- .../_partials/blox/preact-wrapper.html | 122 ++++++- .../_partials/components/headers/navbar.html | 7 +- .../_partials/functions/parse_block_v3.html | 2 +- .../layouts/_partials/tailwind_sources.html | 63 +++- package.json | 3 +- .../config/_default/params.yaml | 12 +- .../startup-landing-page/content/_index.md | 6 +- 26 files changed, 1088 insertions(+), 1063 deletions(-) delete mode 100644 modules/blox/blox/cta-card/block.html create mode 100644 modules/blox/blox/cta-card/client.jsx create mode 100644 modules/blox/blox/cta-card/component.jsx delete mode 100644 modules/blox/blox/cta-image-paragraph/block.html create mode 100644 modules/blox/blox/cta-image-paragraph/client.jsx create mode 100644 modules/blox/blox/cta-image-paragraph/component.jsx delete mode 100644 modules/blox/blox/features/block.html create mode 100644 modules/blox/blox/features/client.jsx create mode 100644 modules/blox/blox/features/component.jsx delete mode 100644 modules/blox/blox/hero/block.html delete mode 100644 modules/blox/blox/stats/block.html create mode 100644 modules/blox/blox/stats/client.jsx create mode 100644 modules/blox/blox/stats/component.jsx delete mode 100644 modules/blox/blox/testimonials/block.html create mode 100644 modules/blox/blox/testimonials/client.jsx create mode 100644 modules/blox/blox/testimonials/component.jsx delete mode 100644 modules/blox/layouts/_partials/blox/hero/wrapper.html diff --git a/modules/blox/blox/cta-card/block.html b/modules/blox/blox/cta-card/block.html deleted file mode 100644 index a0ce680c..00000000 --- a/modules/blox/blox/cta-card/block.html +++ /dev/null @@ -1,178 +0,0 @@ -{{/* Hugo Blox: CTA Card */}} -{{/* Documentation: https://hugoblox.com/blocks/ */}} -{{/* License: https://github.com/HugoBlox/kit/blob/main/LICENSE.md */}} - -{{/* Initialise */}} -{{ $page := .wcPage }} -{{ $block := .wcBlock }} - -{{ $content := $block.content }} -{{ if not (reflect.IsMap $content) }}{{ $content = dict }}{{ end }} -{{ $design := $block.design }} -{{ if not (reflect.IsMap $design) }}{{ $design = dict }}{{ end }} -{{ $card := index $design "card" }} -{{ if not (reflect.IsMap $card) }}{{ $card = dict }}{{ end }} - -{{ $title := "" }} -{{ with index $content "title" }}{{ $title = printf "%v" . | emojify | $page.RenderString }}{{ end }} -{{ $text := "" }} -{{ with index $content "text" }}{{ $text = printf "%v" . | emojify | $page.RenderString | safeHTML }}{{ end }} -{{ $card_class := "" }} -{{ $card_class_raw := index $card "css_class" }} -{{ if eq (printf "%T" $card_class_raw) "string" }} - {{ $card_class = $card_class_raw }} -{{ end }} -{{ $card_style := "" }} -{{ $card_style_raw := index $card "css_style" }} -{{ if eq (printf "%T" $card_style_raw) "string" }} - {{ $card_style = $card_style_raw }} -{{ end }} - -{{/* Contrast and accessibility controls */}} -{{ $text_color_mode := "auto" }} -{{ $text_color_raw := index $card "text_color" }} -{{ if eq (printf "%T" $text_color_raw) "string" }} - {{ $text_color_candidate := lower (strings.TrimSpace $text_color_raw) }} - {{ if in (slice "auto" "light" "dark") $text_color_candidate }} - {{ $text_color_mode = $text_color_candidate }} - {{ end }} -{{ end }} -{{ $overlay_opacity := 0.15 }} -{{ $overlay_raw := index $card "overlay_opacity" }} -{{ if ne $overlay_raw nil }} - {{ $overlay_type := printf "%T" $overlay_raw }} - {{ if or (eq $overlay_type "int") (eq $overlay_type "int8") (eq $overlay_type "int16") (eq $overlay_type "int32") (eq $overlay_type "int64") (eq $overlay_type "uint") (eq $overlay_type "uint8") (eq $overlay_type "uint16") (eq $overlay_type "uint32") (eq $overlay_type "uint64") (eq $overlay_type "float32") (eq $overlay_type "float64") }} - {{ $overlay_opacity = float $overlay_raw }} - {{ else if eq $overlay_type "string" }} - {{ $overlay_trimmed := strings.TrimSpace $overlay_raw }} - {{ if gt (len (findRE "^-?[0-9]*\\.?[0-9]+$" $overlay_trimmed)) 0 }} - {{ $overlay_opacity = float $overlay_trimmed }} - {{ end }} - {{ end }} -{{ end }} -{{ if lt $overlay_opacity 0 }}{{ $overlay_opacity = 0 }}{{ end }} -{{ if gt $overlay_opacity 1 }}{{ $overlay_opacity = 1 }}{{ end }} - -{{/* Auto-detect text color based on background */}} -{{ $text_classes := "" }} -{{ $button_classes := "" }} -{{ if eq $text_color_mode "light" }} - {{ $text_classes = "text-white" }} - {{ $button_classes = "text-gray-900 dark:text-white" }} -{{ else if eq $text_color_mode "dark" }} - {{ $text_classes = "text-gray-900 dark:text-white" }} - {{ $button_classes = "text-gray-900 dark:text-white" }} -{{ else }} - {{/* Auto mode - detect based on gradient colors */}} - {{ $is_dark_bg := false }} - {{ $gradient_start := "" }} - {{ $bg := index $design "background" }} - {{ if and $bg (reflect.IsMap $bg) }} - {{ $gradient := index $bg "gradient" }} - {{ if and $gradient (reflect.IsMap $gradient) }} - {{ $start_raw := index $gradient "start" }} - {{ if eq (printf "%T" $start_raw) "string" }} - {{ $gradient_start = $start_raw }} - {{ end }} - {{ end }} - {{ end }} - {{ if $gradient_start }} - {{ if or (hasPrefix $gradient_start "primary-7") (hasPrefix $gradient_start "primary-8") (hasPrefix $gradient_start "primary-9") (hasPrefix $gradient_start "secondary-7") (hasPrefix $gradient_start "secondary-8") (hasPrefix $gradient_start "secondary-9") }} - {{ $is_dark_bg = true }} - {{ end }} - {{ end }} - {{ if $is_dark_bg }} - {{ $text_classes = "text-white" }} - {{ $button_classes = "text-gray-900" }} - {{ else }} - {{ $text_classes = "text-gray-900 dark:text-white" }} - {{ $button_classes = "text-gray-900 dark:text-gray-100" }} - {{ end }} -{{ end }} - - -{{/* Modern 2025 CTA Card - Background controlled via design.background in front matter */}} -{{ $custom_style := printf "--glassmorphism-opacity: %s;" (string $overlay_opacity) }} -{{ if $card_style }} - {{ $custom_style = printf "%s%s" $custom_style $card_style }} -{{ end }} - -
- - {{/* Modern Typography Hierarchy - Responsive text colors */}} - {{ with $title }} -

- {{ . }} -

- {{ end }} - - {{/* Enhanced Text with Better Spacing - Responsive colors */}} - {{ with $text }} -
- {{ . }} -
- {{ end }} - - {{ $button_raw := index $content "button" }} - {{ if and $button_raw (reflect.IsMap $button_raw) }} - {{ $button_text := "" }} - {{ $button_url := "" }} - {{ $button_icon := "" }} - {{ $button_new_tab := partial "functions/coerce_bool" (dict "value" (index $button_raw "new_tab") "default" false) }} - {{ with index $button_raw "text" }}{{ $button_text = printf "%v" . }}{{ end }} - {{ with index $button_raw "url" }}{{ $button_url = printf "%v" . }}{{ end }} - {{ with index $button_raw "icon" }} - {{ if eq (printf "%T" .) "string" }} - {{ $button_icon = . }} - {{ end }} - {{ end }} - {{ $button_text = strings.TrimSpace $button_text }} - {{ $button_url = strings.TrimSpace $button_url }} - {{ if and $button_text $button_url }} - {{/* Modern 2025 Button Container */}} -
- {{ $button_link := $button_url }} - {{ $button_scheme := (urls.Parse $button_link).Scheme }} - {{ $button_target := "" }} - {{ if not $button_scheme }} - {{/* Check if it's a hash fragment (page anchor) */}} - {{ if not (hasPrefix $button_link "#") }} - {{/* Apply relLangURL for relative paths */}} - {{ $button_link = $button_link | relLangURL }} - {{ end }} - {{ if eq (path.Ext $button_link) ".pdf" }} - {{ $button_new_tab = true }} - {{ end }} - {{ else if in (slice "http" "https") $button_scheme }} - {{ $button_new_tab = true }} - {{ end }} - {{/* Apply new tab if requested or external link */}} - {{ if $button_new_tab }} - {{ $button_target = "target=\"_blank\" rel=\"noopener\"" }} - {{ end }} - - {{/* 2025 Glassmorphism Button */}} - - - {{/* Button Background with Glassmorphism */}} -
- - {{/* Button Content */}} - {{ $button_text }} - {{ if $button_icon }} - - {{ partial "functions/get_icon" (dict "name" $button_icon "attributes" "style=\"height: 1.25em\" class='inline-block'") }} - - {{ end }} - - {{/* Subtle glow effect */}} -
-
-
- {{ end }} - {{ end }} - -
diff --git a/modules/blox/blox/cta-card/client.jsx b/modules/blox/blox/cta-card/client.jsx new file mode 100644 index 00000000..76d2f9fc --- /dev/null +++ b/modules/blox/blox/cta-card/client.jsx @@ -0,0 +1,25 @@ +import { render } from "preact"; +import { CtaCardBlock } from "./component.jsx"; + +function renderCtaCardBlock() { + const blocks = document.querySelectorAll('.cta-card-block-container[data-preact-render="true"]'); + + blocks.forEach((block) => { + try { + if (block.dataset.rendered === "true") return; + + const props = JSON.parse(block.dataset.props); + render(, block); + block.dataset.rendered = "true"; + } catch (e) { + console.error("Error rendering CTA Card Block:", e, block); + } + }); +} + +// Initial render +if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", renderCtaCardBlock); +} else { + renderCtaCardBlock(); +} diff --git a/modules/blox/blox/cta-card/component.jsx b/modules/blox/blox/cta-card/component.jsx new file mode 100644 index 00000000..50b0669c --- /dev/null +++ b/modules/blox/blox/cta-card/component.jsx @@ -0,0 +1,128 @@ +/** + * CTA Card Block Component + * A centered card with title, text, and primary action button. + * Supports glassmorphism, custom backgrounds, and automatic text color adjustment. + */ + +import { Icon } from "../../shared/components/Icon.jsx"; + +// Simple markdown renderer +function renderText(text) { + if (!text) return ""; + return String(text) + .replace(/\*\*(.*?)\*\*/g, "$1") + .replace(/\*(.*?)\*/g, "$1") + .replace(/`(.*?)`/g, "$1") + .replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1'); +} + +export const CtaCardBlock = ({ content, design, id, button_icon_svg }) => { + const card = design?.card || {}; + const cardClass = card.css_class || ""; + const cardStyle = card.css_style || ""; + + // Overlay Opacity Logic + let overlayOpacity = 0.15; + if (card.overlay_opacity !== undefined) { + const val = parseFloat(card.overlay_opacity); + if (!isNaN(val)) overlayOpacity = Math.max(0, Math.min(1, val)); + } + + // Text Color Logic + let textColorMode = "auto"; + if (["light", "dark", "auto"].includes(card.text_color?.toLowerCase())) { + textColorMode = card.text_color.toLowerCase(); + } + + let textClasses = "text-gray-900 dark:text-white"; + let buttonClasses = "text-gray-900 dark:text-gray-100"; + + // Auto-detect based on background gradient + const bg = design?.background || {}; + const gradientStart = bg.gradient?.start || ""; + const isDarkBg = ["primary-7", "primary-8", "primary-9", "secondary-7", "secondary-8", "secondary-9"].some(p => gradientStart.startsWith(p)); + + if (textColorMode === "light") { + textClasses = "text-white"; + buttonClasses = "text-gray-900 dark:text-white"; + } else if (textColorMode === "dark") { + textClasses = "text-gray-900 dark:text-white"; + buttonClasses = "text-gray-900 dark:text-white"; + } else if (isDarkBg) { + textClasses = "text-white"; + buttonClasses = "text-gray-900"; + } + + // Button Logic + const button = content?.button || {}; + const buttonText = button.text?.trim(); + const buttonUrl = button.url?.trim(); + const showButton = !!(buttonText && buttonUrl); + + let isExternal = false; + let isNewTab = button.new_tab === true; + if (buttonUrl) { + try { + const url = new URL(buttonUrl, window.location.origin); + isExternal = url.origin !== window.location.origin; + } catch { + isExternal = /^https?:\/\//.test(buttonUrl); + } + if (buttonUrl.endsWith(".pdf")) isNewTab = true; + if (isExternal) isNewTab = true; + } + + const defaultBgClass = "bg-gradient-to-br from-primary-500/90 via-primary-600/95 to-primary-700/90"; + const finalCardClass = cardClass || defaultBgClass; + + const style = `--glassmorphism-opacity: ${overlayOpacity}; ${cardStyle}`; + + return ( +
+ {/* Title */} + {content.title && ( +

+ )} + + {/* Text */} + {content.text && ( + + ); +}; diff --git a/modules/blox/blox/cta-image-paragraph/block.html b/modules/blox/blox/cta-image-paragraph/block.html deleted file mode 100644 index 7e10c216..00000000 --- a/modules/blox/blox/cta-image-paragraph/block.html +++ /dev/null @@ -1,151 +0,0 @@ -{{/* Hugo Blox: CTA Image Paragraph */}} -{{/* Documentation: https://hugoblox.com/blocks/ */}} -{{/* License: https://github.com/HugoBlox/kit/blob/main/LICENSE.md */}} - -{{/* Initialise */}} -{{ $page := .wcPage }} -{{ $block := .wcBlock }} -{{ $content := $block.content }} -{{ if not (reflect.IsMap $content) }}{{ $content = dict }}{{ end }} - -
- {{ $items_raw := index $content "items" }} - {{ $items := slice }} - {{ if reflect.IsSlice $items_raw }} - {{ $items = $items_raw }} - {{ else if and $items_raw (reflect.IsMap $items_raw) }} - {{ $items = slice $items_raw }} - {{ end }} - - {{ range $idx, $item := $items }} - {{ if reflect.IsMap $item }} - {{ $title := "" }} - {{ $title_raw := index $item "title" }} - {{ if eq (printf "%T" $title_raw) "string" }}{{ $title = $title_raw | emojify | $page.RenderString }}{{ end }} - {{ $text := "" }} - {{ $text_raw := index $item "text" }} - {{ if eq (printf "%T" $text_raw) "string" }}{{ $text = $text_raw | emojify | $page.RenderString }}{{ end }} - {{ $image_name := "" }} - {{ $image_raw := index $item "image" }} - {{ if eq (printf "%T" $image_raw) "string" }}{{ $image_name = strings.TrimSpace $image_raw }}{{ end }} - {{/* Resolve image from page bundle, site assets, remote, or static URLs */}} - {{ $image_resource := false }} - {{ $image_url := "" }} - {{ $image_path := "" }} - {{ if $image_name }} - {{ $image_path = strings.TrimSpace (printf "%v" $image_name) }} - {{ $is_remote := or (strings.HasPrefix $image_path "http://") (strings.HasPrefix $image_path "https://") }} - - {{ if $is_remote }} - {{ $remote := try (resources.GetRemote $image_path) }} - {{ if and $remote (not $remote.Err) }} - {{ $image_resource = $remote.Value }} - {{ else }} - {{ $image_url = $image_path }} - {{ end }} - {{ else }} - {{ $image_path = strings.TrimPrefix "/" $image_path }} - {{ $image_path = strings.TrimPrefix "./" $image_path }} - {{ $image_path = strings.TrimPrefix "assets/" $image_path }} - {{ $image_path = strings.TrimPrefix "media/" $image_path }} - {{ $normalized := path.Clean $image_path }} - - {{ if and $normalized (ne $normalized ".") }} - {{ with $page }} - {{ $image_resource = (.Resources.ByType "image").GetMatch $normalized }} - {{ end }} - {{ if not $image_resource }} - {{ $image_resource = resources.Get (path.Join "media" $normalized) }} - {{ end }} - {{ if not $image_resource }} - {{ $image_url = printf "/media/%s" $normalized }} - {{ end }} - {{ end }} - {{ end }} - {{ end }} - - {{ $image_to_render := $image_resource }} -
- {{ if or $image_to_render $image_url }} - {{ if $image_to_render }} - {{ if ne $image_to_render.MediaType.SubType "gif" }} - {{ $responsive := partial "functions/process_responsive_image.html" (dict - "image" $image_to_render - "mode" "fit" - "sizes" (slice 400 600 800 1200) - ) }} - {{$title | plainify}} - {{ else }} - {{$title | plainify}} - {{ end }} - {{ else if $image_url }} - {{ $is_external := or (strings.HasPrefix $image_url "http://") (strings.HasPrefix $image_url "https://") }} - {{$title | plainify}} - {{ end }} - {{ else if $image_name }} - {{ warnf "%s uses the `cta-image-paragraph` blox which specifies a non-existent `image` at `assets/%s`. Rendering text only." $page.File.Path $image_path }} - {{ end }} -
-

{{$title}}

-

{{$text}}

- {{ $icon := "check" }} - {{ $icon_raw := index $item "feature_icon" }} - {{ if eq (printf "%T" $icon_raw) "string" }}{{ $icon = $icon_raw }}{{ end }} - {{ $features_raw := index $item "features" }} - {{ $features := slice }} - {{ if reflect.IsSlice $features_raw }} - {{ $features = $features_raw }} - {{ else if eq (printf "%T" $features_raw) "string" }} - {{ $features = slice $features_raw }} - {{ end }} - {{ if gt (len $features) 0 }} -
    - {{ range $features }} -
  • - {{ partial "functions/get_icon" (dict "name" $icon "attributes" `class="inline-block pr-3" style="height: 1em;"`) }} - {{ printf "%v" . | markdownify }} -
  • - {{ end }} -
- {{ end }} - {{ $button_raw := index $item "button" }} - {{ if and $button_raw (reflect.IsMap $button_raw) }} - {{ $button_text := "" }} - {{ $button_url := "" }} - {{ with index $button_raw "text" }}{{ $button_text = strings.TrimSpace (printf "%v" .) }}{{ end }} - {{ with index $button_raw "url" }}{{ $button_url = strings.TrimSpace (printf "%v" .) }}{{ end }} - {{ if and $button_text $button_url }} - {{ $link := $button_url }} - {{ $target := "" }} - {{ $scheme := (urls.Parse $link).Scheme }} - {{ if not $scheme }} - {{ if not (hasPrefix $link "#") }} - {{ $link = $link | relLangURL }} - {{ end }} - {{ if eq (path.Ext $link) ".pdf" }}{{ $target = "target=\"_blank\" rel=\"noopener\"" }}{{ end }} - {{ else if in (slice "http" "https") $scheme }} - {{ $target = "target=\"_blank\" rel=\"noopener\"" }} - {{ end }} - - {{ $button_text }} - - - {{ end }} - {{ end }} -
-
- {{ end }} - {{ end }} -
diff --git a/modules/blox/blox/cta-image-paragraph/client.jsx b/modules/blox/blox/cta-image-paragraph/client.jsx new file mode 100644 index 00000000..298411f9 --- /dev/null +++ b/modules/blox/blox/cta-image-paragraph/client.jsx @@ -0,0 +1,25 @@ +/** + * CTA Image Paragraph Block - Client-side Hydration + */ + +import { render } from "preact"; +import { CtaImageParagraphBlock } from "./component.jsx"; + +function renderCtaImageParagraphBlocks() { + const blocks = document.querySelectorAll('[data-block-type="cta-image-paragraph"]'); + + blocks.forEach((block) => { + const propsData = block.dataset.props; + if (propsData) { + try { + const props = JSON.parse(propsData); + render(, block); + console.debug(`✓ CTA Image Paragraph block "${block.id}" rendered with Preact`); + } catch (error) { + console.error(`Failed to render CTA Image Paragraph block "${block.id}":`, error); + } + } + }); +} + +renderCtaImageParagraphBlocks(); diff --git a/modules/blox/blox/cta-image-paragraph/component.jsx b/modules/blox/blox/cta-image-paragraph/component.jsx new file mode 100644 index 00000000..3d893452 --- /dev/null +++ b/modules/blox/blox/cta-image-paragraph/component.jsx @@ -0,0 +1,140 @@ +/** + * CTA Image Paragraph Block Component - Single source of truth + * Alternating image + text sections with feature lists and CTA buttons + * Images are pre-processed by Hugo's responsive image pipeline (srcset) + */ + +import { Icon } from "../../shared/components/Icon.jsx"; + +// Simple markdown renderer +function renderText(text) { + if (!text) return ""; + return String(text) + .replace(/\*\*(.*?)\*\*/g, "$1") + .replace(/\*(.*?)\*/g, "$1") + .replace(/`(.*?)`/g, "$1") + .replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1'); +} + +// Arrow icon for CTA buttons +const ArrowIcon = () => ( + + + +); + +function CtaItem({ item, idx, imageData, featureIconSvg }) { + const isReversed = idx % 2 === 1; + const hasImage = !!imageData; + const featureIcon = item.feature_icon || "check"; + + // Resolve button link + let buttonUrl = item.button?.url || ""; + let isExternal = false; + if (buttonUrl) { + try { + const url = new URL(buttonUrl, window.location.origin); + isExternal = url.origin !== window.location.origin; + } catch { + isExternal = /^https?:\/\//.test(buttonUrl); + } + } + + const features = Array.isArray(item.features) + ? item.features + : typeof item.features === "string" + ? [item.features] + : []; + + return ( +
+ {/* Image */} + {hasImage && ( + imageData.srcset ? ( + {item.title + ) : ( + {item.title + ) + )} + + {/* Text Content */} +
+ {item.title && ( +

+ )} + {item.text && ( +

+ )} + + {/* Feature List */} + {features.length > 0 && ( +

    + {features.map((feature, fIdx) => ( +
  • + {featureIconSvg ? ( + + ) : null} + +
  • + ))} +
+ )} + + {/* CTA Button */} + {item.button?.text && item.button?.url && ( + + {item.button.text} + + + )} +

+
+ ); +} + +export const CtaImageParagraphBlock = ({ content, design, id, item_images, icon_svgs }) => { + const items = Array.isArray(content?.items) ? content.items : []; + const imageMap = item_images || {}; + const iconMap = icon_svgs || {}; + + return ( +
+ {items.map((item, idx) => ( + + ))} +
+ ); +}; diff --git a/modules/blox/blox/features/block.html b/modules/blox/blox/features/block.html deleted file mode 100644 index 012e9e1c..00000000 --- a/modules/blox/blox/features/block.html +++ /dev/null @@ -1,52 +0,0 @@ -{{/* Hugo Blox: Features */}} -{{/* Documentation: https://hugoblox.com/blocks/ */}} -{{/* License: https://github.com/HugoBlox/kit/blob/main/LICENSE.md */}} - -{{/* Initialise */}} -{{ $page := .wcPage }} -{{ $block := .wcBlock }} -{{ $content := $block.content }} -{{ if not (reflect.IsMap $content) }}{{ $content = dict }}{{ end }} -{{ $title := "" }} -{{ with index $content "title" }}{{ $title = printf "%v" . | emojify | $page.RenderString }}{{ end }} -{{ $text := "" }} -{{ with index $content "text" }}{{ $text = printf "%v" . | emojify | $page.RenderString }}{{ end }} -{{ $items_raw := index $content "items" }} -{{ $items := slice }} -{{ if reflect.IsSlice $items_raw }}{{ $items = $items_raw }}{{ end }} - -
-
-
- {{ with $title }} -

{{.}}

- {{ end }} - {{ with $text }} -

{{.}}

- {{ end }} -
-
- {{ range $items }} - {{ if reflect.IsMap . }} - {{ $icon := "" }} - {{ with index . "icon" }} - {{ if eq (printf "%T" .) "string" }}{{ $icon = . }}{{ end }} - {{ end }} - {{ $name := "" }} - {{ with index . "name" }}{{ $name = printf "%v" . }}{{ end }} - {{ $description := "" }} - {{ with index . "description" }}{{ $description = printf "%v" . }}{{ end }} -
- {{ with $icon }} -
- {{ partial "functions/get_icon" (dict "name" . "attributes" `class="w-5 h-5 lg:w-6 lg:h-6"`) }} -
- {{ end }} - {{ with $name }}

{{ . | $page.RenderString }}

{{ end }} - {{ with $description }}

{{ . | $page.RenderString }}

{{ end }} -
- {{ end }} - {{ end }} -
-
-
diff --git a/modules/blox/blox/features/client.jsx b/modules/blox/blox/features/client.jsx new file mode 100644 index 00000000..bab1d116 --- /dev/null +++ b/modules/blox/blox/features/client.jsx @@ -0,0 +1,25 @@ +/** + * Features Block - Client-side Hydration + */ + +import { render } from "preact"; +import { FeaturesBlock } from "./component.jsx"; + +function renderFeaturesBlocks() { + const blocks = document.querySelectorAll('[data-block-type="features"]'); + + blocks.forEach((block) => { + const propsData = block.dataset.props; + if (propsData) { + try { + const props = JSON.parse(propsData); + render(, block); + console.debug(`✓ Features block "${block.id}" rendered with Preact`); + } catch (error) { + console.error(`Failed to render Features block "${block.id}":`, error); + } + } + }); +} + +renderFeaturesBlocks(); diff --git a/modules/blox/blox/features/component.jsx b/modules/blox/blox/features/component.jsx new file mode 100644 index 00000000..3f26e540 --- /dev/null +++ b/modules/blox/blox/features/component.jsx @@ -0,0 +1,81 @@ +/** + * Features Block Component - Single source of truth + * Responsive grid of feature items with icon, name, and description + */ + +import { Icon } from "../../shared/components/Icon.jsx"; + +// Simple markdown renderer +function renderText(text) { + if (!text) return ""; + return String(text) + .replace(/\*\*(.*?)\*\*/g, "$1") + .replace(/\*(.*?)\*/g, "$1") + .replace(/`(.*?)`/g, "$1"); +} + +function FeatureItem({ item, iconSvg }) { + return ( +
+ {iconSvg && ( +
+ +
+ )} + {item.name && ( +

+ )} + {item.description && ( +

+ )} +

+ ); +} + +export const FeaturesBlock = ({ content, design, id, icon_svgs }) => { + const title = content?.title; + const text = content?.text; + const items = Array.isArray(content?.items) ? content.items : []; + const iconMap = icon_svgs || {}; + + return ( +
+
+ {/* Section Header */} + {(title || text) && ( +
+ {title && ( +

+ )} + {text && ( +

+ )} +

+ )} + + {/* Feature Grid */} +
+ {items.map((item, idx) => ( + + ))} +
+
+
+ ); +}; diff --git a/modules/blox/blox/hero/block.html b/modules/blox/blox/hero/block.html deleted file mode 100644 index b7ad8ea2..00000000 --- a/modules/blox/blox/hero/block.html +++ /dev/null @@ -1,117 +0,0 @@ -{{/* Hugo Blox: Hero */}} -{{/* Documentation: https://hugoblox.com/blocks/ */}} -{{/* License: https://github.com/HugoBlox/kit/blob/main/LICENSE.md */}} - -{{/* Initialise */}} -{{ $page := .wcPage }} -{{ $block := .wcBlock }} -{{ $content := $block.content }} -{{ if not (reflect.IsMap $content) }}{{ $content = dict }}{{ end }} -{{ $design := $block.design }} -{{ if not (reflect.IsMap $design) }}{{ $design = dict }}{{ end }} -{{ $no_padding := partial "functions/coerce_bool" (dict "value" (index $design "no_padding") "default" false) }} - -{{ $announcement := index $content "announcement" }} -{{ if not (reflect.IsMap $announcement) }}{{ $announcement = dict }}{{ end }} -{{ $announcement_text := "" }} -{{ with index $announcement "text" }}{{ $announcement_text = strings.TrimSpace (printf "%v" .) }}{{ end }} -{{ $announcement_link := index $announcement "link" }} -{{ if not (reflect.IsMap $announcement_link) }}{{ $announcement_link = dict }}{{ end }} -{{ $announcement_link_text := "" }} -{{ with index $announcement_link "text" }}{{ $announcement_link_text = strings.TrimSpace (printf "%v" .) }}{{ end }} -{{ $announcement_link_url := "" }} -{{ with index $announcement_link "url" }}{{ $announcement_link_url = strings.TrimSpace (printf "%v" .) }}{{ end }} - -{{ $primary_action := index $content "primary_action" }} -{{ if not (reflect.IsMap $primary_action) }}{{ $primary_action = dict }}{{ end }} -{{ $primary_text := "" }} -{{ with index $primary_action "text" }}{{ $primary_text = strings.TrimSpace (printf "%v" .) }}{{ end }} -{{ $primary_url := "" }} -{{ with index $primary_action "url" }}{{ $primary_url = strings.TrimSpace (printf "%v" .) }}{{ end }} -{{ $primary_icon := "" }} -{{ with index $primary_action "icon" }}{{ $primary_icon = printf "%v" . }}{{ end }} - -{{ $secondary_action := index $content "secondary_action" }} -{{ if not (reflect.IsMap $secondary_action) }}{{ $secondary_action = dict }}{{ end }} -{{ $secondary_text := "" }} -{{ with index $secondary_action "text" }}{{ $secondary_text = strings.TrimSpace (printf "%v" .) }}{{ end }} -{{ $secondary_url := "" }} -{{ with index $secondary_action "url" }}{{ $secondary_url = strings.TrimSpace (printf "%v" .) }}{{ end }} -
- -
- - {{ if $announcement_text }} - - {{end}} - -
- {{ with index $content "title" }}

{{ printf "%v" . | markdownify }}

{{end}} - {{ with index $content "text" }}

{{ printf "%v" . | $page.RenderString | emojify }}

{{end}} - - {{ if and $primary_url $primary_text }} - {{ $link := $primary_url }} - {{ $scheme := (urls.Parse $link).Scheme }} - {{ $target := "" }} - {{ if not $scheme }} - {{/* Check if it's a hash fragment (page anchor) */}} - {{ if hasPrefix $link "#" }} - {{/* Keep hash fragments as-is */}} - {{ $link = $link }} - {{ else }} - {{/* Apply relLangURL for relative paths */}} - {{ $link = $link | relLangURL }} - {{ end }} - {{ else if in (slice "http" "https") $scheme }} - {{ $target = "target=\"_blank\" rel=\"noopener\"" }} - {{ end }} -
- - {{ $primary_text | markdownify | emojify }} - {{ if $primary_icon }} - {{ partial "functions/get_icon" (dict "name" $primary_icon "attributes" "style=\"height: 1em\" class='inline-block pl-2'") }} - {{ end }} - - {{end}} - - {{ if and $secondary_url $secondary_text }} - {{ $link_alt := $secondary_url }} - {{ $scheme_alt := (urls.Parse $link_alt).Scheme }} - {{ $target_alt := "" }} - {{ if not $scheme_alt }} - {{/* Check if it's a hash fragment (page anchor) */}} - {{ if hasPrefix $link_alt "#" }} - {{/* Keep hash fragments as-is */}} - {{ $link_alt = $link_alt }} - {{ else }} - {{/* Apply relLangURL for relative paths */}} - {{ $link_alt = $link_alt | relLangURL }} - {{ end }} - {{ else if in (slice "http" "https") $scheme_alt }} - {{ $target_alt = "target=\"_blank\" rel=\"noopener\"" }} - {{ end }} - {{ $secondary_text | markdownify | emojify }} - {{end}} - -
- -
-
- -
diff --git a/modules/blox/blox/hero/component.jsx b/modules/blox/blox/hero/component.jsx index efe1c709..4b166138 100644 --- a/modules/blox/blox/hero/component.jsx +++ b/modules/blox/blox/hero/component.jsx @@ -3,7 +3,7 @@ * Used for both SSR and client-side hydration */ -import {Icon} from "../../shared/components/Icon.jsx"; +import { Icon } from "../../shared/components/Icon.jsx"; // Simple markdown renderer export function renderText(text) { @@ -16,7 +16,7 @@ export function renderText(text) { // Process URLs export function processUrl(url) { - if (!url) return {href: "#"}; + if (!url) return { href: "#" }; if (url.startsWith("http://") || url.startsWith("https://")) { return { @@ -27,18 +27,18 @@ export function processUrl(url) { } if (url.startsWith("#")) { - return {href: url}; + return { href: url }; } - return {href: url}; + return { href: url }; } // Hero Block Component - Single implementation -export const HeroBlock = ({content, design, id, icon_svg}) => { +export const HeroBlock = ({ content, design, id, icon_svg }) => { const paddingClasses = design?.no_padding ? "" : "py-32 sm:py-48 lg:py-56"; return ( -
+
{/* Announcement Banner */} {content.announcement?.text && ( @@ -73,7 +73,7 @@ export const HeroBlock = ({content, design, id, icon_svg}) => { {content.title && (

)} @@ -81,7 +81,7 @@ export const HeroBlock = ({content, design, id, icon_svg}) => { {content.text && (

)} diff --git a/modules/blox/blox/shared/js/components/Icon.jsx b/modules/blox/blox/shared/js/components/Icon.jsx index 9889b0eb..c5ce01b3 100644 --- a/modules/blox/blox/shared/js/components/Icon.jsx +++ b/modules/blox/blox/shared/js/components/Icon.jsx @@ -1,12 +1,12 @@ // biome-ignore lint/correctness/noUnusedImports: Classic Preact JSX runtime may require 'h' for JSX transform -import {h} from "preact"; +import { h } from "preact"; /** * Icon component * Renders an SVG icon from a raw SVG string passed from Hugo. * Decodes JSON-escaped sequences (\u003c) and HTML entities (<, ", "). */ -export const Icon = ({svg, attributes}) => { +export const Icon = ({ svg, attributes }) => { if (!svg) return null; let decoded = String(svg) @@ -28,8 +28,13 @@ export const Icon = ({svg, attributes}) => { decoded = decoded.replace(/; + return ; } const finalAttributes = { @@ -44,5 +49,5 @@ export const Icon = ({svg, attributes}) => { .join(" "); // eslint-disable-next-line lint/security/noDangerouslySetInnerHtml - return ${decoded}`}} />; + return ${decoded}` }} />; }; diff --git a/modules/blox/blox/stats/block.html b/modules/blox/blox/stats/block.html deleted file mode 100644 index 12e90fb1..00000000 --- a/modules/blox/blox/stats/block.html +++ /dev/null @@ -1,341 +0,0 @@ -{{/* Hugo Blox: Stats */}} -{{/* Documentation: https://hugoblox.com/blocks/ */}} -{{/* License: https://github.com/HugoBlox/kit/blob/main/LICENSE.md */}} - -{{/* Initialise */}} -{{ $page := .wcPage }} -{{ $block := .wcBlock }} -{{ $content := $block.content }} -{{ if not (reflect.IsMap $content) }}{{ $content = dict }}{{ end }} -{{ $design := $block.design }} -{{ if not (reflect.IsMap $design) }}{{ $design = dict }}{{ end }} - -{{ $title := "" }} -{{ $title_raw := index $content "title" }} -{{ if $title_raw }} - {{ $title = strings.TrimSpace (printf "%v" $title_raw) }} -{{ end }} -{{ if $title }}{{ $title = $title | emojify | $page.RenderString }}{{ end }} - -{{ $text := "" }} -{{ $text_raw := index $content "text" }} -{{ if $text_raw }} - {{ $text = strings.TrimSpace (printf "%v" $text_raw) }} -{{ end }} -{{ if $text }}{{ $text = $text | emojify | $page.RenderString }}{{ end }} - -{{ $layout := "cards" }} -{{ $layout_raw := index $design "layout" }} -{{ if eq (printf "%T" $layout_raw) "string" }} - {{ $layout_candidate := lower (strings.TrimSpace $layout_raw) }} - {{ if in (slice "cards" "compact" "minimal") $layout_candidate }} - {{ $layout = $layout_candidate }} - {{ end }} -{{ end }} - -{{ $items_raw := index $content "items" }} -{{ $items_iter := slice }} -{{ if reflect.IsSlice $items_raw }} - {{ $items_iter = $items_raw }} -{{ else if and $items_raw (reflect.IsMap $items_raw) }} - {{ $items_iter = slice $items_raw }} -{{ end }} - -{{ $items := slice }} -{{ if gt (len $items_iter) 0 }} - {{ range $items_iter }} - {{ if reflect.IsMap . }} - {{ $icon := "" }} - {{ $statistic := "" }} - {{ $description := "" }} - {{ $sub_metric := "" }} - {{ with index . "icon" }}{{ $icon = printf "%v" . }}{{ end }} - {{ with index . "statistic" }}{{ $statistic = strings.TrimSpace (printf "%v" .) }}{{ end }} - {{ with index . "description" }}{{ $description = strings.TrimSpace (printf "%v" .) }}{{ end }} - {{ with index . "sub_metric" }}{{ $sub_metric = strings.TrimSpace (printf "%v" .) }}{{ end }} - {{ if or $statistic $description }} - {{ $items = $items | append (dict "icon" $icon "statistic" $statistic "description" $description "sub_metric" $sub_metric) }} - {{ end }} - {{ end }} - {{ end }} -{{ end }} - -{{ $item_count := len $items }} -{{ if lt $item_count 1 }}{{ $item_count = 1 }}{{ end }} -{{ if gt $item_count 4 }}{{ $item_count = 4 }}{{ end }} -{{ $grid_cols_class := "lg:grid-cols-3" }} -{{ if eq $item_count 1 }} - {{ $grid_cols_class = "lg:grid-cols-1" }} -{{ else if eq $item_count 2 }} - {{ $grid_cols_class = "lg:grid-cols-2" }} -{{ else if eq $item_count 4 }} - {{ $grid_cols_class = "lg:grid-cols-4" }} -{{ end }} -{{ $base_cols_class := "grid-cols-2" }} -{{ if eq $item_count 1 }}{{ $base_cols_class = "grid-cols-1" }}{{ end }} - -

-
- - {{/* Section Header */}} - {{ if or $title $text }} -
- {{ with $title }} -

- {{ . }} -

- {{ end }} - {{ with $text }} -

- {{ . }} -

- {{ end }} -
- {{ end }} - - {{/* Cards Layout */}} - {{ if eq $layout "cards" }} -
- {{ range $idx, $item := $items }} -
- - {{/* Background Gradient */}} -
- -
- {{/* Icon */}} - {{ $icon := index $item "icon" }} - {{ if $icon }} -
- {{ partial "functions/get_icon" (dict "name" $icon "attributes" "class=\"w-8 h-8 text-primary-600 dark:text-primary-400\"") }} -
- {{ end }} - - {{/* Statistic */}} -
- {{ $statistic := index $item "statistic" }} -

- {{ $statistic | $page.RenderString }} -

-
- - {{/* Description */}} -

- {{ (index $item "description") | $page.RenderString }} -

- - {{/* Optional Sub-metric */}} - {{ with index $item "sub_metric" }} -

- {{ . }} -

- {{ end }} -
-
- {{ end }} -
- - {{/* Compact Layout */}} - {{ else if eq $layout "compact" }} -
-
- {{ range $idx, $item := $items }} -
- {{ $icon := index $item "icon" }} - {{ if $icon }} -
- {{ partial "functions/get_icon" (dict "name" $icon "attributes" "class=\"w-6 h-6 text-primary-600 dark:text-primary-400\"") }} -
- {{ end }} - - {{ $statistic := index $item "statistic" }} -

- {{ $statistic | $page.RenderString }} -

- -

- {{ (index $item "description") | $page.RenderString }} -

-
- {{ end }} -
-
- - {{/* Minimal Layout */}} - {{ else }} -
- {{ range $item := $items }} -
- {{ $icon := index $item "icon" }} - {{ if $icon }} -
- {{ partial "functions/get_icon" (dict "name" $icon "attributes" "class=\"w-7 h-7 text-primary-600 dark:text-primary-400\"") }} -
- {{ end }} - - {{ $statistic := index $item "statistic" }} -

- {{ $statistic | $page.RenderString }} -

- -

- {{ (index $item "description") | $page.RenderString }} -

-
- {{ end }} -
- {{ end }} -
-
- -{{/* Enhanced Animation CSS */}} - - -{{/* Animation JavaScript for Counter and Intersection Observer */}} - diff --git a/modules/blox/blox/stats/client.jsx b/modules/blox/blox/stats/client.jsx new file mode 100644 index 00000000..bae456fc --- /dev/null +++ b/modules/blox/blox/stats/client.jsx @@ -0,0 +1,31 @@ +/** + * Stats Block - Client-side Hydration + * Uses the shared StatsBlock component for consistency + */ + +import { render } from "preact"; +import { StatsBlock } from "./component.jsx"; + +function renderStatsBlocks() { + const statsBlocks = document.querySelectorAll('[data-block-type="stats"]'); + + statsBlocks.forEach((block) => { + const propsData = block.dataset.props; + if (propsData) { + try { + const props = JSON.parse(propsData); + render(, block); + console.debug(`✓ Stats block "${block.id}" rendered with Preact`); + } catch (error) { + console.error(`Failed to render Stats block "${block.id}":`, error); + } + } + }); + + if (statsBlocks.length > 0) { + console.debug(`✓ ${statsBlocks.length} Stats blocks initialized with Preact`); + } +} + +// Initialize immediately — script is already deferred +renderStatsBlocks(); diff --git a/modules/blox/blox/stats/component.jsx b/modules/blox/blox/stats/component.jsx new file mode 100644 index 00000000..e0ab07e4 --- /dev/null +++ b/modules/blox/blox/stats/component.jsx @@ -0,0 +1,300 @@ +/** + * Stats Block Component - Single source of truth + * Used for both SSR (via preact-wrapper) and SaaS live preview + * + * Layouts: cards | compact | minimal + * Features: counter animation, per-item icons, stagger reveal + */ + +import { useEffect, useRef } from "preact/hooks"; +import { Icon } from "../../shared/components/Icon.jsx"; + +// Simple markdown renderer (shared with Hero) +function renderText(text) { + if (!text) return ""; + return text + .replace(/\*\*(.*?)\*\*/g, "$1") + .replace(/\*(.*?)\*/g, "$1") + .replace(/`(.*?)`/g, "$1"); +} + +// Extract numeric target from a statistic string like "10,000+" → 10000 +function parseTarget(statistic) { + if (!statistic) return 0; + const raw = String(statistic).replace(/[^0-9.]/g, ""); + const num = Number(raw); + return isNaN(num) ? 0 : num; +} + +// Extract prefix, number, suffix from "~10,000+" → { prefix: "~", number: "10,000", suffix: "+" } +function extractParts(text) { + if (!text) return { prefix: "", numberPart: "", suffix: "" }; + const parts = String(text).match(/([^0-9]*)([0-9][0-9,.]*)(.*)/); + return { + prefix: parts ? parts[1] : "", + numberPart: parts ? parts[2] : "", + suffix: parts ? parts[3] : "", + }; +} + +// Counter animation hook +function useCounterAnimation(containerRef) { + useEffect(() => { + const container = containerRef.current; + if (!container) return; + + const prefersReducedMotion = window.matchMedia( + "(prefers-reduced-motion: reduce)" + ).matches; + + function animateCounters(el) { + el.classList.add("animate"); + const counters = el.querySelectorAll(".counter"); + + counters.forEach((counter) => { + const target = parseTarget(counter.getAttribute("data-target")); + if (target <= 0) return; + + const { prefix, numberPart, suffix } = extractParts( + counter.textContent + ); + const decimals = (numberPart.split(".")[1] || "").length; + const formatter = new Intl.NumberFormat(undefined, { + minimumFractionDigits: decimals, + maximumFractionDigits: decimals, + }); + + if (prefersReducedMotion) { + counter.textContent = `${prefix}${formatter.format(target)}${suffix}`; + return; + } + + const duration = 2000; + const start = Date.now(); + + function update() { + const now = Date.now(); + const progress = Math.min((now - start) / duration, 1); + const easeOut = 1 - Math.pow(1 - progress, 3); + const current = target * easeOut; + counter.textContent = `${prefix}${formatter.format(current)}${suffix}`; + if (progress < 1) requestAnimationFrame(update); + } + + requestAnimationFrame(update); + }); + } + + const observer = new IntersectionObserver( + (entries) => { + entries.forEach((entry) => { + if (entry.isIntersecting) { + animateCounters(entry.target); + observer.unobserve(entry.target); + } + }); + }, + { threshold: 0.2, rootMargin: "0px 0px -10% 0px" } + ); + + // Observe each stat item + const items = container.querySelectorAll(".stats-item"); + items.forEach((item) => { + const rect = item.getBoundingClientRect(); + const vh = window.innerHeight; + if (rect.top < vh * 0.9 && rect.bottom > 0) { + animateCounters(item); + } else { + observer.observe(item); + } + }); + + return () => observer.disconnect(); + }, []); +} + +// Grid columns class based on item count +function gridColsClass(count) { + if (count <= 1) return "lg:grid-cols-1"; + if (count === 2) return "lg:grid-cols-2"; + if (count >= 4) return "lg:grid-cols-4"; + return "lg:grid-cols-3"; +} + +// ─── Stat Item (Cards) ─────────────────────────────────────────────── +function StatCard({ item, iconSvg }) { + return ( +
+ {/* Hover gradient */} +
+ +
+ {/* Icon */} + {iconSvg && ( +
+ +
+ )} + + {/* Statistic */} +
+

+

+ + {/* Description */} +

+ + {/* Sub-metric */} + {item.sub_metric && ( +

+ {item.sub_metric} +

+ )} +
+
+ ); +} + +// ─── Stat Item (Compact) ───────────────────────────────────────────── +function StatCompact({ item, iconSvg }) { + return ( +
+ {iconSvg && ( +
+ +
+ )} + +

+ +

+

+ ); +} + +// ─── Stat Item (Minimal) ───────────────────────────────────────────── +function StatMinimal({ item, iconSvg }) { + return ( +
+ {iconSvg && ( +
+ +
+ )} + +

+ +

+

+ ); +} + +// ─── Main Stats Block ──────────────────────────────────────────────── +export const StatsBlock = ({ content, design, id, icon_svgs }) => { + const containerRef = useRef(null); + useCounterAnimation(containerRef); + + const title = content?.title; + const text = content?.text; + const items = Array.isArray(content?.items) ? content.items : []; + const layout = (design?.layout || "cards").toLowerCase(); + + const count = Math.max(1, Math.min(items.length, 4)); + const lgCols = gridColsClass(count); + const baseCols = count === 1 ? "grid-cols-1" : "grid-cols-2"; + + // icon_svgs is a dict keyed by icon name, provided by preact-wrapper + const iconMap = icon_svgs || {}; + + return ( +
+
+ {/* Section Header */} + {(title || text) && ( +
+ {title && ( +

+ )} + {text && ( +

+ )} +

+ )} + + {/* Cards Layout */} + {layout === "cards" && ( +
+ {items.map((item, idx) => ( + + ))} +
+ )} + + {/* Compact Layout */} + {layout === "compact" && ( +
+
+ {items.map((item, idx) => ( + + ))} +
+
+ )} + + {/* Minimal Layout */} + {layout === "minimal" && ( +
+ {items.map((item, idx) => ( + + ))} +
+ )} +
+ + {/* Animation styles (scoped via container ref) */} + +
+ ); +}; diff --git a/modules/blox/blox/testimonials/block.html b/modules/blox/blox/testimonials/block.html deleted file mode 100644 index 5ed4c357..00000000 --- a/modules/blox/blox/testimonials/block.html +++ /dev/null @@ -1,148 +0,0 @@ -{{/* Hugo Blox: Testimonials */}} -{{/* Documentation: https://hugoblox.com/blocks/ */}} -{{/* License: https://github.com/HugoBlox/kit/blob/main/LICENSE.md */}} - -{{/* Initialise */}} -{{ $page := .wcPage }} -{{ $block := .wcBlock }} -{{ $content := $block.content }} -{{ if not (reflect.IsMap $content) }}{{ $content = dict }}{{ end }} - -{{ $title := "" }} -{{ $title_raw := index $content "title" }} -{{ if $title_raw }} - {{ $title = strings.TrimSpace (printf "%v" $title_raw) }} -{{ end }} -{{ if $title }}{{ $title = $title | emojify | $page.RenderString }}{{ end }} - -{{ $text := "" }} -{{ $text_raw := index $content "text" }} -{{ if $text_raw }} - {{ $text = strings.TrimSpace (printf "%v" $text_raw) }} -{{ end }} -{{ if $text }}{{ $text = $text | emojify | $page.RenderString }}{{ end }} - -{{ $items_raw := index $content "items" }} -{{ $items_iter := slice }} -{{ if reflect.IsSlice $items_raw }} - {{ $items_iter = $items_raw }} -{{ else if and $items_raw (reflect.IsMap $items_raw) }} - {{ $items_iter = slice $items_raw }} -{{ end }} - -{{ $items := slice }} -{{ if gt (len $items_iter) 0 }} - {{ range $items_iter }} - {{ if reflect.IsMap . }} - {{ $item_text := "" }} - {{ $item_name := "" }} - {{ $item_role := "" }} - {{ $item_image := "" }} - {{ with index . "text" }}{{ $item_text = strings.TrimSpace (printf "%v" .) }}{{ end }} - {{ with index . "name" }}{{ $item_name = strings.TrimSpace (printf "%v" .) }}{{ end }} - {{ with index . "role" }}{{ $item_role = strings.TrimSpace (printf "%v" .) }}{{ end }} - {{ with index . "image" }}{{ $item_image = strings.TrimSpace (printf "%v" .) }}{{ end }} - {{ if or $item_text $item_name $item_role $item_image }} - {{ $items = $items | append (dict "text" $item_text "name" $item_name "role" $item_role "image" $item_image) }} - {{ end }} - {{ end }} - {{ end }} -{{ end }} - -
-
- - {{ with $title }} -
-
-

{{.}}

- {{ with $text }} -

{{.}}

- {{ end }} -
-
- {{ end }} - - {{ range $idx, $item := $items }} -
- - - -
-

"{{ (index $item "text") | $page.RenderString }}"

-
-
- {{/* Resolve avatar from page bundle, site assets, remote, or static URLs */}} - {{ $image_name := index $item "image" }} - {{/* Initialise as boolean to avoid nil command errors in pipeline */}} - {{ $image_resource := false }} - {{ $image_url := "" }} - - {{ if $image_name }} - {{ $image_path := strings.TrimSpace (printf "%v" $image_name) }} - {{ $is_remote := or (strings.HasPrefix $image_path "http://") (strings.HasPrefix $image_path "https://") }} - - {{ if $is_remote }} - {{ $remote := try (resources.GetRemote $image_path) }} - {{ if and $remote (not $remote.Err) }} - {{ $image_resource = $remote.Value }} - {{ else }} - {{/* If remote fetch fails, fall back to the original URL */}} - {{ $image_url = $image_path }} - {{ end }} - {{ else }} - {{/* Normalize common prefixes and dot paths */}} - {{ $image_path = strings.TrimPrefix "/" $image_path }} - {{ $image_path = strings.TrimPrefix "./" $image_path }} - {{ $image_path = strings.TrimPrefix "assets/" $image_path }} - {{ $image_path = strings.TrimPrefix "media/" $image_path }} - {{ $normalized := path.Clean $image_path }} - - {{ if and $normalized (ne $normalized ".") }} - {{/* Prefer page bundle image resources */}} - {{ with $page }} - {{ $image_resource = (.Resources.ByType "image").GetMatch $normalized }} - {{ end }} - - {{ if not $image_resource }} - {{/* Look in global assets/media - use resources.Get for exact path matching */}} - {{ $image_resource = resources.Get (path.Join "media" $normalized) }} - {{ end }} - - {{ if not $image_resource }} - {{/* Static fallback (serves original size) */}} - {{ $image_url = printf "/media/%s" $normalized }} - {{ end }} - {{ end }} - {{ end }} - {{ end }} - - {{ if $image_resource }} - {{ $avatar_96 := $image_resource.Process "Fill 96x96 Center webp" }} - {{ (index $item - {{ else if $image_url }} - {{ $is_external := or (strings.HasPrefix $image_url "http://") (strings.HasPrefix $image_url "https://") }} - {{ (index $item - {{ else }} -
- {{ end }} -
-
{{ index $item "name" }}
-
{{ index $item "role" }}
-
-
-
- {{ end }} - -
-
diff --git a/modules/blox/blox/testimonials/client.jsx b/modules/blox/blox/testimonials/client.jsx new file mode 100644 index 00000000..ee4181fc --- /dev/null +++ b/modules/blox/blox/testimonials/client.jsx @@ -0,0 +1,25 @@ +/** + * Testimonials Block - Client-side Hydration + */ + +import { render } from "preact"; +import { TestimonialsBlock } from "./component.jsx"; + +function renderTestimonialsBlocks() { + const blocks = document.querySelectorAll('[data-block-type="testimonials"]'); + + blocks.forEach((block) => { + const propsData = block.dataset.props; + if (propsData) { + try { + const props = JSON.parse(propsData); + render(, block); + console.debug(`✓ Testimonials block "${block.id}" rendered with Preact`); + } catch (error) { + console.error(`Failed to render Testimonials block "${block.id}":`, error); + } + } + }); +} + +renderTestimonialsBlocks(); diff --git a/modules/blox/blox/testimonials/component.jsx b/modules/blox/blox/testimonials/component.jsx new file mode 100644 index 00000000..3858cd68 --- /dev/null +++ b/modules/blox/blox/testimonials/component.jsx @@ -0,0 +1,108 @@ +/** + * Testimonials Block Component - Single source of truth + * Quote cards with avatar, name, and role + * Avatars are pre-processed by Hugo's image pipeline (passed as item_images prop) + */ + +// Quote mark SVG (inline for zero dependencies) +const QuoteSvg = () => ( + + + +); + +// Simple markdown renderer +function renderText(text) { + if (!text) return ""; + return String(text) + .replace(/\*\*(.*?)\*\*/g, "$1") + .replace(/\*(.*?)\*/g, "$1") + .replace(/`(.*?)`/g, "$1"); +} + +function TestimonialItem({ item, avatarUrl, isLast }) { + return ( +
+ +
+

+

+
+ {/* Avatar */} + {avatarUrl ? ( + {item.name + ) : ( +
+ )} +
+ {item.name && ( +
+ {item.name} +
+ )} + {item.role && ( +
+ {item.role} +
+ )} +
+
+
+ ); +} + +export const TestimonialsBlock = ({ content, design, id, item_images }) => { + const title = content?.title; + const text = content?.text; + const items = Array.isArray(content?.items) ? content.items : []; + const imageMap = item_images || {}; + + return ( +
+
+ {/* Section Header */} + {title && ( +
+
+

+ {text && ( +

+ )} +

+
+ )} + + {/* Testimonial Items */} + {items.map((item, idx) => ( + + ))} +
+
+ ); +}; diff --git a/modules/blox/layouts/_partials/blox/hero/wrapper.html b/modules/blox/layouts/_partials/blox/hero/wrapper.html deleted file mode 100644 index 39ff67f2..00000000 --- a/modules/blox/layouts/_partials/blox/hero/wrapper.html +++ /dev/null @@ -1,32 +0,0 @@ -{{/* Hugo Blox: Hero Block - Preact Wrapper */}} -{{/* Single source of truth - Preact component handles everything */}} -{{/* Documentation: https://hugoblox.com/blocks/ */}} -{{/* License: https://github.com/HugoBlox/kit/blob/main/LICENSE.md */}} - -{{/* Initialize variables */}} -{{ $page := .wcPage }} -{{ $block := .wcBlock }} -{{ $block_id := .wcIdentifier | default (printf "hero-%d" (now.Unix)) }} - -{{/* Prepare props for the Preact component */}} -{{ $props := dict - "content" $block.content - "design" $block.design - "id" $block_id -}} - -{{/* Convert props to JSON for client-side rendering */}} -{{ $propsJSON := $props | jsonify }} - -{{/* Render a container that Preact will populate */}} -{{/* The component will be rendered immediately on page load */}} -
- {{/* Empty - Preact renders everything */}} -
- -{{/* Preact script loading is handled by libraries.html */}} \ No newline at end of file diff --git a/modules/blox/layouts/_partials/blox/preact-wrapper.html b/modules/blox/layouts/_partials/blox/preact-wrapper.html index 61c8fac3..8257ed58 100644 --- a/modules/blox/layouts/_partials/blox/preact-wrapper.html +++ b/modules/blox/layouts/_partials/blox/preact-wrapper.html @@ -17,8 +17,7 @@ "type" $block_type }} -{{/* If an icon is specified, fetch its SVG data and add it to the props */}} -{{/* TODO: Generalize this to work with all block icon fields */}} +{{/* Resolve icon for primary_action (hero, cta blocks) */}} {{ with $block.content.primary_action.icon }} {{ $icon_data := partial "functions/get_icon_data" (dict "name" .) }} {{ if $icon_data }} @@ -26,12 +25,129 @@ {{ end }} {{ end }} +{{/* Resolve icon for single button (cta-card) */}} +{{ with $block.content.button.icon }} + {{ $icon_data := partial "functions/get_icon_data" (dict "name" .) }} + {{ if $icon_data }} + {{ $props = merge $props (dict "button_icon_svg" $icon_data) }} + {{ end }} +{{ end }} + +{{/* Resolve per-item icons (stats, features, etc.) into an icon_svgs map */}} +{{ $icon_svgs := dict }} +{{ with $block.content.items }} + {{ range . }} + {{ with .icon }} + {{ if not (index $icon_svgs .) }} + {{ $icon_data := partial "functions/get_icon_data" (dict "name" .) }} + {{ if $icon_data }} + {{ $icon_svgs = merge $icon_svgs (dict . $icon_data) }} + {{ end }} + {{ end }} + {{ end }} + {{ end }} +{{ end }} +{{ if gt (len $icon_svgs) 0 }} + {{ $props = merge $props (dict "icon_svgs" $icon_svgs) }} +{{ end }} + +{{/* Resolve per-item images via Hugo's image pipeline */}} +{{/* Thumbnail mode (testimonials): Fill 96x96 webp */}} +{{/* Responsive mode (cta-image-paragraph): srcset with breakpoints */}} +{{ $item_images := dict }} +{{ with $block.content.items }} + {{ range $idx, $item := . }} + {{ with .image }} + {{ $image_path := strings.TrimSpace (printf "%v" .) }} + {{ $is_remote := or (strings.HasPrefix $image_path "http://") (strings.HasPrefix $image_path "https://") }} + + {{/* Resolve the image resource */}} + {{ $image_resource := false }} + {{ $fallback_url := "" }} + + {{ if $is_remote }} + {{ $remote := try (resources.GetRemote $image_path) }} + {{ if and $remote (not $remote.Err) }} + {{ $image_resource = $remote.Value }} + {{ else }} + {{ $fallback_url = $image_path }} + {{ end }} + {{ else }} + {{ $normalized := $image_path }} + {{ $normalized = strings.TrimPrefix "/" $normalized }} + {{ $normalized = strings.TrimPrefix "./" $normalized }} + {{ $normalized = strings.TrimPrefix "assets/" $normalized }} + {{ $normalized = strings.TrimPrefix "media/" $normalized }} + {{ $normalized = path.Clean $normalized }} + + {{ if and $normalized (ne $normalized ".") }} + {{ with $page }} + {{ $image_resource = (.Resources.ByType "image").GetMatch $normalized }} + {{ end }} + {{ if not $image_resource }} + {{ $image_resource = resources.Get (path.Join "media" $normalized) }} + {{ end }} + {{ if not $image_resource }} + {{ $fallback_url = printf "/media/%s" $normalized }} + {{ end }} + {{ end }} + {{ end }} + + {{ $img_data := dict }} + {{ if $image_resource }} + {{ if eq $block_type "cta-image-paragraph" }} + {{/* Responsive mode: generate srcset via process_responsive_image */}} + {{ if ne $image_resource.MediaType.SubType "gif" }} + {{ $responsive := partial "functions/process_responsive_image.html" (dict + "image" $image_resource + "mode" "fit" + "sizes" (slice 400 600 800 1200) + ) }} + {{ $img_data = dict "srcset" $responsive.srcset "src" $responsive.fallback.RelPermalink "width" $responsive.fallback.Width "height" $responsive.fallback.Height }} + {{ else }} + {{ $img_data = dict "src" $image_resource.RelPermalink "width" $image_resource.Width "height" $image_resource.Height }} + {{ end }} + {{ else }} + {{/* Thumbnail mode (testimonials, etc.): Fill 96x96 */}} + {{ $processed := $image_resource.Process "Fill 96x96 Center webp" }} + {{ $img_data = dict "src" $processed.RelPermalink }} + {{ end }} + {{ else if $fallback_url }} + {{ $img_data = dict "src" $fallback_url }} + {{ end }} + + {{ if $img_data }} + {{ $item_images = merge $item_images (dict (printf "%d" $idx) $img_data) }} + {{ end }} + {{ end }} + {{ end }} +{{ end }} +{{ if gt (len $item_images) 0 }} + {{ $props = merge $props (dict "item_images" $item_images) }} +{{ end }} + +{{/* Resolve per-item feature_icon (cta-image-paragraph) */}} +{{ with $block.content.items }} + {{ range . }} + {{ with .feature_icon }} + {{ if not (index $icon_svgs .) }} + {{ $icon_data := partial "functions/get_icon_data" (dict "name" .) }} + {{ if $icon_data }} + {{ $icon_svgs = merge $icon_svgs (dict . $icon_data) }} + {{ end }} + {{ end }} + {{ end }} + {{ end }} +{{ end }} +{{ if gt (len $icon_svgs) 0 }} + {{ $props = merge $props (dict "icon_svgs" $icon_svgs) }} +{{ end }} + {{/* Convert props to JSON for client-side rendering */}} {{ $propsJSON := $props | jsonify }} {{/* Render a container that Preact will populate */}}