mirror of
https://github.com/tiennm99/HugoBlox-kit.git
synced 2026-10-11 03:12:56 +00:00
feat(faq): migrate FAQ block from Go HTML to Preact
Replaces block.html with a Preact component featuring smooth CSS grid 0fr→1fr height animation, icon rotation, and multi-paragraph answer support via renderRich(). Single-open accordion with question/title and answer/text field aliases for backward compatibility.
This commit is contained in:
1 parent
237a7e2d59
commit
dc14b48b42
11 files changed
+514
-284
No files matched your search
@@ -95,10 +95,10 @@ function DesktopTable({competitors, rows, cta, icon_svgs, rowStriping = false})
|
||||
{/* Card — overflow-visible so the highlight ring can extend above its top edge */}
|
||||
<div class="relative rounded-2xl ring-1 ring-gray-200 dark:ring-gray-700 bg-white dark:bg-gray-800 overflow-visible shadow-sm">
|
||||
|
||||
{/* Highlight column — ring + flat tint behind cells. Lives INSIDE the card so it paints on top of card bg, but BEFORE the table so cell content sits on top. */}
|
||||
{/* Highlight column FILL — paints BEFORE table, behind cells. Cell content sits on top. */}
|
||||
{highlightIdx >= 0 && (
|
||||
<div
|
||||
class="absolute pointer-events-none ring-2 ring-primary-500 dark:ring-primary-400 rounded-2xl bg-primary-50/60 dark:bg-primary-900/20"
|
||||
class="absolute pointer-events-none rounded-2xl bg-primary-50/60 dark:bg-primary-900/20"
|
||||
style={`top: -1.25rem; bottom: 0; left: calc(${colLeftPct}% - 4px); width: calc(${colWidthPct}% + 8px);`}
|
||||
/>
|
||||
)}
|
||||
@@ -162,21 +162,25 @@ function DesktopTable({competitors, rows, cta, icon_svgs, rowStriping = false})
|
||||
dataIdx++;
|
||||
const values = row.values ?? [];
|
||||
const isOdd = rowStriping && dataIdx % 2 === 1;
|
||||
const rowBg = row.highlight
|
||||
? "bg-primary-100/50 dark:bg-primary-900/25"
|
||||
: isOdd
|
||||
? "bg-gray-50/70 dark:bg-gray-800/30"
|
||||
: "";
|
||||
|
||||
// Row-highlight tints the whole row (intentional — the row is the focus).
|
||||
// Zebra stripes apply per-cell so cells inside the highlight column can opt out
|
||||
// (otherwise the stripe washes out the column's brand tint).
|
||||
const cellBg = (ci) => {
|
||||
if (row.highlight) return "bg-primary-100/50 dark:bg-primary-900/25";
|
||||
if (isOdd && ci !== highlightIdx) return "bg-gray-50/70 dark:bg-gray-800/30";
|
||||
return "";
|
||||
};
|
||||
return (
|
||||
<tr key={ri} class={`border-t border-gray-100 dark:border-gray-700/50 ${rowBg}`}>
|
||||
<td class="px-6 py-4">
|
||||
<tr key={ri} class="border-t border-gray-100 dark:border-gray-700/50">
|
||||
<td class={`px-6 py-4 ${cellBg(-1)}`}>
|
||||
<div class="text-sm font-medium text-gray-900 dark:text-white">{row.feature}</div>
|
||||
{row.note && (
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">{row.note}</div>
|
||||
)}
|
||||
</td>
|
||||
{competitors.map((_, ci) => (
|
||||
<td key={ci} class="px-6 py-4">
|
||||
<td key={ci} class={`px-6 py-4 ${cellBg(ci)}`}>
|
||||
<CellContent cell={resolveCell(values[ci])} isHighlighted={ci === highlightIdx} />
|
||||
</td>
|
||||
))}
|
||||
@@ -206,6 +210,14 @@ function DesktopTable({competitors, rows, cta, icon_svgs, rowStriping = false})
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{/* Highlight column OUTLINE — paints AFTER the table so row stripes/tints can't interrupt the violet ring. Outline only, no fill (fill is handled by the layer behind the table). */}
|
||||
{highlightIdx >= 0 && (
|
||||
<div
|
||||
class="absolute pointer-events-none ring-2 ring-primary-500 dark:ring-primary-400 rounded-2xl"
|
||||
style={`top: -1.25rem; bottom: 0; left: calc(${colLeftPct}% - 4px); width: calc(${colWidthPct}% + 8px);`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -28,31 +28,45 @@ export const CtaCardBlock = ({content, design, _id, button_icon_svg}) => {
|
||||
if (!Number.isNaN(val)) overlayOpacity = Math.max(0, Math.min(1, val));
|
||||
}
|
||||
|
||||
// Text Color Logic
|
||||
// Text Color Logic — three modes:
|
||||
// - "light": white title + white/80 body, white pill button (use on coloured/dark cards)
|
||||
// - "dark": dark title + dark body, dark pill button (use on white/light cards)
|
||||
// - "auto": detect from card classes — colored gradient or saturated bg → light, else fall back
|
||||
//
|
||||
// All output classes must be FULL LITERAL STRINGS so Tailwind's source scanner picks them up.
|
||||
let textColorMode = "auto";
|
||||
if (["light", "dark", "auto"].includes(card.text_color?.toLowerCase())) {
|
||||
textColorMode = card.text_color.toLowerCase();
|
||||
}
|
||||
|
||||
let textClasses = "text-gray-900 dark:text-white";
|
||||
let buttonClasses = "text-gray-900 dark:text-gray-100";
|
||||
|
||||
// Auto-detect based on background gradient
|
||||
const bg = design?.background || {};
|
||||
const gradientStart = bg.gradient?.start || "";
|
||||
const isDarkBg = ["primary-7", "primary-8", "primary-9", "secondary-7", "secondary-8", "secondary-9"].some((p) => gradientStart.startsWith(p));
|
||||
|
||||
if (textColorMode === "light") {
|
||||
textClasses = "text-white";
|
||||
buttonClasses = "text-gray-900 dark:text-white";
|
||||
} else if (textColorMode === "dark") {
|
||||
textClasses = "text-gray-900 dark:text-white";
|
||||
buttonClasses = "text-gray-900 dark:text-white";
|
||||
} else if (isDarkBg) {
|
||||
textClasses = "text-white";
|
||||
buttonClasses = "text-gray-900";
|
||||
if (textColorMode === "auto") {
|
||||
const looksColored =
|
||||
cardClass.includes("bg-gradient") ||
|
||||
/\bbg-(primary|secondary|violet|indigo|blue|purple|pink|fuchsia|rose|red|orange|amber|emerald|teal|cyan|sky)-[4-9]\d{0,2}\b/.test(cardClass) ||
|
||||
/\bfrom-(primary|secondary|violet|indigo|blue|purple|pink|fuchsia|rose|red|orange|amber|emerald|teal|cyan|sky)-[4-9]/.test(cardClass);
|
||||
// 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";
|
||||
}
|
||||
|
||||
// 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";
|
||||
// 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";
|
||||
|
||||
// Button Logic
|
||||
const button = content?.button || {};
|
||||
const buttonText = button.text?.trim();
|
||||
@@ -85,7 +99,7 @@ export const CtaCardBlock = ({content, design, _id, button_icon_svg}) => {
|
||||
{/* Title */}
|
||||
{content.title && (
|
||||
<h2
|
||||
class={`${textClasses} text-4xl sm:text-5xl lg:text-6xl xl:text-7xl font-bold tracking-tight leading-tight`}
|
||||
class={`${titleClasses} text-4xl sm:text-5xl lg:text-6xl xl:text-7xl font-bold tracking-tight leading-tight`}
|
||||
dangerouslySetInnerHTML={{__html: renderText(content.title)}}
|
||||
/>
|
||||
)}
|
||||
@@ -93,7 +107,7 @@ export const CtaCardBlock = ({content, design, _id, button_icon_svg}) => {
|
||||
{/* Text */}
|
||||
{content.text && (
|
||||
<div
|
||||
class={`${textClasses}/80 mt-6 text-lg sm:text-xl lg:text-2xl max-w-3xl leading-relaxed font-light`}
|
||||
class={`${bodyClasses} mt-6 text-lg sm:text-xl lg:text-2xl max-w-3xl leading-relaxed font-light`}
|
||||
dangerouslySetInnerHTML={{__html: renderText(content.text)}}
|
||||
/>
|
||||
)}
|
||||
@@ -105,21 +119,14 @@ export const CtaCardBlock = ({content, design, _id, button_icon_svg}) => {
|
||||
href={buttonUrl}
|
||||
target={isNewTab ? "_blank" : undefined}
|
||||
rel={isNewTab ? "noopener" : undefined}
|
||||
class={`group relative inline-flex items-center gap-3 px-8 py-4 text-lg font-semibold ${buttonClasses} transition-all duration-300 ease-out hover:scale-105 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-gray-500/50 dark:focus-visible:ring-white/50 focus-visible:ring-offset-2 focus-visible:ring-offset-transparent`}
|
||||
class={`group inline-flex items-center gap-3 rounded-2xl px-8 py-4 text-lg font-semibold ${buttonBgClasses} ${buttonTextClasses} transition-all duration-300 ease-out hover:scale-105 hover:shadow-2xl focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/60 focus-visible:ring-offset-2 focus-visible:ring-offset-transparent`}
|
||||
>
|
||||
{/* Glassmorphism Background */}
|
||||
<div class="absolute inset-0 bg-white/95 dark:bg-gray-900/95 backdrop-blur-md rounded-2xl ring-1 ring-gray-200/30 dark:ring-white/30 shadow-lg group-hover:bg-white dark:group-hover:bg-gray-800 group-hover:ring-gray-300/50 dark:group-hover:ring-white/50 group-hover:shadow-2xl transition-all duration-300"></div>
|
||||
|
||||
{/* Button Content */}
|
||||
<span class="relative z-10">{buttonText}</span>
|
||||
<span>{buttonText}</span>
|
||||
{button_icon_svg && (
|
||||
<span class="relative z-10 transition-transform duration-300 group-hover:translate-x-1">
|
||||
<span class="transition-transform duration-300 group-hover:translate-x-1">
|
||||
<Icon svg={button_icon_svg} attributes={{style: "height: 1.25em", class: "inline-block"}} />
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Glow Effect */}
|
||||
<div class="absolute inset-0 bg-white/20 dark:bg-white/10 rounded-2xl opacity-0 group-hover:opacity-100 transition-opacity duration-300 blur-xl"></div>
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,152 +0,0 @@
|
||||
{{- $wcPage := .wcPage -}}
|
||||
{{- $wcBlock := .wcBlock -}}
|
||||
{{- $wcIdentifier := .wcIdentifier -}}
|
||||
{{- $content := $wcBlock.content -}}
|
||||
{{- if not (reflect.IsMap $content) }}{{ $content = dict }}{{ end -}}
|
||||
{{- $design := $wcBlock.design -}}
|
||||
{{- if not (reflect.IsMap $design) }}{{ $design = dict }}{{ end -}}
|
||||
|
||||
{{/* Enable Alpine.js for interactive accordion */}}
|
||||
{{ $wcPage.Page.Store.Set "has_alpine" true }}
|
||||
|
||||
{{- $st := index $design "spacing" -}}
|
||||
{{- if not (reflect.IsMap $st) }}{{ $st = dict }}{{ end -}}
|
||||
{{- $pad := slice "20px" "0" "20px" "0" -}}
|
||||
{{- $pad_raw := index $st "padding" -}}
|
||||
{{- if and (reflect.IsSlice $pad_raw) (ge (len $pad_raw) 4) }}{{ $pad = $pad_raw }}{{ end -}}
|
||||
{{- $pad_sm := slice "3rem" "0" -}}
|
||||
{{- $pad_sm_raw := index $st "padding_sm" -}}
|
||||
{{- if and (reflect.IsSlice $pad_sm_raw) (ge (len $pad_sm_raw) 2) }}{{ $pad_sm = $pad_sm_raw }}{{ end -}}
|
||||
{{- $mar := slice "0" "0" "0" "0" -}}
|
||||
{{- $mar_raw := index $st "margin" -}}
|
||||
{{- if and (reflect.IsSlice $mar_raw) (ge (len $mar_raw) 4) }}{{ $mar = $mar_raw }}{{ end -}}
|
||||
|
||||
{{- $bg := index $design "background" -}}
|
||||
{{- if not (reflect.IsMap $bg) }}{{ $bg = dict }}{{ end -}}
|
||||
{{- $bg_color_val := "" -}}
|
||||
{{- $bg_color_raw := index $bg "color" -}}
|
||||
{{- if eq (printf "%T" $bg_color_raw) "string" }}{{ $bg_color_val = $bg_color_raw }}{{ end -}}
|
||||
|
||||
{{- $title := "" -}}
|
||||
{{- $title_raw := index $content "title" -}}
|
||||
{{- if eq (printf "%T" $title_raw) "string" }}{{ $title = $title_raw }}{{ end -}}
|
||||
{{- $subtitle := "" -}}
|
||||
{{- $subtitle_raw := index $content "subtitle" -}}
|
||||
{{- if eq (printf "%T" $subtitle_raw) "string" }}{{ $subtitle = $subtitle_raw }}{{ end -}}
|
||||
{{- $text := "" -}}
|
||||
{{- $text_raw := index $content "text" -}}
|
||||
{{- if eq (printf "%T" $text_raw) "string" }}{{ $text = $text_raw }}{{ end -}}
|
||||
|
||||
{{- $items_raw := index $content "items" -}}
|
||||
{{- $items := slice -}}
|
||||
{{- if reflect.IsSlice $items_raw }}{{ $items = $items_raw }}{{ end -}}
|
||||
|
||||
{{- $button := index $content "button" -}}
|
||||
{{- if not (reflect.IsMap $button) }}{{ $button = dict }}{{ end -}}
|
||||
{{- $button_text := "" -}}
|
||||
{{- with index $button "text" }}{{ $button_text = strings.TrimSpace (printf "%v" .) }}{{ end -}}
|
||||
{{- $button_url := "" -}}
|
||||
{{- with index $button "url" }}{{ $button_url = strings.TrimSpace (printf "%v" .) }}{{ end -}}
|
||||
{{- $button_icon := "" -}}
|
||||
{{- with index $button "icon" }}{{ $button_icon = printf "%v" . }}{{ end -}}
|
||||
|
||||
<section id="{{ $wcIdentifier }}" class="hb-section {{ $bg_color_val }}" style="padding: {{ delimit $pad " " }}; padding-top: clamp({{ index $pad_sm 0 }}, {{ index $pad 0 }}, {{ index $pad 0 }}); padding-bottom: clamp({{ index $pad_sm 1 }}, {{ index $pad 2 }}, {{ index $pad 2 }}); margin: {{ delimit $mar " " }};">
|
||||
<div class="mx-auto max-w-screen-xl px-6 lg:px-8">
|
||||
|
||||
{{- with $title -}}
|
||||
<div class="mb-8 text-center">
|
||||
<h2 class="text-3xl font-bold tracking-tight text-gray-900 dark:text-white sm:text-4xl">
|
||||
{{ . | markdownify | emojify }}
|
||||
</h2>
|
||||
{{- with $subtitle -}}
|
||||
<p class="mt-4 text-lg text-gray-600 dark:text-gray-300">
|
||||
{{ . | markdownify | emojify }}
|
||||
</p>
|
||||
{{- end -}}
|
||||
</div>
|
||||
{{- end -}}
|
||||
|
||||
{{- with $text -}}
|
||||
<div class="prose prose-slate dark:prose-invert max-w-3xl mx-auto mb-8">
|
||||
{{ . | markdownify | emojify }}
|
||||
</div>
|
||||
{{- end -}}
|
||||
|
||||
{{- if gt (len $items) 0 -}}
|
||||
<div class="max-w-3xl mx-auto" x-data="{ openItem: null }">
|
||||
<div class="space-y-4">
|
||||
{{- range $index, $item := $items -}}
|
||||
{{- if reflect.IsMap $item -}}
|
||||
{{- $question := "" -}}
|
||||
{{- $question_raw := index $item "question" | default (index $item "title") -}}
|
||||
{{- if ne $question_raw nil }}{{ $question = printf "%v" $question_raw }}{{ end -}}
|
||||
{{- $answer := "" -}}
|
||||
{{- $answer_raw := index $item "answer" | default (index $item "text") -}}
|
||||
{{- if ne $answer_raw nil }}{{ $answer = printf "%v" $answer_raw }}{{ end -}}
|
||||
{{- if or $question $answer -}}
|
||||
<div class="faq-item border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden bg-white dark:bg-gray-800 shadow-sm">
|
||||
<button
|
||||
type="button"
|
||||
class="w-full px-6 py-4 text-left flex items-center justify-between hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors cursor-pointer"
|
||||
@click="openItem = openItem === {{ $index }} ? null : {{ $index }}"
|
||||
:aria-expanded="openItem === {{ $index }}"
|
||||
>
|
||||
<span class="font-semibold text-gray-900 dark:text-gray-100 pr-8">
|
||||
{{ $question | markdownify | emojify }}
|
||||
</span>
|
||||
<svg
|
||||
class="w-5 h-5 text-gray-500 transition-transform flex-shrink-0"
|
||||
:class="{ 'rotate-180': openItem === {{ $index }} }"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
|
||||
</svg>
|
||||
</button>
|
||||
<div
|
||||
x-show="openItem === {{ $index }}"
|
||||
x-transition:enter="transition ease-out duration-200"
|
||||
x-transition:enter-start="opacity-0 transform -translate-y-2"
|
||||
x-transition:enter-end="opacity-100 transform translate-y-0"
|
||||
x-transition:leave="transition ease-in duration-150"
|
||||
x-transition:leave-start="opacity-100 transform translate-y-0"
|
||||
x-transition:leave-end="opacity-0 transform -translate-y-2"
|
||||
class="px-6 py-4 bg-gray-50 dark:bg-gray-900 border-t border-gray-200 dark:border-gray-700"
|
||||
>
|
||||
<div class="prose prose-slate dark:prose-invert max-w-none">
|
||||
{{ $answer | markdownify | emojify }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{- end -}}
|
||||
{{- end -}}
|
||||
{{- end -}}
|
||||
</div>
|
||||
</div>
|
||||
{{- end -}}
|
||||
|
||||
{{- if and $button_text $button_url -}}
|
||||
{{- $link := $button_url -}}
|
||||
{{- $target := "" -}}
|
||||
{{- $scheme := (urls.Parse $link).Scheme -}}
|
||||
{{- if not $scheme -}}
|
||||
{{- if not (hasPrefix $link "#") -}}
|
||||
{{- $link = $link | relLangURL -}}
|
||||
{{- end -}}
|
||||
{{- if eq (path.Ext $link) ".pdf" }}{{ $target = "target=\"_blank\" rel=\"noopener\"" }}{{ end -}}
|
||||
{{- else if in (slice "http" "https") $scheme -}}
|
||||
{{- $target = "target=\"_blank\" rel=\"noopener\"" -}}
|
||||
{{- end -}}
|
||||
<div class="mt-10 text-center">
|
||||
<a href="{{ $link | safeURL }}" {{ $target | safeHTMLAttr }} class="inline-flex items-center gap-2 rounded-lg bg-primary-600 px-6 py-3 text-base font-semibold text-white shadow-sm hover:bg-primary-700 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary-600">
|
||||
{{ $button_text }}
|
||||
{{- if $button_icon -}}
|
||||
<span class="ml-2">{{ partial "functions/get_icon" (dict "name" $button_icon "attributes" "class=\"inline-block h-5 w-5\"") }}</span>
|
||||
{{- end -}}
|
||||
</a>
|
||||
</div>
|
||||
{{- end -}}
|
||||
|
||||
</div>
|
||||
</section>
|
||||
@@ -0,0 +1,15 @@
|
||||
import {render} from "preact";
|
||||
import {FaqBlock} from "./component.jsx";
|
||||
|
||||
function renderFaqBlocks() {
|
||||
const blocks = document.querySelectorAll('[data-block-type="faq"]');
|
||||
blocks.forEach((block) => {
|
||||
const propsData = block.dataset.props;
|
||||
if (propsData) {
|
||||
const props = JSON.parse(propsData);
|
||||
render(<FaqBlock {...props} />, block);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
renderFaqBlocks();
|
||||
@@ -0,0 +1,127 @@
|
||||
import {useState} from "preact/hooks";
|
||||
import {Icon} from "../../shared/components/Icon.jsx";
|
||||
|
||||
const PLUS_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 3a.75.75 0 01.75.75v5.5h5.5a.75.75 0 010 1.5h-5.5v5.5a.75.75 0 01-1.5 0v-5.5h-5.5a.75.75 0 010-1.5h5.5v-5.5A.75.75 0 0110 3z" clip-rule="evenodd"/></svg>`;
|
||||
|
||||
function renderInline(text) {
|
||||
if (!text) return "";
|
||||
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, '<a href="$2" class="text-primary-600 dark:text-primary-400 underline underline-offset-2 hover:text-primary-700 dark:hover:text-primary-300">$1</a>');
|
||||
}
|
||||
|
||||
function renderRich(text) {
|
||||
if (!text) return "";
|
||||
const paras = String(text)
|
||||
.split(/\n\s*\n/)
|
||||
.map((p) => p.trim())
|
||||
.filter(Boolean)
|
||||
.map((p, i) => `<p${i > 0 ? ' class="mt-3"' : ""}>${renderInline(p)}</p>`);
|
||||
return paras.join("");
|
||||
}
|
||||
|
||||
function FaqItem({item, isOpen, onToggle}) {
|
||||
const question = item.question ?? item.title ?? "";
|
||||
const answer = item.answer ?? item.text ?? "";
|
||||
|
||||
return (
|
||||
<div class="border-b border-gray-200 dark:border-gray-700 last:border-b-0">
|
||||
<button
|
||||
type="button"
|
||||
class="w-full py-5 px-6 flex items-start justify-between gap-6 text-left hover:bg-gray-50 dark:hover:bg-gray-800/50 transition-colors group focus:outline-none focus-visible:bg-gray-50 dark:focus-visible:bg-gray-800/50"
|
||||
onClick={onToggle}
|
||||
aria-expanded={isOpen}
|
||||
>
|
||||
<span
|
||||
class="font-semibold text-gray-900 dark:text-white text-base sm:text-lg leading-snug"
|
||||
dangerouslySetInnerHTML={{__html: renderInline(question)}}
|
||||
/>
|
||||
<span
|
||||
class={`mt-0.5 flex-shrink-0 h-7 w-7 rounded-full bg-gray-100 dark:bg-gray-800 group-hover:bg-primary-100 dark:group-hover:bg-primary-900/30 flex items-center justify-center transition-transform duration-300 ease-out ${isOpen ? "rotate-45" : ""}`}
|
||||
>
|
||||
<span
|
||||
class="h-3.5 w-3.5 text-gray-700 dark:text-gray-300 group-hover:text-primary-600 dark:group-hover:text-primary-400"
|
||||
dangerouslySetInnerHTML={{__html: PLUS_SVG}}
|
||||
/>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* CSS grid trick: grid-rows transition from 0fr → 1fr animates from 0 height to natural height */}
|
||||
<div
|
||||
class={`grid transition-all duration-300 ease-out ${isOpen ? "grid-rows-[1fr] opacity-100" : "grid-rows-[0fr] opacity-0"}`}
|
||||
>
|
||||
<div class="overflow-hidden">
|
||||
<div
|
||||
class="px-6 pb-6 text-gray-600 dark:text-gray-300 leading-relaxed text-sm sm:text-base"
|
||||
dangerouslySetInnerHTML={{__html: renderRich(answer)}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const FaqBlock = ({content = {}, icon_svgs = {}, button_icon_svg}) => {
|
||||
const {title, subtitle, text, items = [], button = {}} = content;
|
||||
const [openIdx, setOpenIdx] = useState(null);
|
||||
|
||||
const buttonIconSvg = button_icon_svg ?? (button.icon ? icon_svgs?.[button.icon] : null);
|
||||
const isExtBtn = button.url && (button.url.startsWith("http://") || button.url.startsWith("https://"));
|
||||
|
||||
return (
|
||||
<div class="py-16 sm:py-24 px-4 sm:px-6 lg:px-8">
|
||||
<div class="max-w-3xl mx-auto">
|
||||
{(title || subtitle) && (
|
||||
<div class="text-center mb-10 lg:mb-14">
|
||||
{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"
|
||||
dangerouslySetInnerHTML={{__html: renderInline(title)}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{text && (
|
||||
<div
|
||||
class="text-center text-gray-600 dark:text-gray-400 mb-10 max-w-2xl mx-auto"
|
||||
dangerouslySetInnerHTML={{__html: renderRich(text)}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{items.length > 0 && (
|
||||
<div class="rounded-2xl ring-1 ring-gray-200 dark:ring-gray-700 bg-white dark:bg-gray-800 overflow-hidden shadow-sm">
|
||||
{items.map((item, i) => (
|
||||
<FaqItem
|
||||
key={i}
|
||||
item={item}
|
||||
isOpen={openIdx === i}
|
||||
onToggle={() => setOpenIdx(openIdx === i ? null : i)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{button.text && button.url && (
|
||||
<div class="mt-10 text-center">
|
||||
<a
|
||||
href={button.url}
|
||||
{...(isExtBtn ? {target: "_blank", rel: "noopener noreferrer"} : {})}
|
||||
class="inline-flex items-center gap-2 rounded-xl bg-primary-600 px-6 py-3 text-base font-semibold text-white shadow-sm hover:bg-primary-700 transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900"
|
||||
>
|
||||
{button.text}
|
||||
{buttonIconSvg && <Icon svg={buttonIconSvg} attributes={{class: "inline-block", style: "height:1rem;width:auto"}} />}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,8 +1,16 @@
|
||||
{
|
||||
"name": "faq",
|
||||
"version": "1",
|
||||
"title": "FAQ",
|
||||
"description": "Display frequently asked questions in an accordion format with SEO-optimized structured data",
|
||||
"categories": ["content", "interactive"],
|
||||
"id": "faq",
|
||||
"name": "FAQ",
|
||||
"version": "2.0.0",
|
||||
"license": "MIT",
|
||||
"category": "content",
|
||||
"intent": ["inform", "convert"],
|
||||
"verticals": ["startup", "saas", "agency", "academic-cv", "lab", "dev-portfolio", "help"],
|
||||
"tags": ["faq", "questions", "help", "support", "objections", "accordion"],
|
||||
"description": "Frequently asked questions accordion. Smooth height-animated expand, single-open behavior, Markdown answers (bold, italic, code, links), optional CTA button. Use to handle objections at the decision point — common placement is between testimonials and the final CTA.",
|
||||
"author": "Hugo Blox",
|
||||
"homepage": "https://hugoblox.com/blocks/",
|
||||
"repository": "https://github.com/HugoBlox/kit",
|
||||
"keywords": ["hugo", "faq", "accordion", "questions"],
|
||||
"preview": "preview.svg"
|
||||
}
|
||||
@@ -1,62 +1,140 @@
|
||||
/**
|
||||
* Features Block Component - Single source of truth
|
||||
* Responsive grid of feature items with icon, name, and description
|
||||
*/
|
||||
|
||||
import {Icon} from "../../shared/components/Icon.jsx";
|
||||
|
||||
// Simple markdown renderer
|
||||
function renderText(text) {
|
||||
if (!text) return "";
|
||||
return String(text)
|
||||
.replace(/\*\*(.*?)\*\*/g, "<strong>$1</strong>")
|
||||
.replace(/\*(.*?)\*/g, "<em>$1</em>")
|
||||
.replace(/`(.*?)`/g, "<code>$1</code>");
|
||||
.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 FeatureItem({item, iconSvg}) {
|
||||
function FeatureCard({item, iconSvg, imgData, variant = "grid", large = false}) {
|
||||
const isCard = variant === "bento";
|
||||
const wrapperCls = isCard
|
||||
? `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 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>
|
||||
<div class={wrapperCls}>
|
||||
{iconSvg && (
|
||||
<div class="flex justify-center items-center mb-4 w-12 h-12 rounded-full bg-primary-100 lg:h-14 lg:w-14 dark:bg-primary-900 text-primary-600 dark:text-primary-300">
|
||||
<Icon svg={iconSvg} attributes={{class: "flex items-center justify-center w-6 h-6 lg:w-8 lg:h-8"}} />
|
||||
<div 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>
|
||||
)}
|
||||
{item.name && (
|
||||
<h3
|
||||
class={`mb-2 ${titleSize} font-bold text-gray-900 dark:text-white tracking-tight`}
|
||||
dangerouslySetInnerHTML={{__html: renderText(item.name)}}
|
||||
/>
|
||||
)}
|
||||
{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"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{item.name && <h3 class="mb-2 text-xl font-bold dark:text-white" dangerouslySetInnerHTML={{__html: renderText(item.name)}} />}
|
||||
{item.description && <p class="text-gray-500 dark:text-gray-400" dangerouslySetInnerHTML={{__html: renderText(item.description)}} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const FeaturesBlock = ({content, _design, _id, icon_svgs}) => {
|
||||
const title = content?.title;
|
||||
const text = content?.text;
|
||||
const items = Array.isArray(content?.items) ? content.items : [];
|
||||
const iconMap = icon_svgs || {};
|
||||
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"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BentoLayout({items, iconMap, item_images}) {
|
||||
// First item is the featured "large" card spanning 2 cols (md+) and 2 rows (lg+).
|
||||
// grid-flow-dense lets remaining items pack the gaps cleanly across counts (4–7).
|
||||
return (
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5 lg:gap-6 grid-flow-dense auto-rows-fr">
|
||||
{items.map((item, idx) => {
|
||||
const large = idx === 0;
|
||||
const spanCls = large ? "md:col-span-2 lg:row-span-2" : "";
|
||||
return (
|
||||
<div key={idx} class={spanCls}>
|
||||
<FeatureCard
|
||||
item={item}
|
||||
iconSvg={item.icon ? iconMap[item.icon] : null}
|
||||
imgData={item_images?.[String(idx)]}
|
||||
variant="bento"
|
||||
large={large}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const FeaturesBlock = ({content = {}, design = {}, icon_svgs = {}, item_images = {}}) => {
|
||||
const {title, subtitle, text, items: rawItems = []} = content;
|
||||
const items = Array.isArray(rawItems) ? rawItems : [];
|
||||
const layout = design.layout || "grid";
|
||||
|
||||
return (
|
||||
<section>
|
||||
<div class="py-8 px-4 mx-auto max-w-screen-xl sm:py-16 lg:px-6">
|
||||
{/* Section Header */}
|
||||
{(title || text) && (
|
||||
<div class="max-w-screen-md mb-8 lg:mb-16 flex flex-col justify-center mx-auto">
|
||||
<div class="py-16 sm:py-24 px-4 sm:px-6 lg:px-8">
|
||||
<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>
|
||||
)}
|
||||
{title && (
|
||||
<h2
|
||||
class="mb-4 text-4xl tracking-tight font-extrabold text-gray-900 dark:text-white text-center"
|
||||
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-gray-500 sm:text-xl dark:text-gray-400 text-center" dangerouslySetInnerHTML={{__html: renderText(text)}} />}
|
||||
{text && (
|
||||
<p
|
||||
class="text-lg text-gray-600 dark:text-gray-400"
|
||||
dangerouslySetInnerHTML={{__html: renderText(text)}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Feature Grid */}
|
||||
<div class="space-y-8 md:grid md:grid-cols-2 lg:grid-cols-3 md:gap-12 md:space-y-0">
|
||||
{items.map((item, idx) => (
|
||||
<FeatureItem key={idx} item={item} iconSvg={item.icon ? iconMap[item.icon] : null} />
|
||||
))}
|
||||
</div>
|
||||
{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>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -117,8 +117,14 @@ function gridColsClass(count) {
|
||||
return "lg:grid-cols-3";
|
||||
}
|
||||
|
||||
// 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";
|
||||
|
||||
// ─── Stat Item (Cards) ───────────────────────────────────────────────
|
||||
function StatCard({item, iconSvg}) {
|
||||
function StatCard({item, iconSvg, numbersGradient}) {
|
||||
const numberCls = numbersGradient ? GRADIENT_NUMBER_CLS : SOLID_NUMBER_CLS;
|
||||
return (
|
||||
<div class="stats-item group relative bg-white dark:bg-gray-800 rounded-2xl shadow-lg hover:shadow-2xl transition-all duration-500 overflow-hidden border border-gray-100 dark:border-gray-700 hover:border-primary-200 dark:hover:border-primary-800">
|
||||
{/* Hover gradient */}
|
||||
@@ -135,7 +141,7 @@ function StatCard({item, iconSvg}) {
|
||||
{/* Statistic */}
|
||||
<div class="mb-3">
|
||||
<h3
|
||||
class="text-4xl sm:text-5xl lg:text-6xl xl:text-7xl font-black text-gray-900 dark:text-white group-hover:text-primary-600 dark:group-hover:text-primary-400 transition-colors duration-300 counter"
|
||||
class={`text-4xl sm:text-5xl lg:text-6xl xl:text-7xl font-black ${numberCls} transition-colors duration-300 counter`}
|
||||
data-target={parseTarget(item.statistic)}
|
||||
dangerouslySetInnerHTML={{__html: renderText(item.statistic)}}
|
||||
/>
|
||||
@@ -155,7 +161,8 @@ function StatCard({item, iconSvg}) {
|
||||
}
|
||||
|
||||
// ─── Stat Item (Compact) ─────────────────────────────────────────────
|
||||
function StatCompact({item, iconSvg}) {
|
||||
function StatCompact({item, iconSvg, numbersGradient}) {
|
||||
const numberCls = numbersGradient ? GRADIENT_NUMBER_CLS : SOLID_NUMBER_CLS;
|
||||
return (
|
||||
<div class="stats-item group p-8 text-center hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors duration-300">
|
||||
{iconSvg && (
|
||||
@@ -165,7 +172,7 @@ function StatCompact({item, iconSvg}) {
|
||||
)}
|
||||
|
||||
<h3
|
||||
class="text-3xl sm:text-4xl lg:text-5xl font-bold text-gray-900 dark:text-white mb-2 counter"
|
||||
class={`text-3xl sm:text-4xl lg:text-5xl font-bold ${numberCls} mb-2 counter`}
|
||||
data-target={parseTarget(item.statistic)}
|
||||
dangerouslySetInnerHTML={{__html: renderText(item.statistic)}}
|
||||
/>
|
||||
@@ -179,7 +186,8 @@ function StatCompact({item, iconSvg}) {
|
||||
}
|
||||
|
||||
// ─── Stat Item (Minimal) ─────────────────────────────────────────────
|
||||
function StatMinimal({item, iconSvg}) {
|
||||
function StatMinimal({item, iconSvg, numbersGradient}) {
|
||||
const numberCls = numbersGradient ? GRADIENT_NUMBER_CLS : SOLID_NUMBER_CLS;
|
||||
return (
|
||||
<div class="stats-item text-center group">
|
||||
{iconSvg && (
|
||||
@@ -189,7 +197,7 @@ function StatMinimal({item, iconSvg}) {
|
||||
)}
|
||||
|
||||
<h3
|
||||
class="text-3xl sm:text-4xl lg:text-5xl font-bold text-gray-900 dark:text-white mb-2 counter"
|
||||
class={`text-3xl sm:text-4xl lg:text-5xl font-bold ${numberCls} mb-2 counter`}
|
||||
data-target={parseTarget(item.statistic)}
|
||||
dangerouslySetInnerHTML={{__html: renderText(item.statistic)}}
|
||||
/>
|
||||
@@ -208,6 +216,7 @@ export const StatsBlock = ({content, design, _id, icon_svgs}) => {
|
||||
const text = content?.text;
|
||||
const items = Array.isArray(content?.items) ? content.items : [];
|
||||
const layout = (design?.layout || "cards").toLowerCase();
|
||||
const numbersGradient = design?.numbers_gradient === true;
|
||||
|
||||
const count = Math.max(1, Math.min(items.length, 4));
|
||||
const lgCols = gridColsClass(count);
|
||||
@@ -236,7 +245,7 @@ export const StatsBlock = ({content, design, _id, icon_svgs}) => {
|
||||
{layout === "cards" && (
|
||||
<div class={`grid grid-cols-1 sm:grid-cols-2 ${lgCols} gap-6 lg:gap-8`}>
|
||||
{items.map((item, idx) => (
|
||||
<StatCard key={idx} item={item} iconSvg={item.icon ? iconMap[item.icon] : null} />
|
||||
<StatCard key={idx} item={item} iconSvg={item.icon ? iconMap[item.icon] : null} numbersGradient={numbersGradient} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
@@ -246,7 +255,7 @@ export const StatsBlock = ({content, design, _id, icon_svgs}) => {
|
||||
<div class="bg-white dark:bg-gray-800 rounded-2xl shadow-xl border border-gray-100 dark:border-gray-700 overflow-hidden">
|
||||
<div class={`grid grid-cols-1 sm:grid-cols-2 ${lgCols} divide-y sm:divide-y-0 sm:divide-x divide-gray-200 dark:divide-gray-700`}>
|
||||
{items.map((item, idx) => (
|
||||
<StatCompact key={idx} item={item} iconSvg={item.icon ? iconMap[item.icon] : null} />
|
||||
<StatCompact key={idx} item={item} iconSvg={item.icon ? iconMap[item.icon] : null} numbersGradient={numbersGradient} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
@@ -256,7 +265,7 @@ export const StatsBlock = ({content, design, _id, icon_svgs}) => {
|
||||
{layout === "minimal" && (
|
||||
<div class={`grid ${baseCols} ${lgCols} gap-8 lg:gap-12`}>
|
||||
{items.map((item, idx) => (
|
||||
<StatMinimal key={idx} item={item} iconSvg={item.icon ? iconMap[item.icon] : null} />
|
||||
<StatMinimal key={idx} item={item} iconSvg={item.icon ? iconMap[item.icon] : null} numbersGradient={numbersGradient} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -152,12 +152,20 @@
|
||||
{{ $gradient_mesh_html = $mesh_html | safeHTML }}
|
||||
{{ end }}
|
||||
|
||||
{{/* Legacy simple gradient support */}}
|
||||
{{/* Linear or radial gradient (radial added for 2026 hero spotlight pattern) */}}
|
||||
{{ if and $bg.gradient.start $bg.gradient.end }}
|
||||
{{ $angle := string $bg.gradient.direction | default "135" }}
|
||||
{{ $start_color := $bg.gradient.start }}
|
||||
{{ $end_color := $bg.gradient.end }}
|
||||
{{ $gradient_layer = printf "linear-gradient(%sdeg, %s, %s)" $angle $start_color $end_color }}
|
||||
{{ $gradient_type := $bg.gradient.type | default "linear" }}
|
||||
{{ if eq $gradient_type "radial" }}
|
||||
{{ $shape := $bg.gradient.shape | default "ellipse" }}
|
||||
{{ $position := $bg.gradient.position | default "center" }}
|
||||
{{ $size := $bg.gradient.size | default "farthest-corner" }}
|
||||
{{ $gradient_layer = printf "radial-gradient(%s %s at %s, %s, %s)" $shape $size $position $start_color $end_color }}
|
||||
{{ else }}
|
||||
{{ $angle := string $bg.gradient.direction | default "135" }}
|
||||
{{ $gradient_layer = printf "linear-gradient(%sdeg, %s, %s)" $angle $start_color $end_color }}
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
|
||||
{{/* Process video backgrounds */}}
|
||||
@@ -263,6 +271,21 @@
|
||||
{{ $style = printf "%sclip-path: %s;" $style . }}
|
||||
{{ end }}
|
||||
|
||||
{{/* Section break: mask-image fade for smooth transitions between sections */}}
|
||||
{{ with $block.design.section_break }}
|
||||
{{ $mask := "" }}
|
||||
{{ if eq . "fade-top" }}
|
||||
{{ $mask = "linear-gradient(to bottom, transparent 0%, black 6rem, black 100%)" }}
|
||||
{{ else if eq . "fade-bottom" }}
|
||||
{{ $mask = "linear-gradient(to bottom, black 0%, black calc(100% - 6rem), transparent 100%)" }}
|
||||
{{ else if eq . "fade-both" }}
|
||||
{{ $mask = "linear-gradient(to bottom, transparent 0%, black 6rem, black calc(100% - 6rem), transparent 100%)" }}
|
||||
{{ end }}
|
||||
{{ with $mask }}
|
||||
{{ $style = printf "%s-webkit-mask-image: %s; mask-image: %s;" $style . . }}
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
|
||||
{{ $hash_id := $block.id | default (printf "section-%s" (replace $block_type "." "-")) }}
|
||||
{{ $css_classes := $block.design.css_class | default "" }}
|
||||
{{ $widget_class := printf "blox-%s" (strings.ReplacePairs $block_type "." "-" "_" "-") }}
|
||||
|
||||
Reference in new issue
Block a user