mirror of
https://github.com/tiennm99/HugoBlox-kit.git
synced 2026-10-05 10:13:39 +00:00
feat: convert remaining Landing Page blocks from Go HTML to React
This commit is contained in:
1 parent
266d092fac
commit
ef8a34e33a
26 files changed
+1088
-1063
No files matched your search
@@ -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 }}
|
||||
|
||||
<div class="relative overflow-hidden {{with $card_class}}{{.}}{{else}}bg-gradient-to-br from-primary-500/90 via-primary-600/95 to-primary-700/90{{end}} p-8 sm:p-12 lg:p-16 xl:p-20 mx-auto max-w-6xl rounded-3xl shadow-2xl flex flex-col items-center text-center"
|
||||
style="{{ $custom_style | safeCSS }}">
|
||||
|
||||
{{/* Modern Typography Hierarchy - Responsive text colors */}}
|
||||
{{ with $title }}
|
||||
<h2 class="{{ $text_classes }} text-4xl sm:text-5xl lg:text-6xl xl:text-7xl font-bold tracking-tight leading-tight">
|
||||
{{ . }}
|
||||
</h2>
|
||||
{{ end }}
|
||||
|
||||
{{/* Enhanced Text with Better Spacing - Responsive colors */}}
|
||||
{{ with $text }}
|
||||
<div class="{{ $text_classes }}/80 mt-6 text-lg sm:text-xl lg:text-2xl max-w-3xl leading-relaxed font-light">
|
||||
{{ . }}
|
||||
</div>
|
||||
{{ 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 */}}
|
||||
<div class="flex mt-10">
|
||||
{{ $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 */}}
|
||||
<a href="{{ $button_link | safeURL }}"
|
||||
{{ $button_target | safeHTMLAttr }}
|
||||
class="group relative inline-flex items-center gap-3 px-8 py-4 text-lg font-semibold {{ $button_classes }} transition-all duration-300 ease-out hover:scale-105 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-gray-500/50 dark:focus-visible:ring-white/50 focus-visible:ring-offset-2 focus-visible:ring-offset-transparent">
|
||||
|
||||
{{/* Button Background with Glassmorphism */}}
|
||||
<div class="absolute inset-0 bg-white/95 dark:bg-gray-900/95 backdrop-blur-md rounded-2xl ring-1 ring-gray-200/30 dark:ring-white/30 shadow-lg group-hover:bg-white dark:group-hover:bg-gray-800 group-hover:ring-gray-300/50 dark:group-hover:ring-white/50 group-hover:shadow-2xl transition-all duration-300"></div>
|
||||
|
||||
{{/* Button Content */}}
|
||||
<span class="relative z-10">{{ $button_text }}</span>
|
||||
{{ if $button_icon }}
|
||||
<span class="relative z-10 transition-transform duration-300 group-hover:translate-x-1">
|
||||
{{ partial "functions/get_icon" (dict "name" $button_icon "attributes" "style=\"height: 1.25em\" class='inline-block'") }}
|
||||
</span>
|
||||
{{ end }}
|
||||
|
||||
{{/* Subtle glow effect */}}
|
||||
<div class="absolute inset-0 bg-white/20 dark:bg-white/10 rounded-2xl opacity-0 group-hover:opacity-100 transition-opacity duration-300 blur-xl"></div>
|
||||
</a>
|
||||
</div>
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
|
||||
</div>
|
||||
@@ -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(<CtaCardBlock {...props} />, 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();
|
||||
}
|
||||
@@ -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, "<strong>$1</strong>")
|
||||
.replace(/\*(.*?)\*/g, "<em>$1</em>")
|
||||
.replace(/`(.*?)`/g, "<code>$1</code>")
|
||||
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2">$1</a>');
|
||||
}
|
||||
|
||||
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 (
|
||||
<div
|
||||
class={`relative overflow-hidden ${finalCardClass} p-8 sm:p-12 lg:p-16 xl:p-20 mx-auto max-w-6xl rounded-3xl shadow-2xl flex flex-col items-center text-center`}
|
||||
style={style}
|
||||
>
|
||||
{/* Title */}
|
||||
{content.title && (
|
||||
<h2
|
||||
class={`${textClasses} text-4xl sm:text-5xl lg:text-6xl xl:text-7xl font-bold tracking-tight leading-tight`}
|
||||
dangerouslySetInnerHTML={{ __html: renderText(content.title) }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Text */}
|
||||
{content.text && (
|
||||
<div
|
||||
class={`${textClasses}/80 mt-6 text-lg sm:text-xl lg:text-2xl max-w-3xl leading-relaxed font-light`}
|
||||
dangerouslySetInnerHTML={{ __html: renderText(content.text) }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Button */}
|
||||
{showButton && (
|
||||
<div class="flex mt-10">
|
||||
<a
|
||||
href={buttonUrl}
|
||||
target={isNewTab ? "_blank" : undefined}
|
||||
rel={isNewTab ? "noopener" : undefined}
|
||||
class={`group relative inline-flex items-center gap-3 px-8 py-4 text-lg font-semibold ${buttonClasses} transition-all duration-300 ease-out hover:scale-105 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-gray-500/50 dark:focus-visible:ring-white/50 focus-visible:ring-offset-2 focus-visible:ring-offset-transparent`}
|
||||
>
|
||||
{/* Glassmorphism Background */}
|
||||
<div class="absolute inset-0 bg-white/95 dark:bg-gray-900/95 backdrop-blur-md rounded-2xl ring-1 ring-gray-200/30 dark:ring-white/30 shadow-lg group-hover:bg-white dark:group-hover:bg-gray-800 group-hover:ring-gray-300/50 dark:group-hover:ring-white/50 group-hover:shadow-2xl transition-all duration-300"></div>
|
||||
|
||||
{/* Button Content */}
|
||||
<span class="relative z-10">{buttonText}</span>
|
||||
{button_icon_svg && (
|
||||
<span class="relative z-10 transition-transform duration-300 group-hover:translate-x-1">
|
||||
<Icon svg={button_icon_svg} attributes={{ style: "height: 1.25em", class: "inline-block" }} />
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Glow Effect */}
|
||||
<div class="absolute inset-0 bg-white/20 dark:bg-white/10 rounded-2xl opacity-0 group-hover:opacity-100 transition-opacity duration-300 blur-xl"></div>
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -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 }}
|
||||
|
||||
<div>
|
||||
{{ $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 }}
|
||||
<div class="gap-8 items-center py-8 px-4 mx-auto max-w-screen-xl xl:gap-16 {{ if or $image_to_render $image_url }}md:grid md:grid-cols-2{{ end }} sm:py-16 lg:px-6">
|
||||
{{ 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)
|
||||
) }}
|
||||
<img class="w-full"
|
||||
srcset="{{ $responsive.srcset }}"
|
||||
sizes="(max-width: 768px) 100vw, 50vw"
|
||||
src="{{ $responsive.fallback.RelPermalink }}"
|
||||
width="{{ $responsive.fallback.Width }}"
|
||||
height="{{ $responsive.fallback.Height }}"
|
||||
alt="{{$title | plainify}}" {{ if (modBool $idx 2)}}style="order: 1"{{end}}>
|
||||
{{ else }}
|
||||
<img class="w-full"
|
||||
src="{{$image_to_render.RelPermalink}}"
|
||||
width="{{$image_to_render.Width}}"
|
||||
height="{{$image_to_render.Height}}"
|
||||
alt="{{$title | plainify}}" {{ if (modBool $idx 2)}}style="order: 1"{{end}}>
|
||||
{{ end }}
|
||||
{{ else if $image_url }}
|
||||
{{ $is_external := or (strings.HasPrefix $image_url "http://") (strings.HasPrefix $image_url "https://") }}
|
||||
<img class="w-full"
|
||||
src="{{ if $is_external }}{{ $image_url }}{{ else }}{{ $image_url | relURL }}{{ end }}"
|
||||
alt="{{$title | plainify}}" {{ if (modBool $idx 2)}}style="order: 1"{{end}}>
|
||||
{{ 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 }}
|
||||
<div class="mt-4 md:mt-0">
|
||||
<h2 class="mb-4 text-4xl tracking-tight font-extrabold text-gray-900 dark:text-white">{{$title}}</h2>
|
||||
<p class="mb-6 font-light text-gray-500 md:text-lg dark:text-gray-400">{{$text}}</p>
|
||||
{{ $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 }}
|
||||
<ul>
|
||||
{{ range $features }}
|
||||
<li class="relative mb-4 pl-6">
|
||||
{{ partial "functions/get_icon" (dict "name" $icon "attributes" `class="inline-block pr-3" style="height: 1em;"`) }}
|
||||
{{ printf "%v" . | markdownify }}
|
||||
</li>
|
||||
{{ end }}
|
||||
</ul>
|
||||
{{ 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 }}
|
||||
<a href="{{ $link | safeURL }}" {{ $target | safeHTMLAttr }} class="mt-3 inline-flex items-center text-white bg-primary-700 hover:bg-primary-800 focus:ring-4 focus:ring-primary-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center dark:focus:ring-primary-900">
|
||||
{{ $button_text }}
|
||||
<svg class="ml-2 -mr-1 w-5 h-5" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M10.293 3.293a1 1 0 011.414 0l6 6a1 1 0 010 1.414l-6 6a1 1 0 01-1.414-1.414L14.586 11H3a1 1 0 110-2h11.586l-4.293-4.293a1 1 0 010-1.414z" clip-rule="evenodd"></path></svg>
|
||||
</a>
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
</div>
|
||||
</div>
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
</div>
|
||||
@@ -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(<CtaImageParagraphBlock {...props} />, 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();
|
||||
@@ -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, "<strong>$1</strong>")
|
||||
.replace(/\*(.*?)\*/g, "<em>$1</em>")
|
||||
.replace(/`(.*?)`/g, "<code>$1</code>")
|
||||
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2">$1</a>');
|
||||
}
|
||||
|
||||
// Arrow icon for CTA buttons
|
||||
const ArrowIcon = () => (
|
||||
<svg class="ml-2 -mr-1 w-5 h-5" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" d="M10.293 3.293a1 1 0 011.414 0l6 6a1 1 0 010 1.414l-6 6a1 1 0 01-1.414-1.414L14.586 11H3a1 1 0 110-2h11.586l-4.293-4.293a1 1 0 010-1.414z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
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 (
|
||||
<div class={`gap-8 items-center py-8 px-4 mx-auto max-w-screen-xl xl:gap-16 ${hasImage ? "md:grid md:grid-cols-2" : ""} sm:py-16 lg:px-6`}>
|
||||
{/* Image */}
|
||||
{hasImage && (
|
||||
imageData.srcset ? (
|
||||
<img
|
||||
class="w-full"
|
||||
srcset={imageData.srcset}
|
||||
sizes="(max-width: 768px) 100vw, 50vw"
|
||||
src={imageData.src}
|
||||
width={imageData.width}
|
||||
height={imageData.height}
|
||||
alt={item.title || ""}
|
||||
style={isReversed ? { order: 1 } : undefined}
|
||||
/>
|
||||
) : (
|
||||
<img
|
||||
class="w-full"
|
||||
src={imageData.src}
|
||||
width={imageData.width}
|
||||
height={imageData.height}
|
||||
alt={item.title || ""}
|
||||
style={isReversed ? { order: 1 } : undefined}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
|
||||
{/* Text Content */}
|
||||
<div class="mt-4 md:mt-0">
|
||||
{item.title && (
|
||||
<h2
|
||||
class="mb-4 text-4xl tracking-tight font-extrabold text-gray-900 dark:text-white"
|
||||
dangerouslySetInnerHTML={{ __html: renderText(item.title) }}
|
||||
/>
|
||||
)}
|
||||
{item.text && (
|
||||
<p
|
||||
class="mb-6 font-light text-gray-500 md:text-lg dark:text-gray-400"
|
||||
dangerouslySetInnerHTML={{ __html: renderText(item.text) }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Feature List */}
|
||||
{features.length > 0 && (
|
||||
<ul>
|
||||
{features.map((feature, fIdx) => (
|
||||
<li key={fIdx} class="relative mb-4 pl-6">
|
||||
{featureIconSvg ? (
|
||||
<Icon svg={featureIconSvg} attributes={{ class: "inline-block pr-5", style: "height: 1em;" }} />
|
||||
) : null}
|
||||
<span dangerouslySetInnerHTML={{ __html: renderText(String(feature)) }} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{/* CTA Button */}
|
||||
{item.button?.text && item.button?.url && (
|
||||
<a
|
||||
href={buttonUrl}
|
||||
target={isExternal ? "_blank" : undefined}
|
||||
rel={isExternal ? "noopener" : undefined}
|
||||
class="mt-3 inline-flex items-center text-white bg-primary-700 hover:bg-primary-800 focus:ring-4 focus:ring-primary-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center dark:focus:ring-primary-900"
|
||||
>
|
||||
{item.button.text}
|
||||
<ArrowIcon />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div>
|
||||
{items.map((item, idx) => (
|
||||
<CtaItem
|
||||
key={idx}
|
||||
item={item}
|
||||
idx={idx}
|
||||
imageData={imageMap[String(idx)] || null}
|
||||
featureIconSvg={item.feature_icon ? iconMap[item.feature_icon] : iconMap["check"]}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -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 }}
|
||||
|
||||
<section>
|
||||
<div class="py-8 px-4 mx-auto max-w-screen-xl sm:py-16 lg:px-6">
|
||||
<div class="max-w-screen-md mb-8 lg:mb-16 flex flex-col justify-center mx-auto">
|
||||
{{ with $title }}
|
||||
<h2 class="mb-4 text-4xl tracking-tight font-extrabold text-gray-900 dark:text-white text-center">{{.}}</h2>
|
||||
{{ end }}
|
||||
{{ with $text }}
|
||||
<p class="text-gray-500 sm:text-xl dark:text-gray-400 text-center">{{.}}</p>
|
||||
{{ end }}
|
||||
</div>
|
||||
<div class="space-y-8 md:grid md:grid-cols-2 lg:grid-cols-3 md:gap-12 md:space-y-0">
|
||||
{{ 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 }}
|
||||
<div>
|
||||
{{ with $icon }}
|
||||
<div class="flex justify-center items-center mb-4 w-10 h-10 rounded-full bg-primary-100 lg:h-12 lg:w-12 dark:bg-primary-900 text-primary-600 dark:text-primary-300">
|
||||
{{ partial "functions/get_icon" (dict "name" . "attributes" `class="w-5 h-5 lg:w-6 lg:h-6"`) }}
|
||||
</div>
|
||||
{{ end }}
|
||||
{{ with $name }}<h3 class="mb-2 text-xl font-bold dark:text-white">{{ . | $page.RenderString }}</h3>{{ end }}
|
||||
{{ with $description }}<p class="text-gray-500 dark:text-gray-400">{{ . | $page.RenderString }}</p>{{ end }}
|
||||
</div>
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -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(<FeaturesBlock {...props} />, block);
|
||||
console.debug(`✓ Features block "${block.id}" rendered with Preact`);
|
||||
} catch (error) {
|
||||
console.error(`Failed to render Features block "${block.id}":`, error);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
renderFeaturesBlocks();
|
||||
@@ -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, "<strong>$1</strong>")
|
||||
.replace(/\*(.*?)\*/g, "<em>$1</em>")
|
||||
.replace(/`(.*?)`/g, "<code>$1</code>");
|
||||
}
|
||||
|
||||
function FeatureItem({ item, iconSvg }) {
|
||||
return (
|
||||
<div>
|
||||
{iconSvg && (
|
||||
<div class="flex justify-center items-center mb-4 w-10 h-10 rounded-full bg-primary-100 lg:h-12 lg:w-12 dark:bg-primary-900 text-primary-600 dark:text-primary-300">
|
||||
<Icon svg={iconSvg} attributes={{ class: "w-5 h-5 lg:w-6 lg:h-6" }} />
|
||||
</div>
|
||||
)}
|
||||
{item.name && (
|
||||
<h3
|
||||
class="mb-2 text-xl font-bold dark:text-white"
|
||||
dangerouslySetInnerHTML={{ __html: renderText(item.name) }}
|
||||
/>
|
||||
)}
|
||||
{item.description && (
|
||||
<p
|
||||
class="text-gray-500 dark:text-gray-400"
|
||||
dangerouslySetInnerHTML={{ __html: renderText(item.description) }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
<div class="py-8 px-4 mx-auto max-w-screen-xl sm:py-16 lg:px-6">
|
||||
{/* Section Header */}
|
||||
{(title || text) && (
|
||||
<div class="max-w-screen-md mb-8 lg:mb-16 flex flex-col justify-center mx-auto">
|
||||
{title && (
|
||||
<h2
|
||||
class="mb-4 text-4xl tracking-tight font-extrabold text-gray-900 dark:text-white text-center"
|
||||
dangerouslySetInnerHTML={{ __html: renderText(title) }}
|
||||
/>
|
||||
)}
|
||||
{text && (
|
||||
<p
|
||||
class="text-gray-500 sm:text-xl dark:text-gray-400 text-center"
|
||||
dangerouslySetInnerHTML={{ __html: renderText(text) }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Feature Grid */}
|
||||
<div class="space-y-8 md:grid md:grid-cols-2 lg:grid-cols-3 md:gap-12 md:space-y-0">
|
||||
{items.map((item, idx) => (
|
||||
<FeatureItem
|
||||
key={idx}
|
||||
item={item}
|
||||
iconSvg={item.icon ? iconMap[item.icon] : null}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
@@ -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 }}
|
||||
<div class="relative isolate px-6 pt-14 lg:px-8">
|
||||
|
||||
<div class="mx-auto max-w-2xl {{if not $no_padding }}py-32 sm:py-48 lg:py-56{{end}}">
|
||||
|
||||
{{ if $announcement_text }}
|
||||
<div class="hidden sm:mb-8 sm:flex sm:justify-center">
|
||||
<div class="relative rounded-full px-3 py-1 text-sm leading-6 text-gray-600 dark:text-gray-300 ring-1 ring-gray-900/10 dark:ring-gray-300 hover:ring-gray-900/20 dark:hover:ring-gray-400">
|
||||
{{ $announcement_text | markdownify }}
|
||||
{{ if and $announcement_link_text $announcement_link_url }}
|
||||
{{ $link := $announcement_link_url }}
|
||||
{{ $scheme := (urls.Parse $link).Scheme }}
|
||||
{{ if not $scheme }}
|
||||
{{ if not (hasPrefix $link "#") }}
|
||||
{{ $link = $link | relLangURL }}
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
<a href="{{ $link | safeURL }}" class="font-semibold text-primary-600 dark:text-primary-300">
|
||||
<span class="absolute inset-0" aria-hidden="true"></span>{{ $announcement_link_text }} <span aria-hidden="true">→</span>
|
||||
</a>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<div class="text-center">
|
||||
{{ with index $content "title" }}<h1 class="text-4xl font-bold tracking-tight text-gray-900 dark:text-gray-100 sm:text-6xl">{{ printf "%v" . | markdownify }}</h1>{{end}}
|
||||
{{ with index $content "text" }}<p class="mt-6 text-lg leading-8 text-gray-600 dark:text-gray-300 max-w-2xl mx-auto">{{ printf "%v" . | $page.RenderString | emojify }}</p>{{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 }}
|
||||
<div class="mt-10 flex items-center justify-center gap-x-6">
|
||||
<a href="{{ $link | safeURL }}" {{ $target | safeHTMLAttr }} class="rounded-md bg-primary-600 px-3.5 py-2.5 text-sm font-semibold text-white shadow-sm hover:bg-primary-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary-600">
|
||||
{{ $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 }}
|
||||
</a>
|
||||
{{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 }}
|
||||
<a href="{{ $link_alt | safeURL }}" {{ $target_alt | safeHTMLAttr }} class="text-sm font-semibold leading-6 text-gray-900 dark:text-gray-100 hover:dark:text-gray-200 hover:text-gray-800">{{ $secondary_text | markdownify | emojify }} <span aria-hidden="true">→</span></a>
|
||||
{{end}}
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
@@ -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 (
|
||||
<div class="relative isolate px-6 pt-14 lg:px-8" id={id}>
|
||||
<div class="relative isolate px-6 pt-14 lg:px-8">
|
||||
<div class={`mx-auto max-w-2xl ${paddingClasses}`}>
|
||||
{/* Announcement Banner */}
|
||||
{content.announcement?.text && (
|
||||
@@ -73,7 +73,7 @@ export const HeroBlock = ({content, design, id, icon_svg}) => {
|
||||
{content.title && (
|
||||
<h1
|
||||
class="text-4xl font-bold tracking-tight text-gray-900 dark:text-gray-100 sm:text-6xl"
|
||||
dangerouslySetInnerHTML={{__html: renderText(content.title)}}
|
||||
dangerouslySetInnerHTML={{ __html: renderText(content.title) }}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -81,7 +81,7 @@ export const HeroBlock = ({content, design, id, icon_svg}) => {
|
||||
{content.text && (
|
||||
<p
|
||||
class="mt-6 text-lg leading-8 text-gray-600 dark:text-gray-300 max-w-2xl mx-auto"
|
||||
dangerouslySetInnerHTML={{__html: renderText(content.text)}}
|
||||
dangerouslySetInnerHTML={{ __html: renderText(content.text) }}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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(/<svg\b/i, '<svg class="inline-block w-4 h-4"');
|
||||
}
|
||||
|
||||
const wrapperProps = {
|
||||
...attributes,
|
||||
class: `inline-block ${attributes?.class || ""}`.trim(),
|
||||
};
|
||||
|
||||
// eslint-disable-next-line lint/security/noDangerouslySetInnerHtml
|
||||
return <span class="inline-block" dangerouslySetInnerHTML={{__html: decoded}} />;
|
||||
return <span {...wrapperProps} dangerouslySetInnerHTML={{ __html: decoded }} />;
|
||||
}
|
||||
|
||||
const finalAttributes = {
|
||||
@@ -44,5 +49,5 @@ export const Icon = ({svg, attributes}) => {
|
||||
.join(" ");
|
||||
|
||||
// eslint-disable-next-line lint/security/noDangerouslySetInnerHtml
|
||||
return <span class="inline-block" dangerouslySetInnerHTML={{__html: `<svg ${attrs}>${decoded}</svg>`}} />;
|
||||
return <span class="inline-block" dangerouslySetInnerHTML={{ __html: `<svg ${attrs}>${decoded}</svg>` }} />;
|
||||
};
|
||||
@@ -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 }}
|
||||
|
||||
<div class="py-12 sm:py-16 lg:py-20">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
|
||||
{{/* Section Header */}}
|
||||
{{ if or $title $text }}
|
||||
<div class="text-center mb-12 lg:mb-16">
|
||||
{{ with $title }}
|
||||
<h2 class="text-3xl sm:text-4xl font-bold text-gray-900 dark:text-white tracking-tight mb-4">
|
||||
{{ . }}
|
||||
</h2>
|
||||
{{ end }}
|
||||
{{ with $text }}
|
||||
<p class="text-lg text-gray-600 dark:text-gray-400 max-w-3xl mx-auto">
|
||||
{{ . }}
|
||||
</p>
|
||||
{{ end }}
|
||||
</div>
|
||||
{{ end }}
|
||||
|
||||
{{/* Cards Layout */}}
|
||||
{{ if eq $layout "cards" }}
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 {{ $grid_cols_class }} gap-6 lg:gap-8">
|
||||
{{ range $idx, $item := $items }}
|
||||
<div class="group relative bg-white dark:bg-gray-800 rounded-2xl shadow-lg hover:shadow-2xl transition-all duration-500 overflow-hidden border border-gray-100 dark:border-gray-700 hover:border-primary-200 dark:hover:border-primary-800">
|
||||
|
||||
{{/* Background Gradient */}}
|
||||
<div class="absolute inset-0 bg-gradient-to-br from-primary-50 to-transparent dark:from-primary-900/20 dark:to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-500"></div>
|
||||
|
||||
<div class="relative p-8 text-center">
|
||||
{{/* Icon */}}
|
||||
{{ $icon := index $item "icon" }}
|
||||
{{ if $icon }}
|
||||
<div class="inline-flex items-center justify-center w-16 h-16 mb-6 bg-primary-100 dark:bg-primary-900/50 rounded-xl group-hover:scale-110 transition-transform duration-300">
|
||||
{{ partial "functions/get_icon" (dict "name" $icon "attributes" "class=\"w-8 h-8 text-primary-600 dark:text-primary-400\"") }}
|
||||
</div>
|
||||
{{ end }}
|
||||
|
||||
{{/* Statistic */}}
|
||||
<div class="mb-3">
|
||||
{{ $statistic := index $item "statistic" }}
|
||||
<h3 class="text-4xl sm:text-5xl lg:text-6xl xl:text-7xl font-black text-gray-900 dark:text-white group-hover:text-primary-600 dark:group-hover:text-primary-400 transition-colors duration-300 counter" data-target="{{ $statistic | replaceRE `[^0-9.]` "" }}">
|
||||
{{ $statistic | $page.RenderString }}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{{/* Description */}}
|
||||
<p class="text-sm sm:text-base font-medium text-gray-600 dark:text-gray-300 leading-relaxed group-hover:text-gray-700 dark:group-hover:text-gray-200 transition-colors duration-300">
|
||||
{{ (index $item "description") | $page.RenderString }}
|
||||
</p>
|
||||
|
||||
{{/* Optional Sub-metric */}}
|
||||
{{ with index $item "sub_metric" }}
|
||||
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ . }}
|
||||
</p>
|
||||
{{ end }}
|
||||
</div>
|
||||
</div>
|
||||
{{ end }}
|
||||
</div>
|
||||
|
||||
{{/* Compact Layout */}}
|
||||
{{ else if eq $layout "compact" }}
|
||||
<div class="bg-white dark:bg-gray-800 rounded-2xl shadow-xl border border-gray-100 dark:border-gray-700 overflow-hidden">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 {{ $grid_cols_class }} divide-y sm:divide-y-0 sm:divide-x divide-gray-200 dark:divide-gray-700">
|
||||
{{ range $idx, $item := $items }}
|
||||
<div class="group p-8 text-center hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors duration-300">
|
||||
{{ $icon := index $item "icon" }}
|
||||
{{ if $icon }}
|
||||
<div class="inline-flex items-center justify-center w-12 h-12 mb-4 bg-primary-100 dark:bg-primary-900/50 rounded-lg group-hover:scale-110 transition-transform duration-300">
|
||||
{{ partial "functions/get_icon" (dict "name" $icon "attributes" "class=\"w-6 h-6 text-primary-600 dark:text-primary-400\"") }}
|
||||
</div>
|
||||
{{ end }}
|
||||
|
||||
{{ $statistic := index $item "statistic" }}
|
||||
<h3 class="text-3xl sm:text-4xl lg:text-5xl font-bold text-gray-900 dark:text-white mb-2 counter" data-target="{{ $statistic | replaceRE `[^0-9.]` "" }}">
|
||||
{{ $statistic | $page.RenderString }}
|
||||
</h3>
|
||||
|
||||
<p class="text-sm font-medium text-gray-600 dark:text-gray-300 leading-relaxed">
|
||||
{{ (index $item "description") | $page.RenderString }}
|
||||
</p>
|
||||
</div>
|
||||
{{ end }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{/* Minimal Layout */}}
|
||||
{{ else }}
|
||||
<div class="grid {{ $base_cols_class }} {{ $grid_cols_class }} gap-8 lg:gap-12">
|
||||
{{ range $item := $items }}
|
||||
<div class="text-center group">
|
||||
{{ $icon := index $item "icon" }}
|
||||
{{ if $icon }}
|
||||
<div class="inline-flex items-center justify-center w-14 h-14 mb-4 bg-primary-100 dark:bg-primary-900/50 rounded-full group-hover:scale-110 transition-transform duration-300">
|
||||
{{ partial "functions/get_icon" (dict "name" $icon "attributes" "class=\"w-7 h-7 text-primary-600 dark:text-primary-400\"") }}
|
||||
</div>
|
||||
{{ end }}
|
||||
|
||||
{{ $statistic := index $item "statistic" }}
|
||||
<h3 class="text-3xl sm:text-4xl lg:text-5xl font-bold text-gray-900 dark:text-white mb-2 counter" data-target="{{ $statistic | replaceRE `[^0-9.]` "" }}">
|
||||
{{ $statistic | $page.RenderString }}
|
||||
</h3>
|
||||
|
||||
<p class="text-sm font-medium text-gray-600 dark:text-gray-300">
|
||||
{{ (index $item "description") | $page.RenderString }}
|
||||
</p>
|
||||
</div>
|
||||
{{ end }}
|
||||
</div>
|
||||
{{ end }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{/* Enhanced Animation CSS */}}
|
||||
<style>
|
||||
@keyframes fadeInUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(30px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse-glow {
|
||||
0%, 100% {
|
||||
box-shadow: 0 0 0 0 rgb(59 130 246 / 0.4);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 0 10px rgb(59 130 246 / 0);
|
||||
}
|
||||
}
|
||||
|
||||
/* Intersection Observer Animation */
|
||||
.stats-animate {
|
||||
animation: fadeInUp 0.8s ease-out forwards;
|
||||
}
|
||||
|
||||
.stats-item {
|
||||
opacity: 0;
|
||||
transform: translateY(30px);
|
||||
transition: all 0.6s ease-out;
|
||||
}
|
||||
|
||||
.stats-item.animate {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
/* Stagger animation delays */
|
||||
.stats-item:nth-child(1) { transition-delay: 0ms; }
|
||||
.stats-item:nth-child(2) { transition-delay: 150ms; }
|
||||
.stats-item:nth-child(3) { transition-delay: 300ms; }
|
||||
.stats-item:nth-child(4) { transition-delay: 450ms; }
|
||||
.stats-item:nth-child(5) { transition-delay: 600ms; }
|
||||
|
||||
/* Number counting animation */
|
||||
@keyframes countUp {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
.counter-animate {
|
||||
animation: countUp 0.6s ease-out;
|
||||
}
|
||||
|
||||
/* Hover glow effect */
|
||||
.stats-card-glow:hover {
|
||||
animation: pulse-glow 2s infinite;
|
||||
}
|
||||
</style>
|
||||
|
||||
{{/* Animation JavaScript for Counter and Intersection Observer */}}
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
|
||||
function parseTarget(counter) {
|
||||
const dataTarget = counter.getAttribute('data-target');
|
||||
const fallbackMatch = counter.textContent.match(/[0-9,.]+/);
|
||||
const raw = (dataTarget ?? (fallbackMatch ? fallbackMatch[0] : '0')).replace(/,/g, '');
|
||||
const num = Number(raw);
|
||||
return isNaN(num) ? 0 : num;
|
||||
}
|
||||
|
||||
function extractParts(text) {
|
||||
const parts = text.match(/([^0-9]*)([0-9][0-9,.]*)(.*)/);
|
||||
return {
|
||||
prefix: parts ? parts[1] : '',
|
||||
numberPart: parts ? parts[2] : '',
|
||||
suffix: parts ? parts[3] : ''
|
||||
};
|
||||
}
|
||||
|
||||
function animateCountersFor(container) {
|
||||
container.classList.add('animate');
|
||||
|
||||
const counters = container.querySelectorAll('.counter');
|
||||
counters.forEach(counter => {
|
||||
const target = parseTarget(counter);
|
||||
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; // 2 seconds
|
||||
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); // Ease-out cubic
|
||||
const current = target * easeOut;
|
||||
counter.textContent = `${prefix}${formatter.format(current)}${suffix}`;
|
||||
if (progress < 1) requestAnimationFrame(update);
|
||||
}
|
||||
|
||||
requestAnimationFrame(update);
|
||||
});
|
||||
}
|
||||
|
||||
function isInView(el) {
|
||||
const rect = el.getBoundingClientRect();
|
||||
const vh = window.innerHeight || document.documentElement.clientHeight;
|
||||
const vw = window.innerWidth || document.documentElement.clientWidth;
|
||||
return rect.top < vh * 0.9 && rect.bottom > 0 && rect.left < vw && rect.right > 0;
|
||||
}
|
||||
|
||||
// Intersection Observer for stats animation (with near-fold sensitivity)
|
||||
const statsObserver = new IntersectionObserver((entries) => {
|
||||
entries.forEach(entry => {
|
||||
if (entry.isIntersecting) {
|
||||
animateCountersFor(entry.target);
|
||||
statsObserver.unobserve(entry.target);
|
||||
}
|
||||
});
|
||||
}, {
|
||||
threshold: 0.2,
|
||||
rootMargin: '0px 0px -10% 0px'
|
||||
});
|
||||
|
||||
// Observe all stats containers (and trigger immediately if already visible)
|
||||
const statsContainers = document.querySelectorAll('.stats-item, [class*="grid"] > div');
|
||||
statsContainers.forEach(container => {
|
||||
container.classList.add('stats-item');
|
||||
if (isInView(container)) {
|
||||
animateCountersFor(container);
|
||||
} else {
|
||||
statsObserver.observe(container);
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
@@ -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(<StatsBlock {...props} />, 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();
|
||||
@@ -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, "<strong>$1</strong>")
|
||||
.replace(/\*(.*?)\*/g, "<em>$1</em>")
|
||||
.replace(/`(.*?)`/g, "<code>$1</code>");
|
||||
}
|
||||
|
||||
// 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 (
|
||||
<div class="stats-item group relative bg-white dark:bg-gray-800 rounded-2xl shadow-lg hover:shadow-2xl transition-all duration-500 overflow-hidden border border-gray-100 dark:border-gray-700 hover:border-primary-200 dark:hover:border-primary-800">
|
||||
{/* Hover gradient */}
|
||||
<div class="absolute inset-0 bg-gradient-to-br from-primary-50 to-transparent dark:from-primary-900/20 dark:to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-500" />
|
||||
|
||||
<div class="relative p-8 text-center">
|
||||
{/* Icon */}
|
||||
{iconSvg && (
|
||||
<div class="inline-flex items-center justify-center w-16 h-16 mb-6 bg-primary-100 dark:bg-primary-900/50 rounded-xl group-hover:scale-110 transition-transform duration-300">
|
||||
<Icon svg={iconSvg} attributes={{ class: "w-8 h-8 text-primary-600 dark:text-primary-400" }} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Statistic */}
|
||||
<div class="mb-3">
|
||||
<h3
|
||||
class="text-4xl sm:text-5xl lg:text-6xl xl:text-7xl font-black text-gray-900 dark:text-white group-hover:text-primary-600 dark:group-hover:text-primary-400 transition-colors duration-300 counter"
|
||||
data-target={parseTarget(item.statistic)}
|
||||
dangerouslySetInnerHTML={{ __html: renderText(item.statistic) }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<p
|
||||
class="text-sm sm:text-base font-medium text-gray-600 dark:text-gray-300 leading-relaxed group-hover:text-gray-700 dark:group-hover:text-gray-200 transition-colors duration-300"
|
||||
dangerouslySetInnerHTML={{ __html: renderText(item.description) }}
|
||||
/>
|
||||
|
||||
{/* Sub-metric */}
|
||||
{item.sub_metric && (
|
||||
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
{item.sub_metric}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Stat Item (Compact) ─────────────────────────────────────────────
|
||||
function StatCompact({ item, iconSvg }) {
|
||||
return (
|
||||
<div class="stats-item group p-8 text-center hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors duration-300">
|
||||
{iconSvg && (
|
||||
<div class="inline-flex items-center justify-center w-12 h-12 mb-4 bg-primary-100 dark:bg-primary-900/50 rounded-lg group-hover:scale-110 transition-transform duration-300">
|
||||
<Icon svg={iconSvg} attributes={{ class: "w-6 h-6 text-primary-600 dark:text-primary-400" }} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<h3
|
||||
class="text-3xl sm:text-4xl lg:text-5xl font-bold text-gray-900 dark:text-white mb-2 counter"
|
||||
data-target={parseTarget(item.statistic)}
|
||||
dangerouslySetInnerHTML={{ __html: renderText(item.statistic) }}
|
||||
/>
|
||||
|
||||
<p
|
||||
class="text-sm font-medium text-gray-600 dark:text-gray-300 leading-relaxed"
|
||||
dangerouslySetInnerHTML={{ __html: renderText(item.description) }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Stat Item (Minimal) ─────────────────────────────────────────────
|
||||
function StatMinimal({ item, iconSvg }) {
|
||||
return (
|
||||
<div class="stats-item text-center group">
|
||||
{iconSvg && (
|
||||
<div class="inline-flex items-center justify-center w-14 h-14 mb-4 bg-primary-100 dark:bg-primary-900/50 rounded-full group-hover:scale-110 transition-transform duration-300">
|
||||
<Icon svg={iconSvg} attributes={{ class: "w-7 h-7 text-primary-600 dark:text-primary-400" }} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<h3
|
||||
class="text-3xl sm:text-4xl lg:text-5xl font-bold text-gray-900 dark:text-white mb-2 counter"
|
||||
data-target={parseTarget(item.statistic)}
|
||||
dangerouslySetInnerHTML={{ __html: renderText(item.statistic) }}
|
||||
/>
|
||||
|
||||
<p
|
||||
class="text-sm font-medium text-gray-600 dark:text-gray-300"
|
||||
dangerouslySetInnerHTML={{ __html: renderText(item.description) }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 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 (
|
||||
<div class="py-8 sm:py-12 lg:py-16" ref={containerRef}>
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
{/* Section Header */}
|
||||
{(title || text) && (
|
||||
<div class="text-center mb-12 lg:mb-16">
|
||||
{title && (
|
||||
<h2
|
||||
class="text-3xl sm:text-4xl font-bold text-gray-900 dark:text-white tracking-tight mb-4"
|
||||
dangerouslySetInnerHTML={{ __html: renderText(title) }}
|
||||
/>
|
||||
)}
|
||||
{text && (
|
||||
<p
|
||||
class="text-lg text-gray-600 dark:text-gray-400 max-w-3xl mx-auto"
|
||||
dangerouslySetInnerHTML={{ __html: renderText(text) }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Cards Layout */}
|
||||
{layout === "cards" && (
|
||||
<div class={`grid grid-cols-1 sm:grid-cols-2 ${lgCols} gap-6 lg:gap-8`}>
|
||||
{items.map((item, idx) => (
|
||||
<StatCard key={idx} item={item} iconSvg={item.icon ? iconMap[item.icon] : null} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Compact Layout */}
|
||||
{layout === "compact" && (
|
||||
<div class="bg-white dark:bg-gray-800 rounded-2xl shadow-xl border border-gray-100 dark:border-gray-700 overflow-hidden">
|
||||
<div class={`grid grid-cols-1 sm:grid-cols-2 ${lgCols} divide-y sm:divide-y-0 sm:divide-x divide-gray-200 dark:divide-gray-700`}>
|
||||
{items.map((item, idx) => (
|
||||
<StatCompact key={idx} item={item} iconSvg={item.icon ? iconMap[item.icon] : null} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Minimal Layout */}
|
||||
{layout === "minimal" && (
|
||||
<div class={`grid ${baseCols} ${lgCols} gap-8 lg:gap-12`}>
|
||||
{items.map((item, idx) => (
|
||||
<StatMinimal key={idx} item={item} iconSvg={item.icon ? iconMap[item.icon] : null} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Animation styles (scoped via container ref) */}
|
||||
<style>{`
|
||||
.stats-item {
|
||||
opacity: 0;
|
||||
transform: translateY(30px);
|
||||
transition: opacity 0.6s ease-out, transform 0.6s ease-out;
|
||||
}
|
||||
.stats-item.animate {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
.stats-item:nth-child(1) { transition-delay: 0ms; }
|
||||
.stats-item:nth-child(2) { transition-delay: 150ms; }
|
||||
.stats-item:nth-child(3) { transition-delay: 300ms; }
|
||||
.stats-item:nth-child(4) { transition-delay: 450ms; }
|
||||
.stats-item:nth-child(5) { transition-delay: 600ms; }
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -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 }}
|
||||
|
||||
<section>
|
||||
<div class="max-w-screen-xl px-4 py-8 mx-auto text-center lg:py-16 lg:px-6">
|
||||
|
||||
{{ with $title }}
|
||||
<div class="py-8 px-4 mx-auto max-w-screen-xl sm:py-16 lg:px-6">
|
||||
<div class="max-w-screen-md mb-8 lg:mb-16 flex flex-col justify-center mx-auto">
|
||||
<h2 class="mb-4 text-4xl tracking-tight font-extrabold text-gray-900 dark:text-white text-center">{{.}}</h2>
|
||||
{{ with $text }}
|
||||
<p class="text-gray-500 sm:text-xl dark:text-gray-400 text-center">{{.}}</p>
|
||||
{{ end }}
|
||||
</div>
|
||||
</div>
|
||||
{{ end }}
|
||||
|
||||
{{ range $idx, $item := $items }}
|
||||
<div class="max-w-screen-md mx-auto {{if ne (math.Add $idx 1) (len $items) }}mb-12{{end}}">
|
||||
<svg class="h-16 mx-auto mb-3 text-gray-400 dark:text-gray-500" xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 32 32">
|
||||
<path d="M9.563 8.469l-0.813-1.25c-5.625 3.781-8.75 8.375-8.75 12.156 0 3.656 2.688 5.375 4.969 5.375 2.875 0 4.906-2.438 4.906-5 0-2.156-1.375-4-3.219-4.688-0.531-0.188-1.031-0.344-1.031-1.25 0-1.156 0.844-2.875 3.938-5.344zM21.969 8.469l-0.813-1.25c-5.563 3.781-8.75 8.375-8.75 12.156 0 3.656 2.75 5.375 5.031 5.375 2.906 0 4.969-2.438 4.969-5 0-2.156-1.406-4-3.313-4.688-0.531-0.188-1-0.344-1-1.25 0-1.156 0.875-2.875 3.875-5.344z"/>
|
||||
</svg>
|
||||
<blockquote>
|
||||
<p class="text-2xl font-medium text-gray-900 dark:text-white">"{{ (index $item "text") | $page.RenderString }}"</p>
|
||||
</blockquote>
|
||||
<div class="flex items-center justify-center mt-6 space-x-3">
|
||||
{{/* 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" }}
|
||||
<img class="w-12 h-12 rounded-full"
|
||||
src="{{$avatar_96.RelPermalink}}"
|
||||
width="48"
|
||||
height="48"
|
||||
alt="{{ (index $item "name") | plainify }}"
|
||||
loading="lazy">
|
||||
{{ else if $image_url }}
|
||||
{{ $is_external := or (strings.HasPrefix $image_url "http://") (strings.HasPrefix $image_url "https://") }}
|
||||
<img class="w-12 h-12 rounded-full"
|
||||
src="{{ if $is_external }}{{ $image_url }}{{ else }}{{ $image_url | relURL }}{{ end }}"
|
||||
width="48"
|
||||
height="48"
|
||||
alt="{{ (index $item "name") | plainify }}"
|
||||
loading="lazy">
|
||||
{{ else }}
|
||||
<div class="w-12 h-12 rounded-full bg-gray-200 dark:bg-gray-700"></div>
|
||||
{{ end }}
|
||||
<div class="flex flex-col items-center">
|
||||
<div class="pr-3 font-medium text-gray-900 dark:text-white">{{ index $item "name" }}</div>
|
||||
<div class="pl-3 text-sm font-light text-gray-500 dark:text-gray-400">{{ index $item "role" }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{ end }}
|
||||
|
||||
</div>
|
||||
</section>
|
||||
@@ -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(<TestimonialsBlock {...props} />, block);
|
||||
console.debug(`✓ Testimonials block "${block.id}" rendered with Preact`);
|
||||
} catch (error) {
|
||||
console.error(`Failed to render Testimonials block "${block.id}":`, error);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
renderTestimonialsBlocks();
|
||||
@@ -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 = () => (
|
||||
<svg
|
||||
class="h-16 mx-auto mb-3 text-gray-400 dark:text-gray-500"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 32 32"
|
||||
>
|
||||
<path d="M9.563 8.469l-0.813-1.25c-5.625 3.781-8.75 8.375-8.75 12.156 0 3.656 2.688 5.375 4.969 5.375 2.875 0 4.906-2.438 4.906-5 0-2.156-1.375-4-3.219-4.688-0.531-0.188-1.031-0.344-1.031-1.25 0-1.156 0.844-2.875 3.938-5.344zM21.969 8.469l-0.813-1.25c-5.563 3.781-8.75 8.375-8.75 12.156 0 3.656 2.75 5.375 5.031 5.375 2.906 0 4.969-2.438 4.969-5 0-2.156-1.406-4-3.313-4.688-0.531-0.188-1-0.344-1-1.25 0-1.156 0.875-2.875 3.875-5.344z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
// Simple markdown renderer
|
||||
function renderText(text) {
|
||||
if (!text) return "";
|
||||
return String(text)
|
||||
.replace(/\*\*(.*?)\*\*/g, "<strong>$1</strong>")
|
||||
.replace(/\*(.*?)\*/g, "<em>$1</em>")
|
||||
.replace(/`(.*?)`/g, "<code>$1</code>");
|
||||
}
|
||||
|
||||
function TestimonialItem({ item, avatarUrl, isLast }) {
|
||||
return (
|
||||
<div class={`max-w-screen-md mx-auto ${isLast ? "" : "mb-12"}`}>
|
||||
<QuoteSvg />
|
||||
<blockquote>
|
||||
<p
|
||||
class="text-2xl font-medium text-gray-900 dark:text-white"
|
||||
dangerouslySetInnerHTML={{ __html: `"${renderText(item.text)}"` }}
|
||||
/>
|
||||
</blockquote>
|
||||
<div class="flex items-center justify-center mt-6 space-x-3">
|
||||
{/* Avatar */}
|
||||
{avatarUrl ? (
|
||||
<img
|
||||
class="w-12 h-12 rounded-full"
|
||||
src={avatarUrl}
|
||||
width="48"
|
||||
height="48"
|
||||
alt={item.name || ""}
|
||||
loading="lazy"
|
||||
/>
|
||||
) : (
|
||||
<div class="w-12 h-12 rounded-full bg-gray-200 dark:bg-gray-700" />
|
||||
)}
|
||||
<div class="flex flex-col items-center">
|
||||
{item.name && (
|
||||
<div class="pr-3 font-medium text-gray-900 dark:text-white">
|
||||
{item.name}
|
||||
</div>
|
||||
)}
|
||||
{item.role && (
|
||||
<div class="pl-3 text-sm font-light text-gray-500 dark:text-gray-400">
|
||||
{item.role}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
<div class="max-w-screen-xl px-4 py-8 mx-auto text-center lg:py-16 lg:px-6">
|
||||
{/* Section Header */}
|
||||
{title && (
|
||||
<div class="py-8 px-4 mx-auto max-w-screen-xl sm:py-16 lg:px-6">
|
||||
<div class="max-w-screen-md mb-8 lg:mb-16 flex flex-col justify-center mx-auto">
|
||||
<h2
|
||||
class="mb-4 text-4xl tracking-tight font-extrabold text-gray-900 dark:text-white text-center"
|
||||
dangerouslySetInnerHTML={{ __html: renderText(title) }}
|
||||
/>
|
||||
{text && (
|
||||
<p
|
||||
class="text-gray-500 sm:text-xl dark:text-gray-400 text-center"
|
||||
dangerouslySetInnerHTML={{ __html: renderText(text) }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Testimonial Items */}
|
||||
{items.map((item, idx) => (
|
||||
<TestimonialItem
|
||||
key={idx}
|
||||
item={item}
|
||||
avatarUrl={imageMap[String(idx)]?.src || null}
|
||||
isLast={idx === items.length - 1}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
@@ -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 */}}
|
||||
<div
|
||||
id="{{ $block_id }}"
|
||||
class="hero-block-container"
|
||||
data-props='{{ $propsJSON | safeHTML }}'
|
||||
data-hero-render="immediate"
|
||||
>
|
||||
{{/* Empty - Preact renders everything */}}
|
||||
</div>
|
||||
|
||||
{{/* Preact script loading is handled by libraries.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 */}}
|
||||
<div
|
||||
id="{{ $block_id }}"
|
||||
class="{{ $block_type }}-block-container preact-block"
|
||||
data-block-type="{{ $block_type }}"
|
||||
data-props='{{ $propsJSON | safeHTML }}'
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
{{ $branding := partialCached "functions/get_branding" . "branding" }}
|
||||
{{ $logo := partialCached "functions/get_logo" (dict "constraint" "max_height" "size" 36) "navbar_logo" }}
|
||||
{{- $show_logo := site.Params.hugoblox.header.show_logo | default true -}}
|
||||
{{- $show_title := site.Params.hugoblox.header.show_title | default true -}}
|
||||
{{- $navbar_title := site.Params.hugoblox.header.title | default $branding.name -}}
|
||||
<header id="site-header" class="header">
|
||||
<nav class="navbar px-3 flex justify-start">
|
||||
<div class="order-0 h-full">
|
||||
<!-- Brand -->
|
||||
<a class="navbar-brand" href="{{ site.Home.RelPermalink }}" title="{{ $branding.site_title }}">
|
||||
{{/* Auto-detect logo from assets/media/logo.{svg,png} */}}
|
||||
{{ if $show_logo }}
|
||||
{{ with $logo.resource }}
|
||||
{{ if $logo.is_svg }}
|
||||
{{- .Content | safeHTML -}}
|
||||
@@ -20,8 +24,9 @@
|
||||
alt="{{ $branding.name }}" />
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
{{/* Display brand name */}}
|
||||
{{ $branding.name }}
|
||||
{{ if $show_title }}{{ $navbar_title }}{{ end }}
|
||||
</a>
|
||||
</div>
|
||||
<!-- Mobile menu -->
|
||||
|
||||
@@ -177,7 +177,7 @@
|
||||
{{ if ne $bg_img.MediaType.SubType "svg" }}
|
||||
{{ $bg_img = $bg_img.Fit "1920x1920 webp" }}
|
||||
{{ end }}
|
||||
{{ $image_layer := printf "url('%s')" $bg_img.Permalink }}
|
||||
{{ $image_layer := printf "url('%s')" $bg_img.RelPermalink }}
|
||||
{{ $background_layers = $background_layers | append $image_layer }}
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
|
||||
@@ -27,32 +27,55 @@ and Tailwind's real filesystem scanner.
|
||||
{{ $paths = $paths | append `assets/js/preact-built/**/*.js` }}
|
||||
|
||||
{{/*
|
||||
3. Inline sources: extract class attributes from Preact JSX/TSX and embed them
|
||||
directly so Tailwind can always see them regardless of FS layout.
|
||||
3. Comprehensive class extraction from Preact JSX/TSX source files.
|
||||
|
||||
Hugo's virtual filesystem mounts block JSX/TSX into assets/js/hbx/blocks/.
|
||||
Tailwind's @source file paths only work on the real filesystem, but these
|
||||
files exist only in Hugo's virtual FS (or in a module cache for remote modules).
|
||||
So we read the file content via resources.Match and extract class candidates inline.
|
||||
|
||||
Strategy: Extract ALL string literal content — not just class= attributes.
|
||||
This catches classes in any pattern:
|
||||
- Static attrs: class="gap-8 items-center"
|
||||
- JSX expressions: class={"gap-8 items-center"}
|
||||
- Template literals: class={`gap-8 ${cond ? "md:grid" : ""}`}
|
||||
- Utility fns: cn("gap-8", condition && "md:grid-cols-2")
|
||||
|
||||
Tailwind's scanner automatically ignores non-class tokens (import paths,
|
||||
error messages, etc.), so false positives are harmless.
|
||||
*/}}
|
||||
{{ $jsx_files := resources.Match "js/hbx/**/*.jsx" }}
|
||||
{{ $tsx_files := resources.Match "js/hbx/**/*.tsx" }}
|
||||
{{ $all_jsx := resources.Match "js/hbx/**/*.jsx" }}
|
||||
{{ $all_tsx := resources.Match "js/hbx/**/*.tsx" }}
|
||||
|
||||
{{ $extracted_classes := slice }}
|
||||
{{ range $f := $jsx_files }}
|
||||
{{ range $f := $all_jsx | append $all_tsx }}
|
||||
{{ $code := $f.Content }}
|
||||
{{ $matches := findRE `class(?:Name)?\s*=\s*"([^"]+)"` $code }}
|
||||
{{ range $m := $matches }}
|
||||
{{ $cls := replaceRE `^class(?:Name)?\s*=\s*"([^"]+)"$` `$1` $m }}
|
||||
{{ $classes := split $cls " " }}
|
||||
{{ range $class := $classes }}
|
||||
{{ $extracted_classes = $extracted_classes | append $class }}
|
||||
|
||||
{{/* A) Extract content of all double-quoted strings */}}
|
||||
{{ $dq_matches := findRE `"[^"]*"` $code }}
|
||||
{{ range $m := $dq_matches }}
|
||||
{{ $str := strings.TrimPrefix `"` (strings.TrimSuffix `"` $m) }}
|
||||
{{ range split $str " " }}
|
||||
{{ $t := strings.TrimSpace . }}
|
||||
{{/* Filter out tokens with unsafe characters (braces, quotes, parens) */}}
|
||||
{{ if and $t (not (findRE `[\{\}\(\)"';]` $t)) }}
|
||||
{{ $extracted_classes = $extracted_classes | append $t }}
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
{{ range $f := $tsx_files }}
|
||||
{{ $code := $f.Content }}
|
||||
{{ $matches := findRE `class(?:Name)?\s*=\s*"([^"]+)"` $code }}
|
||||
{{ range $m := $matches }}
|
||||
{{ $cls := replaceRE `^class(?:Name)?\s*=\s*"([^"]+)"$` `$1` $m }}
|
||||
{{ $classes := split $cls " " }}
|
||||
{{ range $class := $classes }}
|
||||
{{ $extracted_classes = $extracted_classes | append $class }}
|
||||
|
||||
{{/* B) Extract static parts of template literals (backtick strings) */}}
|
||||
{{/* Removes ${...} interpolations, keeping surrounding text */}}
|
||||
{{ $bt_matches := findRE "`[^`]*`" $code }}
|
||||
{{ range $m := $bt_matches }}
|
||||
{{ $str := strings.TrimPrefix "`" (strings.TrimSuffix "`" $m) }}
|
||||
{{ $static := replaceRE `\$\{[^}]*\}` " " $str }}
|
||||
{{ range split $static " " }}
|
||||
{{ $t := strings.TrimSpace . }}
|
||||
{{/* Filter out tokens with unsafe characters (braces, quotes, parens) */}}
|
||||
{{ if and $t (not (findRE `[\{\}\(\)"';]` $t)) }}
|
||||
{{ $extracted_classes = $extracted_classes | append $t }}
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
|
||||
+2
-1
@@ -32,6 +32,7 @@
|
||||
"scripts": {
|
||||
"prod": "scripts/view-starter-prod.sh",
|
||||
"dev": "scripts/view-starter-dev.sh",
|
||||
"dev:stop": "lsof -ti :8081 | xargs kill -9",
|
||||
"test": "scripts/view-test.sh",
|
||||
"lint": "biome check --reporter=summary .",
|
||||
"lint:code": "biome check --reporter=summary . --linter-enabled=true --formatter-enabled=false",
|
||||
@@ -46,4 +47,4 @@
|
||||
"test:releaser": "poetry run python scripts/test_release_modules.py"
|
||||
},
|
||||
"browserslist": "> 1%"
|
||||
}
|
||||
}
|
||||
@@ -13,11 +13,11 @@ hugoblox:
|
||||
# ────────────────────────────────────────────────────────────────────────────
|
||||
identity:
|
||||
# Site name - shown in navbar, footer, copyright, and browser title by default
|
||||
name: "Your Name"
|
||||
name: "Company Name"
|
||||
# Legal entity for copyright notices (defaults to name if not set)
|
||||
organization: ""
|
||||
# Schema.org type for structured data
|
||||
type: person # person | organization | local_business
|
||||
type: organization # person | organization | local_business
|
||||
# Short tagline displayed on your site
|
||||
tagline: ""
|
||||
# Site meta description for search engines and social sharing
|
||||
@@ -83,9 +83,15 @@ hugoblox:
|
||||
header:
|
||||
enable: true
|
||||
# Header style variant
|
||||
style: navbar # navbar | navbar-simple | minimal
|
||||
block: navbar # navbar | navbar-simple
|
||||
# Stick to top on scroll
|
||||
sticky: true
|
||||
# Show logo in navbar
|
||||
show_logo: false
|
||||
# Show site title text in navbar
|
||||
show_title: true
|
||||
# Override navbar title (defaults to identity.name)
|
||||
title: ""
|
||||
# Menu alignment
|
||||
align: center # left | center | right
|
||||
# Feature toggles
|
||||
|
||||
@@ -25,9 +25,9 @@ sections:
|
||||
text: "Read more"
|
||||
url: "/blog/"
|
||||
design:
|
||||
# spacing:
|
||||
# padding: [0, 0, 0, 0]
|
||||
# margin: [0, 0, 0, 0]
|
||||
spacing:
|
||||
padding: [0, 0, 0, 0]
|
||||
margin: [0, 0, 0, 0]
|
||||
# For full-screen, add `min-h-screen` below
|
||||
css_class: "dark"
|
||||
background:
|
||||
|
||||
Reference in new issue
Block a user