feat(blox): 2026 visual polish and new layout options across blocks

- features: add bento layout (asymmetric first-card-featured grid with
  grid-flow-dense), subtitle field, per-item image support
- stats: add numbers_gradient option (bg-clip-text gradient numbers)
- pricing: gradient border wrapper on highlighted tier replacing ring,
  gradient CTA button on primary style; extracted TierCardContent
- testimonials: single item → featured card with decorative quote mark
  and gradient avatar fallback; multiple → responsive card grid
- cta-image-paragraph: rounded-2xl image wrapper with shadow, pill CTA
  button, circle-icon feature list
- cta-card: fix auto text-color detection on colored gradient cards;
  use full literal class strings (Tailwind scanner safety)
- parse_block_v3: radial gradient support, section_break mask-image
  fade, bg-color migration to section element when gradient_mesh active
  to fix stacking context (orbs at z:-10 no longer occluded)
This commit is contained in:
George Cushen committed 2026-05-02 17:03:52 +01:00
1 parent c0458a2e8f
commit a2bc56bd14
9 files changed
+383 -428

No files matched your search

+11 -14
View File
@@ -47,25 +47,22 @@ export const CtaCardBlock = ({content, design, _id, button_icon_svg}) => {
// Legacy detection: design.background.gradient.start using saturated Tailwind tokens
const bg = design?.background || {};
const gradientStart = bg.gradient?.start || "";
const legacyDarkBg = ["primary-7", "primary-8", "primary-9", "secondary-7", "secondary-8", "secondary-9"].some((p) => gradientStart.startsWith(p));
textColorMode = (looksColored || legacyDarkBg) ? "light" : "dark";
const legacyDarkBg = ["primary-7", "primary-8", "primary-9", "secondary-7", "secondary-8", "secondary-9"].some((p) =>
gradientStart.startsWith(p),
);
textColorMode = looksColored || legacyDarkBg ? "light" : "dark";
}
// Title, body, and button classes — full literals only
const titleClasses = textColorMode === "light"
? "text-white"
: "text-gray-900 dark:text-white";
const bodyClasses = textColorMode === "light"
? "text-white/80"
: "text-gray-700 dark:text-gray-300";
const titleClasses = textColorMode === "light" ? "text-white" : "text-gray-900 dark:text-white";
const bodyClasses = textColorMode === "light" ? "text-white/80" : "text-gray-700 dark:text-gray-300";
// Button: in "light" mode (coloured card), keep a white pill across both themes for consistency.
// In "dark" mode (light card), use a dark pill.
const buttonBgClasses = textColorMode === "light"
? "bg-white ring-1 ring-white/40 hover:bg-white/95 hover:ring-white/60 shadow-lg"
: "bg-gray-900 dark:bg-white ring-1 ring-gray-900/10 dark:ring-white/10 hover:bg-gray-800 dark:hover:bg-gray-100 shadow-lg";
const buttonTextClasses = textColorMode === "light"
? "text-gray-900"
: "text-white dark:text-gray-900";
const buttonBgClasses =
textColorMode === "light"
? "bg-white ring-1 ring-white/40 hover:bg-white/95 hover:ring-white/60 shadow-lg"
: "bg-gray-900 dark:bg-white ring-1 ring-gray-900/10 dark:ring-white/10 hover:bg-gray-800 dark:hover:bg-gray-100 shadow-lg";
const buttonTextClasses = textColorMode === "light" ? "text-gray-900" : "text-white dark:text-gray-900";
// Button Logic
const button = content?.button || {};
@@ -6,7 +6,6 @@
import {Icon} from "../../shared/components/Icon.jsx";
// Simple markdown renderer
function renderText(text) {
if (!text) return "";
return String(text)
@@ -16,23 +15,11 @@ function renderText(text) {
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2">$1</a>');
}
// Arrow icon for CTA buttons
const ArrowIcon = () => (
<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>
);
const ARROW_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"><path fill-rule="evenodd" d="M10.293 3.293a1 1 0 011.414 0l6 6a1 1 0 010 1.414l-6 6a1 1 0 01-1.414-1.414L14.586 11H3a1 1 0 110-2h11.586l-4.293-4.293a1 1 0 010-1.414z" clip-rule="evenodd"/></svg>`;
function CtaItem({item, idx, imageData, featureIconSvg}) {
const isReversed = idx % 2 === 1;
const hasImage = !!imageData;
const _featureIcon = item.feature_icon || "check";
// Resolve button link
const buttonUrl = item.button?.url || "";
let isExternal = false;
if (buttonUrl) {
@@ -47,65 +34,78 @@ function CtaItem({item, idx, imageData, featureIconSvg}) {
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`}>
<div
class={`flex flex-col gap-10 items-center py-10 px-4 mx-auto max-w-screen-xl sm:py-16 lg:px-6 ${
imageData ? "md:flex-row md:gap-16" : ""
} ${isReversed && imageData ? "md:flex-row-reverse" : ""}`}
>
{/* 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}
/>
))}
{imageData && (
<div class="w-full md:w-1/2 flex-shrink-0">
<div class="relative overflow-hidden rounded-2xl shadow-xl ring-1 ring-gray-100 dark:ring-gray-700">
{imageData.srcset ? (
<img
class="w-full h-auto object-cover"
srcset={imageData.srcset}
sizes="(max-width: 768px) 100vw, 50vw"
src={imageData.src}
width={imageData.width}
height={imageData.height}
alt={item.title || ""}
/>
) : (
<img class="w-full h-auto object-cover" src={imageData.src} width={imageData.width} height={imageData.height} alt={item.title || ""} />
)}
</div>
</div>
)}
{/* Text Content */}
<div class="mt-4 md:mt-0">
<div class="w-full md:w-1/2">
{item.title && (
<h2
class="mb-4 text-4xl tracking-tight font-extrabold text-gray-900 dark:text-white"
class="mb-4 text-3xl sm:text-4xl font-bold tracking-tight 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)}} />
<p class="mb-8 text-lg text-gray-500 dark:text-gray-400 leading-relaxed" dangerouslySetInnerHTML={{__html: renderText(item.text)}} />
)}
{/* Feature List */}
{features.length > 0 && (
<ul>
<ul class="space-y-4 mb-8">
{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 key={fIdx} class="flex items-start gap-3">
<span class="mt-0.5 flex-shrink-0 inline-flex items-center justify-center w-5 h-5 rounded-full bg-primary-100 dark:bg-primary-900/40">
{featureIconSvg ? (
<Icon
svg={featureIconSvg}
attributes={{
class: "text-primary-600 dark:text-primary-400",
style: "width:0.65rem;height:0.65rem",
}}
/>
) : (
<svg class="w-3 h-3 text-primary-600 dark:text-primary-400" fill="currentColor" viewBox="0 0 12 12" aria-hidden="true">
<path d="M10.28 2.28a1 1 0 0 0-1.44 0L4 7.12 3.16 6.28a1 1 0 0 0-1.44 1.44l2 2a1 1 0 0 0 1.44 0l6-6a1 1 0 0 0 0-1.44z" />
</svg>
)}
</span>
<span class="text-gray-700 dark:text-gray-300" 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"
class="inline-flex items-center gap-2 rounded-full bg-primary-600 text-white px-6 py-3 text-sm font-semibold hover:bg-primary-700 transition-all duration-200 shadow-sm hover:shadow-md"
>
{item.button.text}
<ArrowIcon />
<span class="w-4 h-4 flex-shrink-0" dangerouslySetInnerHTML={{__html: ARROW_SVG}} />
</a>
)}
</div>
+21 -44
View File
@@ -5,7 +5,10 @@ function renderText(text) {
return String(text)
.replace(/\*\*(.*?)\*\*/g, "<strong>$1</strong>")
.replace(/\*(.*?)\*/g, "<em>$1</em>")
.replace(/`(.*?)`/g, '<code class="px-1.5 py-0.5 rounded font-mono text-sm bg-gray-100 dark:bg-gray-800 text-gray-800 dark:text-gray-200">$1</code>');
.replace(
/`(.*?)`/g,
'<code class="px-1.5 py-0.5 rounded font-mono text-sm bg-gray-100 dark:bg-gray-800 text-gray-800 dark:text-gray-200">$1</code>',
);
}
function FeatureCard({item, iconSvg, imgData, variant = "grid", large = false}) {
@@ -14,22 +17,18 @@ function FeatureCard({item, iconSvg, imgData, variant = "grid", large = false})
? `relative h-full rounded-2xl ring-1 ring-gray-200 dark:ring-gray-700 bg-white dark:bg-gray-800/50 ${large ? "p-8 lg:p-10" : "p-6"} hover:ring-primary-300 dark:hover:ring-primary-600 hover:shadow-lg transition-all duration-300 overflow-hidden`
: "";
const iconWrapper = large
? "w-14 h-14 lg:w-16 lg:h-16"
: "w-11 h-11 lg:w-12 lg:h-12";
const iconSize = large
? "height:1.75rem;width:auto"
: "height:1.4rem;width:auto";
const iconWrapper = large ? "w-14 h-14 lg:w-16 lg:h-16" : "w-11 h-11 lg:w-12 lg:h-12";
const iconSize = large ? "height:1.75rem;width:auto" : "height:1.4rem;width:auto";
const titleSize = large
? "text-2xl lg:text-3xl"
: isCard ? "text-lg lg:text-xl" : "text-xl";
const titleSize = large ? "text-2xl lg:text-3xl" : isCard ? "text-lg lg:text-xl" : "text-xl";
const descSize = large ? "text-base lg:text-lg" : "text-sm lg:text-base";
return (
<div class={wrapperCls}>
{iconSvg && (
<div class={`flex justify-center items-center mb-5 ${iconWrapper} rounded-2xl bg-primary-100 dark:bg-primary-900/40 text-primary-600 dark:text-primary-300`}>
<div
class={`flex justify-center items-center mb-5 ${iconWrapper} rounded-2xl bg-primary-100 dark:bg-primary-900/40 text-primary-600 dark:text-primary-300`}
>
<Icon svg={iconSvg} attributes={{class: "inline-block", style: iconSize}} />
</div>
)}
@@ -40,19 +39,11 @@ function FeatureCard({item, iconSvg, imgData, variant = "grid", large = false})
/>
)}
{item.description && (
<p
class={`${descSize} text-gray-600 dark:text-gray-400 leading-relaxed`}
dangerouslySetInnerHTML={{__html: renderText(item.description)}}
/>
<p class={`${descSize} text-gray-600 dark:text-gray-400 leading-relaxed`} dangerouslySetInnerHTML={{__html: renderText(item.description)}} />
)}
{large && imgData?.src && (
<div class="mt-6 -mx-2 lg:-mx-4">
<img
src={imgData.src}
alt={item.name || ""}
class="w-full rounded-xl ring-1 ring-gray-200 dark:ring-gray-700"
loading="lazy"
/>
<img src={imgData.src} alt={item.name || ""} class="w-full rounded-xl ring-1 ring-gray-200 dark:ring-gray-700" loading="lazy" />
</div>
)}
</div>
@@ -63,13 +54,7 @@ function GridLayout({items, iconMap, item_images}) {
return (
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8 md:gap-10 lg:gap-12">
{items.map((item, idx) => (
<FeatureCard
key={idx}
item={item}
iconSvg={item.icon ? iconMap[item.icon] : null}
imgData={item_images?.[String(idx)]}
variant="grid"
/>
<FeatureCard key={idx} item={item} iconSvg={item.icon ? iconMap[item.icon] : null} imgData={item_images?.[String(idx)]} variant="grid" />
))}
</div>
);
@@ -109,31 +94,23 @@ export const FeaturesBlock = ({content = {}, design = {}, icon_svgs = {}, item_i
<div class="max-w-7xl mx-auto">
{(title || text || subtitle) && (
<div class="text-center max-w-3xl mx-auto mb-12 lg:mb-16">
{subtitle && (
<p class="text-xs font-semibold uppercase tracking-widest text-primary-600 dark:text-primary-400 mb-3">
{subtitle}
</p>
)}
{subtitle && <p class="text-xs font-semibold uppercase tracking-widest text-primary-600 dark:text-primary-400 mb-3">{subtitle}</p>}
{title && (
<h2
class="text-3xl sm:text-4xl lg:text-5xl 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"
dangerouslySetInnerHTML={{__html: renderText(text)}}
/>
)}
{text && <p class="text-lg text-gray-600 dark:text-gray-400" dangerouslySetInnerHTML={{__html: renderText(text)}} />}
</div>
)}
{items.length > 0 && (
layout === "bento"
? <BentoLayout items={items} iconMap={icon_svgs} item_images={item_images} />
: <GridLayout items={items} iconMap={icon_svgs} item_images={item_images} />
)}
{items.length > 0 &&
(layout === "bento" ? (
<BentoLayout items={items} iconMap={icon_svgs} item_images={item_images} />
) : (
<GridLayout items={items} iconMap={icon_svgs} item_images={item_images} />
))}
</div>
</div>
);
+44 -53
View File
@@ -13,17 +13,17 @@ const MARQUEE_CSS = `
`;
// Slot widths keep every logo at a consistent visual footprint regardless of SVG aspect ratio
const SLOT_W = {sm: "w-24", md: "w-32", lg: "w-40"};
const SLOT_W = {sm: "w-24", md: "w-32", lg: "w-40"};
// Max-width caps very wide SVGs inside their slot (height × ~2.5)
const MAX_W = {sm: "3.5rem", md: "5rem", lg: "7rem"};
const MAX_W = {sm: "3.5rem", md: "5rem", lg: "7rem"};
// Heights passed as inline style so Icon.jsx's auto-sizing fires correctly
const HEIGHT = {sm: "1.75rem", md: "2.5rem", lg: "3.5rem"};
const HEIGHT = {sm: "1.75rem", md: "2.5rem", lg: "3.5rem"};
// Full-string Tailwind filter classes — no dynamic concatenation
const FILTER = {
grayscale: "grayscale opacity-60 hover:grayscale-0 hover:opacity-100 transition-all duration-300",
white: "brightness-0 invert transition-all duration-300",
color: "transition-all duration-300",
white: "brightness-0 invert transition-all duration-300",
color: "transition-all duration-300",
};
function renderText(text) {
@@ -34,18 +34,18 @@ function renderText(text) {
}
function LogoItem({item, idx, logoStyle, logoSize, icon_svgs, item_images, padY = "py-3"}) {
const iconSvg = item.icon ? (icon_svgs?.[item.icon] ?? null) : null;
const iconSvg = item.icon ? (icon_svgs?.[item.icon] ?? null) : null;
const imgData = item_images?.[String(idx)] ?? null;
const height = HEIGHT[logoSize] ?? HEIGHT.md;
const maxW = MAX_W[logoSize] ?? MAX_W.md;
const slotW = SLOT_W[logoSize] ?? SLOT_W.md;
const height = HEIGHT[logoSize] ?? HEIGHT.md;
const maxW = MAX_W[logoSize] ?? MAX_W.md;
const slotW = SLOT_W[logoSize] ?? SLOT_W.md;
// Per-item overrides: style and scale
const itemStyle = item.style ?? logoStyle;
const filter = FILTER[itemStyle] ?? FILTER.grayscale;
const filter = FILTER[itemStyle] ?? FILTER.grayscale;
// scale: compensates for SVG intrinsic whitespace or atypical artwork areas.
// e.g. scale: 1.3 enlarges a logo whose SVG has excessive padding baked into its viewBox.
const scale = typeof item.scale === "number" ? item.scale : 1;
const scale = typeof item.scale === "number" ? item.scale : 1;
const scaleStyle = scale !== 1 ? `;transform:scale(${scale})` : "";
let visual;
@@ -80,12 +80,13 @@ function LogoItem({item, idx, logoStyle, logoSize, icon_svgs, item_images, padY
const cls = `flex items-center justify-center flex-shrink-0 ${slotW} ${padY}`;
return item.url ? (
<a href={item.url} class={cls} aria-label={item.name || undefined}
{...(isExt ? {target: "_blank", rel: "noopener noreferrer"} : {})}>
<a href={item.url} class={cls} aria-label={item.name || undefined} {...(isExt ? {target: "_blank", rel: "noopener noreferrer"} : {})}>
{visual}
</a>
) : (
<div class={cls} role="img" aria-label={item.name || undefined}>{visual}</div>
<div class={cls} role="img" aria-label={item.name || undefined}>
{visual}
</div>
);
}
@@ -93,8 +94,7 @@ function LogoRow({items, logoStyle, logoSize, icon_svgs, item_images}) {
return (
<div class="flex flex-wrap items-center justify-center">
{items.map((item, i) => (
<LogoItem key={i} item={item} idx={i} logoStyle={logoStyle} logoSize={logoSize}
icon_svgs={icon_svgs} item_images={item_images} />
<LogoItem key={i} item={item} idx={i} logoStyle={logoStyle} logoSize={logoSize} icon_svgs={icon_svgs} item_images={item_images} />
))}
</div>
);
@@ -102,15 +102,17 @@ function LogoRow({items, logoStyle, logoSize, icon_svgs, item_images}) {
function LogoGrid({items, logoStyle, logoSize, icon_svgs, item_images}) {
const cols =
items.length <= 3 ? "grid-cols-3" :
items.length <= 4 ? "grid-cols-2 sm:grid-cols-4" :
items.length <= 6 ? "grid-cols-3 sm:grid-cols-6" :
"grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6";
items.length <= 3
? "grid-cols-3"
: items.length <= 4
? "grid-cols-2 sm:grid-cols-4"
: items.length <= 6
? "grid-cols-3 sm:grid-cols-6"
: "grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6";
return (
<div class={`grid ${cols} gap-6 items-center`}>
{items.map((item, i) => (
<LogoItem key={i} item={item} idx={i} logoStyle={logoStyle} logoSize={logoSize}
icon_svgs={icon_svgs} item_images={item_images} />
<LogoItem key={i} item={item} idx={i} logoStyle={logoStyle} logoSize={logoSize} icon_svgs={icon_svgs} item_images={item_images} />
))}
</div>
);
@@ -125,17 +127,18 @@ function LogoMarquee({items, logoStyle, logoSize, icon_svgs, item_images, speed}
mask-image fades edges without depending on the section background colour —
works on any bg (white, gray-50, dark, gradient, etc.)
*/}
<div
class="overflow-hidden"
style="mask-image: linear-gradient(to right, transparent, black 8%, black 92%, transparent)"
>
<div
class="hbx-logos-track flex w-max items-center"
style={`--hbx-marquee-dur:${speed ?? 30}s`}
>
<div class="overflow-hidden" style="mask-image: linear-gradient(to right, transparent, black 8%, black 92%, transparent)">
<div class="hbx-logos-track flex w-max items-center" style={`--hbx-marquee-dur:${speed ?? 30}s`}>
{doubled.map((item, i) => (
<LogoItem key={i} item={item} idx={i % items.length} logoStyle={logoStyle}
logoSize={logoSize} icon_svgs={icon_svgs} item_images={item_images} />
<LogoItem
key={i}
item={item}
idx={i % items.length}
logoStyle={logoStyle}
logoSize={logoSize}
icon_svgs={icon_svgs}
item_images={item_images}
/>
))}
</div>
</div>
@@ -144,15 +147,13 @@ function LogoMarquee({items, logoStyle, logoSize, icon_svgs, item_images, speed}
}
export const LogosBlock = ({content = {}, design = {}, icon_svgs = {}, item_images = {}}) => {
const rawItems = Array.isArray(content.items) ? content.items
: Array.isArray(content.logos) ? content.logos
: [];
const rawItems = Array.isArray(content.items) ? content.items : Array.isArray(content.logos) ? content.logos : [];
const {title, subtitle, cta} = content;
const layout = design.layout || design.display_mode || "row";
const logoStyle = design.logo_style || "grayscale";
const logoSize = design.logo_size || "md";
const speed = design.marquee_speed ?? 30;
const layout = design.layout || design.display_mode || "row";
const logoStyle = design.logo_style || "grayscale";
const logoSize = design.logo_size || "md";
const speed = design.marquee_speed ?? 30;
const isExtCta = cta?.url && (cta.url.startsWith("http://") || cta.url.startsWith("https://"));
@@ -167,26 +168,16 @@ export const LogosBlock = ({content = {}, design = {}, icon_svgs = {}, item_imag
dangerouslySetInnerHTML={{__html: renderText(title)}}
/>
)}
{subtitle && (
<p
class="text-sm text-gray-400 dark:text-gray-500"
dangerouslySetInnerHTML={{__html: renderText(subtitle)}}
/>
)}
{subtitle && <p class="text-sm text-gray-400 dark:text-gray-500" dangerouslySetInnerHTML={{__html: renderText(subtitle)}} />}
</div>
)}
{layout === "marquee" && (
<LogoMarquee items={rawItems} logoStyle={logoStyle} logoSize={logoSize}
icon_svgs={icon_svgs} item_images={item_images} speed={speed} />
)}
{layout === "grid" && (
<LogoGrid items={rawItems} logoStyle={logoStyle} logoSize={logoSize}
icon_svgs={icon_svgs} item_images={item_images} />
<LogoMarquee items={rawItems} logoStyle={logoStyle} logoSize={logoSize} icon_svgs={icon_svgs} item_images={item_images} speed={speed} />
)}
{layout === "grid" && <LogoGrid items={rawItems} logoStyle={logoStyle} logoSize={logoSize} icon_svgs={icon_svgs} item_images={item_images} />}
{layout !== "marquee" && layout !== "grid" && (
<LogoRow items={rawItems} logoStyle={logoStyle} logoSize={logoSize}
icon_svgs={icon_svgs} item_images={item_images} />
<LogoRow items={rawItems} logoStyle={logoStyle} logoSize={logoSize} icon_svgs={icon_svgs} item_images={item_images} />
)}
{cta?.text && cta?.url && (
+114 -79
View File
@@ -16,7 +16,9 @@ function BillingToggle({billing, setBilling, toggle}) {
const isYearly = billing === "yearly";
return (
<div class="flex items-center justify-center gap-4 mb-10">
<span class={`text-sm transition-colors ${isYearly ? "font-normal text-gray-400 dark:text-gray-500" : "font-semibold text-gray-900 dark:text-white"}`}>
<span
class={`text-sm transition-colors ${isYearly ? "font-normal text-gray-400 dark:text-gray-500" : "font-semibold text-gray-900 dark:text-white"}`}
>
{toggle.monthly_label || "Monthly"}
</span>
<button
@@ -27,9 +29,13 @@ function BillingToggle({billing, setBilling, toggle}) {
class={`relative inline-flex h-7 w-12 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900 ${isYearly ? "bg-primary-600" : "bg-gray-300 dark:bg-gray-600"}`}
>
<span class="sr-only">Toggle billing period</span>
<span class={`pointer-events-none inline-block h-6 w-6 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out ${isYearly ? "translate-x-5" : "translate-x-0"}`} />
<span
class={`pointer-events-none inline-block h-6 w-6 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out ${isYearly ? "translate-x-5" : "translate-x-0"}`}
/>
</button>
<span class={`flex items-center gap-2 text-sm transition-colors ${isYearly ? "font-semibold text-gray-900 dark:text-white" : "font-normal text-gray-400 dark:text-gray-500"}`}>
<span
class={`flex items-center gap-2 text-sm transition-colors ${isYearly ? "font-semibold text-gray-900 dark:text-white" : "font-normal text-gray-400 dark:text-gray-500"}`}
>
{toggle.yearly_label || "Yearly"}
{toggle.yearly_discount && (
<span class="inline-flex items-center rounded-full bg-green-50 dark:bg-green-900/20 px-2.5 py-0.5 text-xs font-semibold text-green-700 dark:text-green-400">
@@ -47,17 +53,12 @@ function PriceDisplay({price = {}, price_suffix, price_note, price_note_monthly,
const isContact = raw === "" || raw == null;
const isFree = String(raw) === "0";
// billing-aware note: per-billing field → static fallback
const note = billing === "yearly"
? (price_note_yearly ?? price_note)
: (price_note_monthly ?? price_note);
const note = billing === "yearly" ? (price_note_yearly ?? price_note) : (price_note_monthly ?? price_note);
if (isContact) {
return (
<div class="mb-6">
<p class="text-3xl font-bold text-gray-900 dark:text-white">
{price_note || "Contact us"}
</p>
<p class="text-3xl font-bold text-gray-900 dark:text-white">{price_note || "Contact us"}</p>
</div>
);
}
@@ -85,7 +86,7 @@ function PriceDisplay({price = {}, price_suffix, price_note, price_note_monthly,
function FeatureRow({feature}) {
const text = typeof feature === "string" ? feature : (feature.text ?? "");
const included = typeof feature === "string" ? true : (feature.included !== false);
const included = typeof feature === "string" ? true : feature.included !== false;
const note = typeof feature === "object" ? feature.note : null;
return (
@@ -102,6 +103,66 @@ function FeatureRow({feature}) {
);
}
function TierCardContent({
name,
description,
price,
price_suffix,
price_note,
price_note_monthly,
price_note_yearly,
cta,
features,
billing,
icon_svgs,
highlight,
}) {
const ctaStyle = cta.style || (highlight ? "primary" : "outline");
const ctaIconSvg = cta.icon ? (icon_svgs?.[cta.icon] ?? null) : null;
const isExternalCta = cta.url && (cta.url.startsWith("http://") || cta.url.startsWith("https://"));
return (
<>
<div class="mb-5">
<h3 class="text-lg font-bold text-gray-900 dark:text-white">{name}</h3>
{description && <p class="mt-1.5 text-sm text-gray-500 dark:text-gray-400">{description}</p>}
</div>
<PriceDisplay
price={price}
price_suffix={price_suffix}
price_note={price_note}
price_note_monthly={price_note_monthly}
price_note_yearly={price_note_yearly}
billing={billing}
/>
{cta.text && cta.url && (
<a
href={cta.url}
{...(isExternalCta ? {target: "_blank", rel: "noopener noreferrer"} : {})}
class={`mb-8 flex w-full items-center justify-center gap-2 rounded-xl px-4 py-3 text-sm font-semibold transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900 ${
ctaStyle === "primary"
? "bg-gradient-to-r from-primary-600 to-secondary-600 text-white hover:from-primary-700 hover:to-secondary-700 shadow-sm hover:shadow-md"
: "ring-1 ring-inset ring-gray-300 dark:ring-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700/50"
}`}
>
{cta.text}
{ctaIconSvg && <Icon svg={ctaIconSvg} attributes={{class: "w-4 h-4 flex-shrink-0"}} />}
</a>
)}
{features.length > 0 && (
<ul class="mt-auto space-y-3 border-t border-gray-100 dark:border-gray-700/60 pt-6">
{features.map((f, i) => (
<FeatureRow key={i} feature={f} />
))}
</ul>
)}
</>
);
}
function PricingTier({tier, billing, icon_svgs}) {
const {
name = "",
@@ -117,62 +178,49 @@ function PricingTier({tier, billing, icon_svgs}) {
features = [],
} = tier;
const ctaStyle = cta.style || (highlight ? "primary" : "outline");
const ctaIconSvg = cta.icon ? (icon_svgs?.[cta.icon] ?? null) : null;
const isExternalCta = cta.url && (cta.url.startsWith("http://") || cta.url.startsWith("https://"));
const cardProps = {
name,
description,
price,
price_suffix,
price_note,
price_note_monthly,
price_note_yearly,
cta,
features,
billing,
icon_svgs,
highlight,
};
if (highlight) {
return (
<div class="relative">
{badge && (
<div class="absolute -top-4 inset-x-0 flex justify-center z-10">
<span class="inline-flex items-center rounded-full bg-gradient-to-r from-primary-600 to-secondary-600 px-4 py-1 text-xs font-semibold text-white shadow-sm">
{badge}
</span>
</div>
)}
{/* Gradient border: 1px gradient ring via padding + colored wrapper */}
<div class="rounded-3xl p-px bg-gradient-to-br from-primary-400 via-primary-500 to-secondary-500 shadow-2xl">
<div class="flex flex-col rounded-3xl p-8 bg-white dark:bg-gray-900 h-full">
<TierCardContent {...cardProps} />
</div>
</div>
</div>
);
}
return (
<div
class={`relative flex flex-col rounded-3xl p-8 transition-shadow duration-200 ${
highlight
? "ring-2 ring-primary-500 shadow-2xl bg-primary-50 dark:bg-primary-900/10 scale-[1.03]"
: "ring-1 ring-gray-200 dark:ring-gray-700 bg-white dark:bg-gray-800 hover:shadow-xl"
}`}
>
<div class="relative flex flex-col rounded-3xl p-8 ring-1 ring-gray-200 dark:ring-gray-700 bg-white dark:bg-gray-800 hover:shadow-xl transition-shadow duration-200">
{badge && (
<div class="absolute -top-4 inset-x-0 flex justify-center">
<span class="inline-flex items-center rounded-full bg-primary-600 px-4 py-1 text-xs font-semibold text-white shadow-sm">
{badge}
</span>
<span class="inline-flex items-center rounded-full bg-primary-600 px-4 py-1 text-xs font-semibold text-white shadow-sm">{badge}</span>
</div>
)}
<div class="mb-5">
<h3 class="text-lg font-bold text-gray-900 dark:text-white">{name}</h3>
{description && (
<p class="mt-1.5 text-sm text-gray-500 dark:text-gray-400">{description}</p>
)}
</div>
<PriceDisplay
price={price}
price_suffix={price_suffix}
price_note={price_note}
price_note_monthly={price_note_monthly}
price_note_yearly={price_note_yearly}
billing={billing}
/>
{cta.text && cta.url && (
<a
href={cta.url}
{...(isExternalCta ? {target: "_blank", rel: "noopener noreferrer"} : {})}
class={`mb-8 flex w-full items-center justify-center gap-2 rounded-xl px-4 py-3 text-sm font-semibold transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900 ${
ctaStyle === "primary"
? "bg-primary-600 text-white hover:bg-primary-700 shadow-sm hover:shadow-md"
: "ring-1 ring-inset ring-primary-500 text-primary-600 dark:text-primary-400 hover:bg-primary-50 dark:hover:bg-primary-900/20"
}`}
>
{cta.text}
{ctaIconSvg && <Icon svg={ctaIconSvg} attributes={{class: "w-4 h-4 flex-shrink-0"}} />}
</a>
)}
{features.length > 0 && (
<ul class="mt-auto space-y-3 border-t border-gray-100 dark:border-gray-700 pt-6">
{features.map((f, i) => <FeatureRow key={i} feature={f} />)}
</ul>
)}
<TierCardContent {...cardProps} />
</div>
);
}
@@ -180,19 +228,13 @@ function PricingTier({tier, billing, icon_svgs}) {
export const PricingBlock = ({content = {}, icon_svgs = {}}) => {
const {title, subtitle, billing_toggle = {}, tiers = []} = content;
const hasYearlyPrices = tiers.some(
(t) => t.price?.yearly != null && t.price.yearly !== t.price.monthly
);
const hasYearlyPrices = tiers.some((t) => t.price?.yearly != null && t.price.yearly !== t.price.monthly);
const showToggle = billing_toggle.enabled && hasYearlyPrices;
const [billing, setBilling] = useState("monthly");
const colsClass =
tiers.length === 1
? "max-w-sm mx-auto"
: tiers.length === 2
? "max-w-4xl mx-auto grid-cols-1 sm:grid-cols-2"
: "grid-cols-1 md:grid-cols-3";
tiers.length === 1 ? "max-w-sm mx-auto" : tiers.length === 2 ? "max-w-4xl mx-auto grid-cols-1 sm:grid-cols-2" : "grid-cols-1 md:grid-cols-3";
return (
<div class="py-16 sm:py-24 px-4 sm:px-6 lg:px-8">
@@ -205,20 +247,13 @@ export const PricingBlock = ({content = {}, icon_svgs = {}}) => {
dangerouslySetInnerHTML={{__html: renderText(title)}}
/>
)}
{subtitle && (
<p
class="text-lg text-gray-600 dark:text-gray-400"
dangerouslySetInnerHTML={{__html: renderText(subtitle)}}
/>
)}
{subtitle && <p class="text-lg text-gray-600 dark:text-gray-400" dangerouslySetInnerHTML={{__html: renderText(subtitle)}} />}
</div>
)}
{showToggle && (
<BillingToggle billing={billing} setBilling={setBilling} toggle={billing_toggle} />
)}
{showToggle && <BillingToggle billing={billing} setBilling={setBilling} toggle={billing_toggle} />}
{/* pt-6 clears space for the absolutely-positioned badge on the highlighted tier */}
{/* pt-6 gives clearance for absolutely-positioned badges */}
<div class={`grid gap-8 pt-6 ${colsClass}`}>
{tiers.map((tier, i) => (
<PricingTier key={i} tier={tier} billing={billing} icon_svgs={icon_svgs} />
+3 -2
View File
@@ -119,8 +119,9 @@ function gridColsClass(count) {
// Gradient text uses bg-clip-text so colors come from the gradient utilities
// (full classes for Tailwind's source scanner to pick up at build time)
const GRADIENT_NUMBER_CLS = "bg-gradient-to-br from-primary-600 via-primary-500 to-secondary-500 dark:from-primary-400 dark:via-primary-300 dark:to-secondary-400 bg-clip-text text-transparent";
const SOLID_NUMBER_CLS = "text-gray-900 dark:text-white";
const GRADIENT_NUMBER_CLS =
"bg-gradient-to-br from-primary-600 via-primary-500 to-secondary-500 dark:from-primary-400 dark:via-primary-300 dark:to-secondary-400 bg-clip-text text-transparent";
const SOLID_NUMBER_CLS = "text-gray-900 dark:text-white";
// ─── Stat Item (Cards) ───────────────────────────────────────────────
function StatCard({item, iconSvg, numbersGradient}) {
+34 -137
View File
@@ -6,7 +6,11 @@ function renderText(text) {
return String(text)
.replace(/\*\*(.*?)\*\*/g, "<strong>$1</strong>")
.replace(/\*(.*?)\*/g, "<em>$1</em>")
.replace(/`(.*?)`/g, (_, code) => `<code class="px-1.5 py-0.5 rounded font-mono text-sm bg-gray-100 dark:bg-gray-800 text-gray-800 dark:text-gray-200">${code}</code>`);
.replace(
/`(.*?)`/g,
(_, code) =>
`<code class="px-1.5 py-0.5 rounded font-mono text-sm bg-gray-100 dark:bg-gray-800 text-gray-800 dark:text-gray-200">${code}</code>`,
);
}
function toRoman(n) {
@@ -47,9 +51,9 @@ function StepMarker({idx, iconSvg, markerStyle, numbering, size = "sm"}) {
);
}
const dim = size === "lg" ? "w-14 h-14 text-base" : "w-10 h-10 text-sm";
const dim = size === "lg" ? "w-14 h-14 text-base" : "w-10 h-10 text-sm";
const iconH = size === "lg" ? "1.75rem" : "1.25rem";
const base = `${dim} flex items-center justify-center rounded-full flex-shrink-0 font-bold`;
const base = `${dim} flex items-center justify-center rounded-full flex-shrink-0 font-bold`;
// Vertical/timeline icon — keep circle for visual anchoring against the connector line
if (markerStyle === "icon" && iconSvg) {
@@ -60,27 +64,17 @@ function StepMarker({idx, iconSvg, markerStyle, numbering, size = "sm"}) {
);
}
return (
<div class={`${base} bg-primary-600 dark:bg-primary-500 text-white`}>
{markerLabel(idx, numbering)}
</div>
);
return <div class={`${base} bg-primary-600 dark:bg-primary-500 text-white`}>{markerLabel(idx, numbering)}</div>;
}
function StepBody({step, imgData}) {
const isExtCta =
step.cta?.url &&
(step.cta.url.startsWith("http://") || step.cta.url.startsWith("https://"));
const isExtCta = step.cta?.url && (step.cta.url.startsWith("http://") || step.cta.url.startsWith("https://"));
return (
<>
{(step.badge || step.date) && (
<div class="flex items-center gap-2 mb-2">
{step.date && (
<span class="text-xs font-semibold text-primary-600 dark:text-primary-400 uppercase tracking-wide">
{step.date}
</span>
)}
{step.date && <span class="text-xs font-semibold text-primary-600 dark:text-primary-400 uppercase tracking-wide">{step.date}</span>}
{step.badge && (
<span class="px-2 py-0.5 text-xs font-medium rounded-full bg-primary-100 dark:bg-primary-900/60 text-primary-700 dark:text-primary-300">
{step.badge}
@@ -89,24 +83,10 @@ function StepBody({step, imgData}) {
</div>
)}
{step.title && (
<h3
class="text-lg font-semibold text-gray-900 dark:text-white mb-2"
dangerouslySetInnerHTML={{__html: renderText(step.title)}}
/>
)}
{step.text && (
<p
class="text-gray-500 dark:text-gray-400 leading-relaxed"
dangerouslySetInnerHTML={{__html: renderText(step.text)}}
/>
)}
{imgData && (
<img
src={imgData.src}
alt={step.title || ""}
class="mt-4 rounded-xl shadow-sm w-full object-cover max-h-60"
/>
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-2" dangerouslySetInnerHTML={{__html: renderText(step.title)}} />
)}
{step.text && <p class="text-gray-500 dark:text-gray-400 leading-relaxed" dangerouslySetInnerHTML={{__html: renderText(step.text)}} />}
{imgData && <img src={imgData.src} alt={step.title || ""} class="mt-4 rounded-xl shadow-sm w-full object-cover max-h-60" />}
{step.cta?.text && step.cta?.url && (
<a
href={step.cta.url}
@@ -134,18 +114,11 @@ function VerticalSteps({steps, markerStyle, numbering, connector, icon_svgs, ite
return (
<li key={i} class="step-item flex gap-5">
<div class="flex flex-col items-center flex-shrink-0">
<StepMarker
idx={i}
iconSvg={iconSvg}
markerStyle={markerStyle}
numbering={numbering}
/>
<StepMarker idx={i} iconSvg={iconSvg} markerStyle={markerStyle} numbering={numbering} />
{!isLast && connector !== "none" && (
<div
class={`flex-1 w-0.5 my-2 ${
isDashed
? "border-l-2 border-dashed border-gray-200 dark:border-gray-700"
: "bg-gray-200 dark:bg-gray-700"
isDashed ? "border-l-2 border-dashed border-gray-200 dark:border-gray-700" : "bg-gray-200 dark:bg-gray-700"
}`}
style="min-height: 1.25rem"
/>
@@ -173,25 +146,12 @@ function HorizontalSteps({steps, markerStyle, numbering, connector, icon_svgs})
const iconSvg = step.icon ? (icon_svgs?.[step.icon] ?? null) : null;
return (
<li key={i} class="step-item flex gap-4 items-start">
<StepMarker
idx={i}
iconSvg={iconSvg}
markerStyle={markerStyle}
numbering={numbering}
/>
<StepMarker idx={i} iconSvg={iconSvg} markerStyle={markerStyle} numbering={numbering} />
<div class="pt-0.5 flex-1 min-w-0">
{step.title && (
<h3
class="font-semibold text-gray-900 dark:text-white mb-1"
dangerouslySetInnerHTML={{__html: renderText(step.title)}}
/>
)}
{step.text && (
<p
class="text-sm text-gray-500 dark:text-gray-400"
dangerouslySetInnerHTML={{__html: renderText(step.text)}}
/>
<h3 class="font-semibold text-gray-900 dark:text-white mb-1" dangerouslySetInnerHTML={{__html: renderText(step.title)}} />
)}
{step.text && <p class="text-sm text-gray-500 dark:text-gray-400" dangerouslySetInnerHTML={{__html: renderText(step.text)}} />}
{step.cta?.text && step.cta?.url && (
<a
href={step.cta.url}
@@ -214,9 +174,7 @@ function HorizontalSteps({steps, markerStyle, numbering, connector, icon_svgs})
{connector !== "none" && (
<div
class={`absolute top-7 left-7 right-7 h-0.5 ${
isDashed
? "border-t-2 border-dashed border-gray-200 dark:border-gray-700"
: "bg-gray-200 dark:bg-gray-700"
isDashed ? "border-t-2 border-dashed border-gray-200 dark:border-gray-700" : "bg-gray-200 dark:bg-gray-700"
}`}
/>
)}
@@ -224,13 +182,7 @@ function HorizontalSteps({steps, markerStyle, numbering, connector, icon_svgs})
const iconSvg = step.icon ? (icon_svgs?.[step.icon] ?? null) : null;
return (
<div key={i} class="step-item relative z-10 flex flex-col items-center">
<StepMarker
idx={i}
iconSvg={iconSvg}
markerStyle={markerStyle}
numbering={numbering}
size="lg"
/>
<StepMarker idx={i} iconSvg={iconSvg} markerStyle={markerStyle} numbering={numbering} size="lg" />
</div>
);
})}
@@ -246,16 +198,10 @@ function HorizontalSteps({steps, markerStyle, numbering, connector, icon_svgs})
</span>
)}
{step.title && (
<h3
class="text-base font-bold text-gray-900 dark:text-white mb-1.5"
dangerouslySetInnerHTML={{__html: renderText(step.title)}}
/>
<h3 class="text-base font-bold text-gray-900 dark:text-white mb-1.5" dangerouslySetInnerHTML={{__html: renderText(step.title)}} />
)}
{step.text && (
<p
class="text-sm text-gray-500 dark:text-gray-400 leading-relaxed"
dangerouslySetInnerHTML={{__html: renderText(step.text)}}
/>
<p class="text-sm text-gray-500 dark:text-gray-400 leading-relaxed" dangerouslySetInnerHTML={{__html: renderText(step.text)}} />
)}
{step.cta?.text && step.cta?.url && (
<a
@@ -286,30 +232,17 @@ function TimelineSteps({steps, markerStyle, numbering, icon_svgs, item_images})
const isLeft = i % 2 === 0;
const iconSvg = step.icon ? (icon_svgs?.[step.icon] ?? null) : null;
const imgData = item_images?.[String(i)] ?? null;
const isExtCta =
step.cta?.url &&
(step.cta.url.startsWith("http://") || step.cta.url.startsWith("https://"));
const isExtCta = step.cta?.url && (step.cta.url.startsWith("http://") || step.cta.url.startsWith("https://"));
return (
<li
key={i}
class={`step-item flex items-start gap-4 md:gap-0 ${
isLeft ? "md:flex-row" : "md:flex-row-reverse"
}`}
>
<li key={i} class={`step-item flex items-start gap-4 md:gap-0 ${isLeft ? "md:flex-row" : "md:flex-row-reverse"}`}>
{/* Content card */}
<div
class={`flex-1 md:max-w-[calc(50%-2rem)] ${
isLeft ? "md:pr-8" : "md:pl-8"
}`}
>
<div class={`flex-1 md:max-w-[calc(50%-2rem)] ${isLeft ? "md:pr-8" : "md:pl-8"}`}>
<div class="bg-white dark:bg-gray-800/80 rounded-2xl border border-gray-200 dark:border-gray-700 p-5 shadow-sm hover:shadow-md transition-shadow duration-300">
{(step.badge || step.date) && (
<div class="flex items-center gap-2 mb-3">
{step.date && (
<span class="text-xs font-semibold text-primary-600 dark:text-primary-400 uppercase tracking-wide">
{step.date}
</span>
<span class="text-xs font-semibold text-primary-600 dark:text-primary-400 uppercase tracking-wide">{step.date}</span>
)}
{step.badge && (
<span class="px-2 py-0.5 text-xs font-medium rounded-full bg-primary-100 dark:bg-primary-900/60 text-primary-700 dark:text-primary-300">
@@ -325,18 +258,9 @@ function TimelineSteps({steps, markerStyle, numbering, icon_svgs, item_images})
/>
)}
{step.text && (
<p
class="text-sm text-gray-500 dark:text-gray-400 leading-relaxed"
dangerouslySetInnerHTML={{__html: renderText(step.text)}}
/>
)}
{imgData && (
<img
src={imgData.src}
alt={step.title || ""}
class="mt-3 rounded-lg w-full object-cover max-h-48"
/>
<p class="text-sm text-gray-500 dark:text-gray-400 leading-relaxed" dangerouslySetInnerHTML={{__html: renderText(step.text)}} />
)}
{imgData && <img src={imgData.src} alt={step.title || ""} class="mt-3 rounded-lg w-full object-cover max-h-48" />}
{step.cta?.text && step.cta?.url && (
<a
href={step.cta.url}
@@ -351,13 +275,7 @@ function TimelineSteps({steps, markerStyle, numbering, icon_svgs, item_images})
{/* Center marker */}
<div class="relative z-10 flex-shrink-0">
<StepMarker
step={step}
idx={i}
iconSvg={iconSvg}
markerStyle={markerStyle}
numbering={numbering}
/>
<StepMarker step={step} idx={i} iconSvg={iconSvg} markerStyle={markerStyle} numbering={numbering} />
</div>
{/* Spacer on opposite side (desktop) */}
@@ -436,11 +354,7 @@ export const StepsBlock = ({content = {}, design = {}, icon_svgs = {}, item_imag
const containerRef = useRef(null);
useStepAnimation(containerRef);
const steps = Array.isArray(content.items)
? content.items
: Array.isArray(content.steps)
? content.steps
: [];
const steps = Array.isArray(content.items) ? content.items : Array.isArray(content.steps) ? content.steps : [];
const {title, subtitle, text} = content;
const layout = design.layout || "vertical";
@@ -449,8 +363,7 @@ export const StepsBlock = ({content = {}, design = {}, icon_svgs = {}, item_imag
const connector = design.connector || "line";
const isCentered = layout === "horizontal" || layout === "timeline";
const maxW =
layout === "horizontal" ? "max-w-5xl" : layout === "timeline" ? "max-w-4xl" : "max-w-2xl";
const maxW = layout === "horizontal" ? "max-w-5xl" : layout === "timeline" ? "max-w-4xl" : "max-w-2xl";
return (
<div class="py-12 sm:py-16 px-4 sm:px-6 lg:px-8" ref={containerRef}>
@@ -459,11 +372,7 @@ export const StepsBlock = ({content = {}, design = {}, icon_svgs = {}, item_imag
<div class={`mx-auto ${maxW}`}>
{(title || subtitle || text) && (
<div class={`mb-10 sm:mb-14 ${isCentered ? "text-center" : ""}`}>
{subtitle && (
<p class="text-sm font-semibold uppercase tracking-widest text-primary-600 dark:text-primary-400 mb-3">
{subtitle}
</p>
)}
{subtitle && <p class="text-sm font-semibold uppercase tracking-widest text-primary-600 dark:text-primary-400 mb-3">{subtitle}</p>}
{title && (
<h2
class="text-3xl font-bold tracking-tight text-gray-900 dark:text-white sm:text-4xl mb-3"
@@ -480,22 +389,10 @@ export const StepsBlock = ({content = {}, design = {}, icon_svgs = {}, item_imag
)}
{layout === "horizontal" && (
<HorizontalSteps
steps={steps}
markerStyle={markerStyle}
numbering={numbering}
connector={connector}
icon_svgs={icon_svgs}
/>
<HorizontalSteps steps={steps} markerStyle={markerStyle} numbering={numbering} connector={connector} icon_svgs={icon_svgs} />
)}
{layout === "timeline" && (
<TimelineSteps
steps={steps}
markerStyle={markerStyle}
numbering={numbering}
icon_svgs={icon_svgs}
item_images={item_images}
/>
<TimelineSteps steps={steps} markerStyle={markerStyle} numbering={numbering} icon_svgs={icon_svgs} item_images={item_images} />
)}
{layout !== "horizontal" && layout !== "timeline" && (
<VerticalSteps
+98 -51
View File
@@ -1,23 +1,10 @@
/**
* Testimonials Block Component - Single source of truth
* Quote cards with avatar, name, and role
* Quote cards with avatar, name, and role.
* Single item: large featured card. Multiple items: responsive grid of cards.
* Avatars are pre-processed by Hugo's image pipeline (passed as item_images prop)
*/
// Quote mark SVG (inline for zero dependencies)
const QuoteSvg = () => (
<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)
@@ -26,23 +13,74 @@ function renderText(text) {
.replace(/`(.*?)`/g, "<code>$1</code>");
}
function TestimonialItem({item, avatarUrl, isLast}) {
function Avatar({item, avatarUrl, size = "lg"}) {
const sizeClass = size === "lg" ? "w-14 h-14" : "w-10 h-10";
const textSize = size === "lg" ? "text-xl" : "text-base";
const initial = item.name ? item.name.trim()[0].toUpperCase() : "?";
if (avatarUrl) {
return (
<img
class={`${sizeClass} rounded-full object-cover ring-2 ring-primary-100 dark:ring-primary-900/50`}
src={avatarUrl}
width={size === "lg" ? 56 : 40}
height={size === "lg" ? 56 : 40}
alt={item.name || ""}
loading="lazy"
/>
);
}
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)}"`}} />
<div
class={`${sizeClass} rounded-full bg-gradient-to-br from-primary-400 to-secondary-400 flex items-center justify-center text-white font-bold ${textSize} flex-shrink-0`}
>
{initial}
</div>
);
}
function TestimonialFeatured({item, avatarUrl}) {
return (
<div class="relative max-w-3xl mx-auto bg-white dark:bg-gray-800 rounded-3xl p-10 sm:p-14 shadow-xl ring-1 ring-gray-100 dark:ring-gray-700 overflow-hidden">
{/* Decorative large quote mark */}
<div
class="absolute top-6 left-8 text-9xl font-serif font-bold text-primary-100 dark:text-primary-900/30 leading-none select-none pointer-events-none"
aria-hidden="true"
>
{"“"}
</div>
<blockquote class="relative">
<p
class="text-xl sm:text-2xl font-medium text-gray-900 dark:text-white leading-relaxed"
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 class="mt-8 flex items-center gap-4">
<Avatar item={item} avatarUrl={avatarUrl} size="lg" />
<div>
{item.name && <div class="font-semibold text-gray-900 dark:text-white">{item.name}</div>}
{item.role && <div class="text-sm text-gray-500 dark:text-gray-400">{item.role}</div>}
</div>
</div>
</div>
);
}
function TestimonialCard({item, avatarUrl}) {
return (
<div class="flex flex-col bg-white dark:bg-gray-800 rounded-2xl p-8 shadow-sm ring-1 ring-gray-100 dark:ring-gray-700 hover:shadow-md transition-shadow duration-200">
<blockquote class="flex-1 mb-6">
<p class="text-base text-gray-700 dark:text-gray-300 leading-relaxed" dangerouslySetInnerHTML={{__html: `“${renderText(item.text)}”`}} />
</blockquote>
<div class="flex items-center gap-3">
<Avatar item={item} avatarUrl={avatarUrl} size="sm" />
<div>
{item.name && <div class="text-sm font-semibold text-gray-900 dark:text-white">{item.name}</div>}
{item.role && <div class="text-xs text-gray-500 dark:text-gray-400">{item.role}</div>}
</div>
</div>
</div>
@@ -55,27 +93,36 @@ export const TestimonialsBlock = ({content, _design, _id, item_images}) => {
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>
)}
const gridCols =
items.length === 1
? ""
: items.length === 2
? "grid grid-cols-1 sm:grid-cols-2 gap-6 max-w-4xl mx-auto"
: "grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6";
{/* Testimonial Items */}
{items.map((item, idx) => (
<TestimonialItem key={idx} item={item} avatarUrl={imageMap[String(idx)]?.src || null} isLast={idx === items.length - 1} />
))}
</div>
</section>
return (
<div class="max-w-screen-xl px-4 py-8 mx-auto text-center lg:py-16 lg:px-6">
{(title || text) && (
<div class="max-w-screen-md mb-12 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"
dangerouslySetInnerHTML={{__html: renderText(title)}}
/>
)}
{text && <p class="text-gray-500 sm:text-xl dark:text-gray-400" dangerouslySetInnerHTML={{__html: renderText(text)}} />}
</div>
)}
{items.length === 1 ? (
<TestimonialFeatured item={items[0]} avatarUrl={imageMap["0"]?.src || null} />
) : (
<div class={gridCols}>
{items.map((item, idx) => (
<TestimonialCard key={idx} item={item} avatarUrl={imageMap[String(idx)]?.src || null} />
))}
</div>
)}
</div>
);
};
@@ -55,6 +55,16 @@
{{ $gradient_mesh := $bg.gradient_mesh | default dict }}
{{ if $gradient_mesh.enable }}
{{ $has_gradient_mesh = true }}
{{/* Stacking-fix: the mesh wrapper sits at z-index:-10, while .home-section-bg sits at z-index:-1.
If we leave bg-color on home-section-bg, the colour paints OVER the mesh and the orbs are hidden.
Migrate the bg-color off home-section-bg and onto the section element itself, so the section's
own background paints BEHIND the mesh layer (orbs visible) and the radial gradient + image layers
on home-section-bg paint ABOVE the mesh (still visible). */}}
{{ if $style_bg }}
{{ $style = print $style $style_bg }}
{{ $style_bg = "" }}
{{ end }}
{{ $mesh_style := $gradient_mesh.style | default "orbs" }}
{{ $mesh_animation_raw := $gradient_mesh.animation | default "pulse" }}
{{ $mesh_intensity := $gradient_mesh.intensity | default "subtle" }}