fix: lint

This commit is contained in:
George Cushen committed 2026-02-19 14:15:26 +00:00
1 parent 00147612cd
commit a3c21b401e
29 files changed
+683 -813

No files matched your search

+8 -3
View File
@@ -16,13 +16,12 @@
"!**/vendor",
"!**/*.html",
"!**/layouts/**/*.html",
"!**/layouts/**/*.htm",
"!**/*.webmanifest",
"!**/*.backlinks.json",
"!**/hugo_stats.json",
"!**/*.code-workspace",
"!**/modules/blox/data/icons/devicon.json",
"!**/templates",
"!**/templates/**/*.html",
"!**/test",
"!**/modules/**/package.json",
"!**/assets/jsconfig.json",
@@ -64,7 +63,13 @@
}
}
},
"assist": { "actions": { "source": { "organizeImports": "on" } } },
"assist": {
"actions": {
"source": {
"organizeImports": "on"
}
}
},
"css": {
"parser": {
"tailwindDirectives": true
+6 -8
View File
@@ -90,22 +90,20 @@
line-height: 1.2;
/* Subtle gradient text for premium feel */
background: linear-gradient(135deg,
var(--color-gray-500) 0%,
var(--color-gray-400) 100%);
background: linear-gradient(135deg, var(--color-gray-500) 0%, var(--color-gray-400) 100%);
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
/* Smooth reveal animation */
opacity: 0.85;
transition: opacity 0.3s ease, transform 0.3s ease;
transition:
opacity 0.3s ease,
transform 0.3s ease;
}
.dark .hb-ruby-name rt {
background: linear-gradient(135deg,
var(--color-gray-400) 0%,
var(--color-gray-300) 100%);
background: linear-gradient(135deg, var(--color-gray-400) 0%, var(--color-gray-300) 100%);
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
@@ -280,4 +278,4 @@
background-image:
radial-gradient(at 20% 25%, hsla(212, 35%, 15%, 1) 0px, transparent 50%), radial-gradient(at 75% 30%, hsla(160, 35%, 15%, 1) 0px, transparent 50%),
radial-gradient(at 25% 80%, hsla(280, 35%, 15%, 1) 0px, transparent 50%), radial-gradient(at 75% 85%, hsla(30, 35%, 15%, 1) 0px, transparent 50%);
}
}
+15 -15
View File
@@ -1,25 +1,25 @@
import { render } from "preact";
import { CtaCardBlock } from "./component.jsx";
import {render} from "preact";
import {CtaCardBlock} from "./component.jsx";
function renderCtaCardBlock() {
const blocks = document.querySelectorAll('.cta-card-block-container[data-preact-render="true"]');
const blocks = document.querySelectorAll('.cta-card-block-container[data-preact-render="true"]');
blocks.forEach((block) => {
try {
if (block.dataset.rendered === "true") return;
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);
}
});
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);
document.addEventListener("DOMContentLoaded", renderCtaCardBlock);
} else {
renderCtaCardBlock();
renderCtaCardBlock();
}
+106 -106
View File
@@ -4,125 +4,125 @@
* Supports glassmorphism, custom backgrounds, and automatic text color adjustment.
*/
import { Icon } from "../../shared/components/Icon.jsx";
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>');
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 || "";
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));
// Overlay Opacity Logic
let overlayOpacity = 0.15;
if (card.overlay_opacity !== undefined) {
const val = parseFloat(card.overlay_opacity);
if (!Number.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;
}
// Text Color Logic
let textColorMode = "auto";
if (["light", "dark", "auto"].includes(card.text_color?.toLowerCase())) {
textColorMode = card.text_color.toLowerCase();
}
const defaultBgClass = "bg-gradient-to-br from-primary-500/90 via-primary-600/95 to-primary-700/90";
const finalCardClass = cardClass || defaultBgClass;
let textClasses = "text-gray-900 dark:text-white";
let buttonClasses = "text-gray-900 dark:text-gray-100";
const style = `--glassmorphism-opacity: ${overlayOpacity}; ${cardStyle}`;
// 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));
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)}}
/>
)}
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 (
{/* Text */}
{content.text && (
<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) }}
/>
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>
)}
{/* 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>
)}
{/* 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>
);
};
@@ -2,24 +2,24 @@
* CTA Image Paragraph Block - Client-side Hydration
*/
import { render } from "preact";
import { CtaImageParagraphBlock } from "./component.jsx";
import {render} from "preact";
import {CtaImageParagraphBlock} from "./component.jsx";
function renderCtaImageParagraphBlocks() {
const blocks = document.querySelectorAll('[data-block-type="cta-image-paragraph"]');
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);
}
}
});
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();
@@ -4,137 +4,131 @@
* Images are pre-processed by Hugo's responsive image pipeline (srcset)
*/
import { Icon } from "../../shared/components/Icon.jsx";
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>');
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>
<svg aria-hidden="true" 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";
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);
}
// Resolve button link
const 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]
: [];
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}
/>
)
)}
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) }}
/>
)}
{/* 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>
)}
{/* 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>
);
{/* 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 || {};
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>
);
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>
);
};
+15 -15
View File
@@ -2,24 +2,24 @@
* Features Block - Client-side Hydration
*/
import { render } from "preact";
import { FeaturesBlock } from "./component.jsx";
import {render} from "preact";
import {FeaturesBlock} from "./component.jsx";
function renderFeaturesBlocks() {
const blocks = document.querySelectorAll('[data-block-type="features"]');
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);
}
}
});
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();
+46 -65
View File
@@ -3,79 +3,60 @@
* Responsive grid of feature items with icon, name, and description
*/
import { Icon } from "../../shared/components/Icon.jsx";
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>");
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-12 h-12 rounded-full bg-primary-100 lg:h-14 lg:w-14 dark:bg-primary-900 text-primary-600 dark:text-primary-300">
<Icon svg={iconSvg} attributes={{ class: "flex items-center justify-center w-6 h-6 lg:w-8 lg:h-8" }} />
</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) }}
/>
)}
function FeatureItem({item, iconSvg}) {
return (
<div>
{iconSvg && (
<div class="flex justify-center items-center mb-4 w-12 h-12 rounded-full bg-primary-100 lg:h-14 lg:w-14 dark:bg-primary-900 text-primary-600 dark:text-primary-300">
<Icon svg={iconSvg} attributes={{class: "flex items-center justify-center w-6 h-6 lg:w-8 lg:h-8"}} />
</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 || {};
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>
)}
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>
);
{/* 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>
);
};
+7 -7
View File
@@ -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,14 +27,14 @@ 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 (
@@ -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,5 +1,5 @@
// 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
@@ -13,7 +13,7 @@ import { h } from "preact";
* the SVG fills that container with height:100%;width:auto instead.
* - If the caller provides explicit w-/h- Tailwind classes, those take precedence.
*/
export const Icon = ({ svg, attributes }) => {
export const Icon = ({svg, attributes}) => {
if (!svg) return null;
// Clean the SVG string: decode HTML entities and TRIM whitespace
@@ -35,9 +35,7 @@ export const Icon = ({ svg, attributes }) => {
// use 100% to fill the container; otherwise default to 1em for self-sizing.
const callerStyle = attributes?.style || "";
const hasExplicitHeight = /height\s*:/i.test(String(callerStyle));
const svgStyle = hasExplicitHeight
? "height:100%;width:auto"
: "height:1em;width:auto";
const svgStyle = hasExplicitHeight ? "height:100%;width:auto" : "height:1em;width:auto";
decoded = decoded.replace(/^(<svg\b)/i, `$1 style="${svgStyle}"`);
@@ -47,7 +45,7 @@ export const Icon = ({ svg, attributes }) => {
};
// eslint-disable-next-line lint/security/noDangerouslySetInnerHtml
return <span {...wrapperProps} dangerouslySetInnerHTML={{ __html: decoded }} />;
return <span {...wrapperProps} dangerouslySetInnerHTML={{__html: decoded}} />;
}
const finalAttributes = {
@@ -62,5 +60,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>`}} />;
};
+18 -18
View File
@@ -3,28 +3,28 @@
* Uses the shared StatsBlock component for consistency
*/
import { render } from "preact";
import { StatsBlock } from "./component.jsx";
import {render} from "preact";
import {StatsBlock} from "./component.jsx";
function renderStatsBlocks() {
const statsBlocks = document.querySelectorAll('[data-block-type="stats"]');
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`);
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
+210 -226
View File
@@ -6,280 +6,264 @@
* Features: counter animation, per-item icons, stagger reveal
*/
import { useEffect, useRef } from "preact/hooks";
import { Icon } from "../../shared/components/Icon.jsx";
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>");
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;
if (!statistic) return 0;
const raw = String(statistic).replace(/[^0-9.]/g, "");
const num = Number(raw);
return Number.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] : "",
};
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;
useEffect(() => {
const container = containerRef.current;
if (!container) return;
const prefersReducedMotion = window.matchMedia(
"(prefers-reduced-motion: reduce)"
).matches;
const prefersReducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
function animateCounters(el) {
el.classList.add("animate");
const counters = el.querySelectorAll(".counter");
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;
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);
}
const {prefix, numberPart, suffix} = extractParts(counter.textContent);
const decimals = (numberPart.split(".")[1] || "").length;
const formatter = new Intl.NumberFormat(undefined, {
minimumFractionDigits: decimals,
maximumFractionDigits: decimals,
});
return () => observer.disconnect();
}, []);
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 - (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";
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" />
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>
)}
<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>
{/* 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) }}
/>
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) }}
/>
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);
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 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";
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 || {};
// 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>
)}
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>
)}
{/* 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>
)}
{/* 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>
)}
{/* Animation styles (scoped via container ref) */}
<style>{`
{/* 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);
@@ -295,6 +279,6 @@ export const StatsBlock = ({ content, design, id, icon_svgs }) => {
.stats-item:nth-child(4) { transition-delay: 450ms; }
.stats-item:nth-child(5) { transition-delay: 600ms; }
`}</style>
</div>
);
</div>
);
};
+15 -15
View File
@@ -2,24 +2,24 @@
* Testimonials Block - Client-side Hydration
*/
import { render } from "preact";
import { TestimonialsBlock } from "./component.jsx";
import {render} from "preact";
import {TestimonialsBlock} from "./component.jsx";
function renderTestimonialsBlocks() {
const blocks = document.querySelectorAll('[data-block-type="testimonials"]');
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);
}
}
});
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();
+61 -88
View File
@@ -6,103 +6,76 @@
// 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>
<svg
aria-hidden="true"
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>");
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>
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 || {};
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}
/>
))}
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>
</section>
);
</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>
);
};
+4 -7
View File
@@ -34,12 +34,9 @@
"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",
"lint:code:fix": "biome check --write . --linter-enabled=true --formatter-enabled=false",
"lint:style": "biome check --reporter=summary modules/blox/assets/css/ modules/slides/assets/css/ --linter-enabled=true --formatter-enabled=false",
"lint:style:fix": "biome check --write modules/blox/assets/css/ modules/slides/assets/css/ --linter-enabled=true --formatter-enabled=false",
"format": "biome check --write .",
"lint": "biome check --reporter=summary . --linter-enabled=true --formatter-enabled=false",
"lint:fix": "biome check --write . --linter-enabled=true --formatter-enabled=false",
"format": "biome format --write .",
"update:starters": "poetry run python ./scripts/update.py",
"vendor:libs": "vite build --config vite.config.js",
"vendor:libs:watch": "vite build --config vite.config.js --watch",
@@ -47,4 +44,4 @@
"test:releaser": "poetry run python scripts/test_release_modules.py"
},
"browserslist": "> 1%"
}
}
@@ -5,21 +5,15 @@
"postCreateCommand": "pnpm --version && hugo version",
"customizations": {
"vscode": {
"extensions": [
"ownable.ownable"
]
"extensions": ["ownable.ownable"]
}
},
"mounts": [
"source=pnpm-store,target=/home/vscode/.local/share/pnpm,type=volume"
],
"mounts": ["source=pnpm-store,target=/home/vscode/.local/share/pnpm,type=volume"],
"remoteUser": "vscode",
"forwardPorts": [
1313
],
"forwardPorts": [1313],
"portsAttributes": {
"1313": {
"label": "Hugo Server"
}
}
}
}
+2 -4
View File
@@ -1,5 +1,3 @@
{
"recommendations": [
"ownable.ownable"
]
}
"recommendations": ["ownable.ownable"]
}
@@ -5,21 +5,15 @@
"postCreateCommand": "pnpm --version && hugo version",
"customizations": {
"vscode": {
"extensions": [
"ownable.ownable"
]
"extensions": ["ownable.ownable"]
}
},
"mounts": [
"source=pnpm-store,target=/home/vscode/.local/share/pnpm,type=volume"
],
"mounts": ["source=pnpm-store,target=/home/vscode/.local/share/pnpm,type=volume"],
"remoteUser": "vscode",
"forwardPorts": [
1313
],
"forwardPorts": [1313],
"portsAttributes": {
"1313": {
"label": "Hugo Server"
}
}
}
}
+2 -4
View File
@@ -1,5 +1,3 @@
{
"recommendations": [
"ownable.ownable"
]
}
"recommendations": ["ownable.ownable"]
}
@@ -5,21 +5,15 @@
"postCreateCommand": "pnpm --version && hugo version",
"customizations": {
"vscode": {
"extensions": [
"ownable.ownable"
]
"extensions": ["ownable.ownable"]
}
},
"mounts": [
"source=pnpm-store,target=/home/vscode/.local/share/pnpm,type=volume"
],
"mounts": ["source=pnpm-store,target=/home/vscode/.local/share/pnpm,type=volume"],
"remoteUser": "vscode",
"forwardPorts": [
1313
],
"forwardPorts": [1313],
"portsAttributes": {
"1313": {
"label": "Hugo Server"
}
}
}
}
@@ -5,21 +5,15 @@
"postCreateCommand": "pnpm --version && hugo version",
"customizations": {
"vscode": {
"extensions": [
"ownable.ownable"
]
"extensions": ["ownable.ownable"]
}
},
"mounts": [
"source=pnpm-store,target=/home/vscode/.local/share/pnpm,type=volume"
],
"mounts": ["source=pnpm-store,target=/home/vscode/.local/share/pnpm,type=volume"],
"remoteUser": "vscode",
"forwardPorts": [
1313
],
"forwardPorts": [1313],
"portsAttributes": {
"1313": {
"label": "Hugo Server"
}
}
}
}
+2 -4
View File
@@ -1,5 +1,3 @@
{
"recommendations": [
"ownable.ownable"
]
}
"recommendations": ["ownable.ownable"]
}
@@ -5,21 +5,15 @@
"postCreateCommand": "pnpm --version && hugo version",
"customizations": {
"vscode": {
"extensions": [
"ownable.ownable"
]
"extensions": ["ownable.ownable"]
}
},
"mounts": [
"source=pnpm-store,target=/home/vscode/.local/share/pnpm,type=volume"
],
"mounts": ["source=pnpm-store,target=/home/vscode/.local/share/pnpm,type=volume"],
"remoteUser": "vscode",
"forwardPorts": [
1313
],
"forwardPorts": [1313],
"portsAttributes": {
"1313": {
"label": "Hugo Server"
}
}
}
}
+2 -4
View File
@@ -1,5 +1,3 @@
{
"recommendations": [
"ownable.ownable"
]
}
"recommendations": ["ownable.ownable"]
}
@@ -5,21 +5,15 @@
"postCreateCommand": "pnpm --version && hugo version",
"customizations": {
"vscode": {
"extensions": [
"ownable.ownable"
]
"extensions": ["ownable.ownable"]
}
},
"mounts": [
"source=pnpm-store,target=/home/vscode/.local/share/pnpm,type=volume"
],
"mounts": ["source=pnpm-store,target=/home/vscode/.local/share/pnpm,type=volume"],
"remoteUser": "vscode",
"forwardPorts": [
1313
],
"forwardPorts": [1313],
"portsAttributes": {
"1313": {
"label": "Hugo Server"
}
}
}
}
@@ -5,21 +5,15 @@
"postCreateCommand": "pnpm --version && hugo version",
"customizations": {
"vscode": {
"extensions": [
"ownable.ownable"
]
"extensions": ["ownable.ownable"]
}
},
"mounts": [
"source=pnpm-store,target=/home/vscode/.local/share/pnpm,type=volume"
],
"mounts": ["source=pnpm-store,target=/home/vscode/.local/share/pnpm,type=volume"],
"remoteUser": "vscode",
"forwardPorts": [
1313
],
"forwardPorts": [1313],
"portsAttributes": {
"1313": {
"label": "Hugo Server"
}
}
}
}
+2 -4
View File
@@ -1,5 +1,3 @@
{
"recommendations": [
"ownable.ownable"
]
}
"recommendations": ["ownable.ownable"]
}
@@ -5,21 +5,15 @@
"postCreateCommand": "pnpm --version && hugo version",
"customizations": {
"vscode": {
"extensions": [
"ownable.ownable"
]
"extensions": ["ownable.ownable"]
}
},
"mounts": [
"source=pnpm-store,target=/home/vscode/.local/share/pnpm,type=volume"
],
"mounts": ["source=pnpm-store,target=/home/vscode/.local/share/pnpm,type=volume"],
"remoteUser": "vscode",
"forwardPorts": [
1313
],
"forwardPorts": [1313],
"portsAttributes": {
"1313": {
"label": "Hugo Server"
}
}
}
}
+2 -4
View File
@@ -1,5 +1,3 @@
{
"recommendations": [
"ownable.ownable"
]
}
"recommendations": ["ownable.ownable"]
}