mirror of
https://github.com/tiennm99/HugoBlox-kit.git
synced 2026-10-11 03:12:56 +00:00
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:
1 parent
c0458a2e8f
commit
a2bc56bd14
9 files changed
+383
-428
No files matched your search
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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}) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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" }}
|
||||
|
||||
Reference in new issue
Block a user