mirror of
https://github.com/tiennm99/HugoBlox-kit.git
synced 2026-10-11 03:12:56 +00:00
feat(hero): Phase 1 — title highlight, eyebrow, CTA styles, trust strip, announcement badge
- Title: [bracket] syntax wraps word(s) in primary→secondary gradient
span (negative lookahead avoids markdown link conflicts)
- Eyebrow: small primary-coloured uppercase label rendered just above
the title — distinct from the announcement pill
- CTAs: per-action style field (gradient | solid | outline | ghost |
text), rounded-full pill default, primary defaults to gradient with
brand-coloured shadow glow, secondary defaults to text
- Secondary action: now supports an icon (replaces trailing arrow)
- Trust strip: optional {stars, text} block rendered below CTAs with
amber star strip and supporting copy
- Announcement: optional {text, color} badge prefix (NEW/BETA chips)
- preact-wrapper: resolves secondary_action.icon into secondary_icon_svg
- Capabilities registry: title-highlight, eyebrow, cta-styles,
announcement-badge, trust-strip
This commit is contained in:
1 parent
b564ceaffa
commit
6f61c4ac30
4 files changed
+165
-88
No files matched your search
@@ -8,72 +8,158 @@ import {Icon} from "../../shared/components/Icon.jsx";
|
||||
// Simple markdown renderer
|
||||
export function renderText(text) {
|
||||
if (!text) return "";
|
||||
return text
|
||||
return String(text)
|
||||
.replace(/\*\*(.*?)\*\*/g, "<strong>$1</strong>")
|
||||
.replace(/\*(.*?)\*/g, "<em>$1</em>")
|
||||
.replace(/`(.*?)`/g, "<code>$1</code>");
|
||||
}
|
||||
|
||||
// Title renderer: supports [highlight] syntax for gradient-text spans (in addition to markdown).
|
||||
// `[word]` becomes a primary→secondary gradient span. Negative lookahead `(?!\()` avoids
|
||||
// clobbering markdown link syntax `[text](url)`.
|
||||
const HIGHLIGHT_CLS =
|
||||
"bg-gradient-to-r from-primary-500 via-secondary-500 to-primary-500 dark:from-primary-400 dark:via-secondary-400 dark:to-primary-400 bg-clip-text text-transparent";
|
||||
|
||||
export function renderTitle(text) {
|
||||
if (!text) return "";
|
||||
return String(text)
|
||||
.replace(/\[([^\]]+)\](?!\()/g, `<span class="${HIGHLIGHT_CLS}">$1</span>`)
|
||||
.replace(/\*\*(.*?)\*\*/g, "<strong>$1</strong>")
|
||||
.replace(/\*(.*?)\*/g, "<em>$1</em>");
|
||||
}
|
||||
|
||||
// Process URLs
|
||||
export function processUrl(url) {
|
||||
if (!url) return {href: "#"};
|
||||
|
||||
if (url.startsWith("http://") || url.startsWith("https://")) {
|
||||
return {
|
||||
href: url,
|
||||
target: "_blank",
|
||||
rel: "noopener",
|
||||
};
|
||||
return {href: url, target: "_blank", rel: "noopener"};
|
||||
}
|
||||
|
||||
if (url.startsWith("#")) {
|
||||
return {href: url};
|
||||
}
|
||||
|
||||
return {href: url};
|
||||
}
|
||||
|
||||
// Full literal class strings (Tailwind scanner safety)
|
||||
const ACTION_STYLES = {
|
||||
gradient:
|
||||
"rounded-full bg-gradient-to-r from-primary-600 to-secondary-600 px-6 py-3 text-sm font-semibold text-white shadow-lg shadow-primary-500/25 hover:shadow-xl hover:shadow-primary-500/40 hover:from-primary-500 hover:to-secondary-500 transition-all duration-200 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary-500",
|
||||
solid:
|
||||
"rounded-full bg-primary-600 px-6 py-3 text-sm font-semibold text-white shadow-sm hover:bg-primary-500 transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary-600",
|
||||
outline:
|
||||
"rounded-full px-6 py-3 text-sm font-semibold ring-1 ring-inset ring-gray-300 dark:ring-gray-600 text-gray-900 dark:text-gray-100 hover:bg-gray-50 dark:hover:bg-gray-800/60 transition-colors",
|
||||
ghost:
|
||||
"rounded-full px-6 py-3 text-sm font-semibold text-gray-900 dark:text-gray-100 hover:bg-gray-100 dark:hover:bg-gray-800/60 transition-colors",
|
||||
text: "text-sm font-semibold leading-6 text-gray-900 dark:text-gray-100 hover:text-gray-700 dark:hover:text-gray-300 transition-colors",
|
||||
};
|
||||
|
||||
function ActionButton({action, defaultStyle, iconSvg}) {
|
||||
if (!action?.url || !action?.text) return null;
|
||||
const styleKey = action.style && ACTION_STYLES[action.style] ? action.style : defaultStyle;
|
||||
const cls = ACTION_STYLES[styleKey];
|
||||
const url = processUrl(action.url);
|
||||
// text-style without an explicit icon shows the classic trailing arrow
|
||||
const showTextArrow = styleKey === "text" && !iconSvg;
|
||||
|
||||
return (
|
||||
<a href={url.href} {...(url.target && {target: url.target, rel: url.rel})} class={`inline-flex items-center gap-2 ${cls}`}>
|
||||
<span dangerouslySetInnerHTML={{__html: renderText(action.text)}} />
|
||||
{iconSvg && (
|
||||
<span class="inline-flex">
|
||||
<Icon svg={iconSvg} attributes={{style: "height: 1em", class: "inline-block"}} />
|
||||
</span>
|
||||
)}
|
||||
{showTextArrow && <span aria-hidden="true">→</span>}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
// Star strip for the trust block. Supports fractional values (e.g. 4.5 → 4 full + half).
|
||||
const STAR_FULL = `<svg viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"><path d="M9.05 2.927c.3-.921 1.603-.921 1.902 0l1.286 3.957a1 1 0 0 0 .95.69h4.162c.969 0 1.371 1.24.588 1.81l-3.367 2.446a1 1 0 0 0-.364 1.118l1.287 3.957c.3.921-.755 1.688-1.539 1.118l-3.366-2.446a1 1 0 0 0-1.176 0l-3.366 2.446c-.784.57-1.838-.197-1.539-1.118l1.287-3.957a1 1 0 0 0-.364-1.118L2.06 9.384c-.783-.57-.38-1.81.588-1.81h4.162a1 1 0 0 0 .95-.69L9.05 2.927Z"/></svg>`;
|
||||
const STAR_EMPTY = `<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true"><path stroke-linejoin="round" d="M9.05 2.927c.3-.921 1.603-.921 1.902 0l1.286 3.957a1 1 0 0 0 .95.69h4.162c.969 0 1.371 1.24.588 1.81l-3.367 2.446a1 1 0 0 0-.364 1.118l1.287 3.957c.3.921-.755 1.688-1.539 1.118l-3.366-2.446a1 1 0 0 0-1.176 0l-3.366 2.446c-.784.57-1.838-.197-1.539-1.118l1.287-3.957a1 1 0 0 0-.364-1.118L2.06 9.384c-.783-.57-.38-1.81.588-1.81h4.162a1 1 0 0 0 .95-.69L9.05 2.927Z"/></svg>`;
|
||||
|
||||
function StarStrip({stars}) {
|
||||
const n = Math.max(0, Math.min(5, Number(stars) || 0));
|
||||
const full = Math.floor(n);
|
||||
return (
|
||||
<div class="inline-flex items-center gap-0.5 text-amber-400">
|
||||
{Array.from({length: 5}).map((_, i) => (
|
||||
<span key={i} class="w-4 h-4 inline-block" dangerouslySetInnerHTML={{__html: i < full ? STAR_FULL : STAR_EMPTY}} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TrustStrip({trust}) {
|
||||
if (!trust) return null;
|
||||
const {stars, text} = trust;
|
||||
if (!stars && !text) return null;
|
||||
return (
|
||||
<div class="mt-8 flex items-center justify-center gap-3 flex-wrap">
|
||||
{stars != null && <StarStrip stars={stars} />}
|
||||
{text && <span class="text-sm text-gray-600 dark:text-gray-400" dangerouslySetInnerHTML={{__html: renderText(text)}} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Announcement: pill above the title with optional leading badge.
|
||||
// announcement.badge: {text, color?} — color is one of: primary | green | amber | rose
|
||||
const BADGE_COLORS = {
|
||||
primary: "bg-primary-100 text-primary-700 dark:bg-primary-900/40 dark:text-primary-300",
|
||||
green: "bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300",
|
||||
amber: "bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300",
|
||||
rose: "bg-rose-100 text-rose-700 dark:bg-rose-900/40 dark:text-rose-300",
|
||||
};
|
||||
|
||||
function AnnouncementPill({announcement}) {
|
||||
if (!announcement?.text) return null;
|
||||
const badge = announcement.badge;
|
||||
const badgeCls = badge && BADGE_COLORS[badge.color] ? BADGE_COLORS[badge.color] : BADGE_COLORS.primary;
|
||||
|
||||
return (
|
||||
<div class="hidden sm:mb-8 sm:flex sm:justify-center">
|
||||
<div class="relative flex items-center gap-2 rounded-full pl-1 pr-4 py-1 text-sm leading-6 text-gray-600 dark:text-gray-300 ring-1 ring-gray-900/10 dark:ring-gray-300/30 hover:ring-gray-900/20 dark:hover:ring-gray-300/50 transition-all">
|
||||
{badge?.text && <span class={`rounded-full px-2.5 py-0.5 text-xs font-semibold ${badgeCls}`}>{badge.text}</span>}
|
||||
{!badge?.text && <span class="pl-2" />}
|
||||
<span dangerouslySetInnerHTML={{__html: renderText(announcement.text)}} />
|
||||
{announcement.link?.text && (
|
||||
<a
|
||||
href={processUrl(announcement.link.url).href}
|
||||
{...(processUrl(announcement.link.url).target && {
|
||||
target: processUrl(announcement.link.url).target,
|
||||
rel: processUrl(announcement.link.url).rel,
|
||||
})}
|
||||
class="font-semibold text-primary-600 dark:text-primary-300"
|
||||
>
|
||||
<span class="absolute inset-0" aria-hidden="true" />
|
||||
{announcement.link.text} <span aria-hidden="true">→</span>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Hero Block Component - Single implementation
|
||||
export const HeroBlock = ({content, design, _id, icon_svg}) => {
|
||||
export const HeroBlock = ({content, design, _id, icon_svg, secondary_icon_svg}) => {
|
||||
const paddingClasses = design?.no_padding ? "" : "py-32 sm:py-48 lg:py-56";
|
||||
|
||||
return (
|
||||
<div class="relative isolate px-6 pt-14 lg:px-8">
|
||||
<div class={`mx-auto max-w-2xl ${paddingClasses}`}>
|
||||
{/* Announcement Banner */}
|
||||
{content.announcement?.text && (
|
||||
<div class="hidden sm:mb-8 sm:flex sm:justify-center">
|
||||
<div class="relative rounded-full px-3 py-1 text-sm leading-6 text-gray-600 dark:text-gray-300 ring-1 ring-gray-900/10 dark:ring-gray-300 hover:ring-gray-900/20 dark:hover:ring-gray-400">
|
||||
{/* eslint-disable-next-line lint/security/noDangerouslySetInnerHtml */}
|
||||
<span
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: renderText(content.announcement.text),
|
||||
}}
|
||||
/>
|
||||
{content.announcement.link?.text && (
|
||||
<a
|
||||
href={processUrl(content.announcement.link.url).href}
|
||||
{...(processUrl(content.announcement.link.url).target && {
|
||||
target: processUrl(content.announcement.link.url).target,
|
||||
rel: processUrl(content.announcement.link.url).rel,
|
||||
})}
|
||||
class="pl-2 font-semibold text-primary-600 dark:text-primary-300"
|
||||
>
|
||||
<span class="absolute inset-0" aria-hidden="true"></span>
|
||||
{content.announcement.link.text} <span aria-hidden="true">→</span>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<AnnouncementPill announcement={content.announcement} />
|
||||
|
||||
{/* Main Content */}
|
||||
<div class="text-center">
|
||||
{/* Eyebrow */}
|
||||
{content.eyebrow && (
|
||||
<p
|
||||
class="mb-4 text-sm font-semibold uppercase tracking-wider text-primary-600 dark:text-primary-400"
|
||||
dangerouslySetInnerHTML={{__html: renderText(content.eyebrow)}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Title */}
|
||||
{content.title && (
|
||||
<h1
|
||||
class="text-4xl font-bold tracking-tight text-gray-900 dark:text-gray-100 sm:text-6xl"
|
||||
dangerouslySetInnerHTML={{__html: renderText(content.title)}}
|
||||
dangerouslySetInnerHTML={{__html: renderTitle(content.title)}}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -87,50 +173,13 @@ export const HeroBlock = ({content, design, _id, icon_svg}) => {
|
||||
|
||||
{/* Action Buttons */}
|
||||
{(content.primary_action?.url || content.secondary_action?.url) && (
|
||||
<div class="mt-10 flex items-center justify-center gap-x-6">
|
||||
{/* Primary CTA */}
|
||||
{content.primary_action?.url && (
|
||||
<a
|
||||
href={processUrl(content.primary_action.url).href}
|
||||
{...(processUrl(content.primary_action.url).target && {
|
||||
target: processUrl(content.primary_action.url).target,
|
||||
rel: processUrl(content.primary_action.url).rel,
|
||||
})}
|
||||
class="rounded-md bg-primary-600 px-3.5 py-2.5 text-sm font-semibold text-white shadow-sm hover:bg-primary-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary-600"
|
||||
>
|
||||
<span
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: renderText(content.primary_action.text),
|
||||
}}
|
||||
/>
|
||||
{content.primary_action.icon && (
|
||||
<span class="inline-block pl-2">
|
||||
<Icon svg={icon_svg} />
|
||||
</span>
|
||||
)}
|
||||
</a>
|
||||
)}
|
||||
|
||||
{/* Secondary CTA */}
|
||||
{content.secondary_action?.url && (
|
||||
<a
|
||||
href={processUrl(content.secondary_action.url).href}
|
||||
{...(processUrl(content.secondary_action.url).target && {
|
||||
target: processUrl(content.secondary_action.url).target,
|
||||
rel: processUrl(content.secondary_action.url).rel,
|
||||
})}
|
||||
class="text-sm font-semibold leading-6 text-gray-900 dark:text-gray-100 hover:dark:text-gray-200 hover:text-gray-800"
|
||||
>
|
||||
<span
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: renderText(content.secondary_action.text),
|
||||
}}
|
||||
/>
|
||||
<span aria-hidden="true"> →</span>
|
||||
</a>
|
||||
)}
|
||||
<div class="mt-10 flex items-center justify-center gap-x-6 flex-wrap gap-y-3">
|
||||
<ActionButton action={content.primary_action} defaultStyle="gradient" iconSvg={icon_svg} />
|
||||
<ActionButton action={content.secondary_action} defaultStyle="text" iconSvg={secondary_icon_svg} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<TrustStrip trust={content.trust} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -25,6 +25,14 @@
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
|
||||
{{/* Resolve icon for secondary_action (hero) */}}
|
||||
{{ with $block.content.secondary_action.icon }}
|
||||
{{ $icon_data := partial "functions/get_icon_data" (dict "name" .) }}
|
||||
{{ if $icon_data }}
|
||||
{{ $props = merge $props (dict "secondary_icon_svg" $icon_data) }}
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
|
||||
{{/* Resolve icon for single button (cta-card) */}}
|
||||
{{ with $block.content.button.icon }}
|
||||
{{ $icon_data := partial "functions/get_icon_data" (dict "name" .) }}
|
||||
|
||||
@@ -78,7 +78,17 @@
|
||||
"categories": ["marketing", "landing"],
|
||||
"meta": {
|
||||
"hugoblox": {
|
||||
"capabilities": ["headline", "subtitle", "dual-cta", "announcement-banner"],
|
||||
"capabilities": [
|
||||
"headline",
|
||||
"title-highlight",
|
||||
"eyebrow",
|
||||
"subtitle",
|
||||
"dual-cta",
|
||||
"cta-styles",
|
||||
"announcement-banner",
|
||||
"announcement-badge",
|
||||
"trust-strip"
|
||||
],
|
||||
"complexity": "simple",
|
||||
"preact": true,
|
||||
"schemaUrl": "https://api.hugoblox.com/v1/blocks/hero/schema"
|
||||
|
||||
@@ -6,20 +6,30 @@ type: landing
|
||||
sections:
|
||||
- block: hero
|
||||
content:
|
||||
title: Build Your Landing Pages with Hugo Blox
|
||||
text: 🧱 EASY. FREE (OPEN SOURCE). NO-CODE 🧱
|
||||
eyebrow: The open-source page builder
|
||||
title: Build your landing page with [Hugo Blox]
|
||||
text: Production-grade blocks. Edit in Markdown and YAML. Deploy anywhere — for free.
|
||||
primary_action:
|
||||
text: Get Started
|
||||
url: https://hugoblox.com/templates/
|
||||
icon: rocket-launch
|
||||
style: gradient
|
||||
secondary_action:
|
||||
text: Read the docs
|
||||
url: https://docs.hugoblox.com
|
||||
icon: book-open
|
||||
style: ghost
|
||||
announcement:
|
||||
text: "Announcing the release of version 1."
|
||||
badge:
|
||||
text: NEW
|
||||
color: primary
|
||||
text: Hugo Blox v1 is here.
|
||||
link:
|
||||
text: "Read more"
|
||||
text: Read more
|
||||
url: "/blog/"
|
||||
trust:
|
||||
stars: 5
|
||||
text: Loved by **10,000+** developers · Trusted by teams at Google, OpenAI, NVIDIA
|
||||
design:
|
||||
spacing:
|
||||
padding: [0, 0, 0, 0]
|
||||
|
||||
Reference in new issue
Block a user