mirror of
https://github.com/tiennm99/HugoBlox-kit.git
synced 2026-10-04 14:12:59 +00:00
fix: lint
This commit is contained in:
1 parent
00147612cd
commit
a3c21b401e
29 files changed
+683
-813
No files matched your search
+8
-3
@@ -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
|
||||
|
||||
@@ -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%);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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();
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>`}} />;
|
||||
};
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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();
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -1,5 +1,3 @@
|
||||
{
|
||||
"recommendations": [
|
||||
"ownable.ownable"
|
||||
]
|
||||
}
|
||||
"recommendations": ["ownable.ownable"]
|
||||
}
|
||||
Reference in new issue
Block a user