diff --git a/modules/blox/blox/pricing/client.jsx b/modules/blox/blox/pricing/client.jsx new file mode 100644 index 00000000..ad7b5ed3 --- /dev/null +++ b/modules/blox/blox/pricing/client.jsx @@ -0,0 +1,15 @@ +import {render} from "preact"; +import {PricingBlock} from "./component.jsx"; + +function renderPricingBlocks() { + const blocks = document.querySelectorAll('[data-block-type="pricing"]'); + blocks.forEach((block) => { + const propsData = block.dataset.props; + if (propsData) { + const props = JSON.parse(propsData); + render(, block); + } + }); +} + +renderPricingBlocks(); diff --git a/modules/blox/blox/pricing/component.jsx b/modules/blox/blox/pricing/component.jsx new file mode 100644 index 00000000..c22336b2 --- /dev/null +++ b/modules/blox/blox/pricing/component.jsx @@ -0,0 +1,229 @@ +import {useState} from "preact/hooks"; +import {Icon} from "../../shared/components/Icon.jsx"; + +const CHECK_SVG = ``; +const X_SVG = ``; + +function renderText(text) { + if (!text) return ""; + return String(text) + .replace(/\*\*(.*?)\*\*/g, "$1") + .replace(/\*(.*?)\*/g, "$1") + .replace(/`(.*?)`/g, "$1"); +} + +function BillingToggle({billing, setBilling, toggle}) { + const isYearly = billing === "yearly"; + return ( +
+ + {toggle.monthly_label || "Monthly"} + + + + {toggle.yearly_label || "Yearly"} + {toggle.yearly_discount && ( + + {toggle.yearly_discount} + + )} + +
+ ); +} + +function PriceDisplay({price = {}, price_suffix, price_note, price_note_monthly, price_note_yearly, billing}) { + const raw = billing === "yearly" && price.yearly != null ? price.yearly : price.monthly; + const currency = price.currency ?? "$"; + 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); + + if (isContact) { + return ( +
+

+ {price_note || "Contact us"} +

+
+ ); + } + + if (isFree) { + return ( +
+

Free

+ {note &&

{note}

} +
+ ); + } + + return ( +
+
+ {currency && {currency}} + {raw} + {price_suffix && {price_suffix}} +
+ {note &&

{note}

} +
+ ); +} + +function FeatureRow({feature}) { + const text = typeof feature === "string" ? feature : (feature.text ?? ""); + const included = typeof feature === "string" ? true : (feature.included !== false); + const note = typeof feature === "object" ? feature.note : null; + + return ( +
  • + + + {text} + {note && ({note})} + +
  • + ); +} + +function PricingTier({tier, billing, icon_svgs}) { + const { + name = "", + badge = "", + description = "", + price = {}, + price_suffix = "", + price_note = "", + price_note_monthly, + price_note_yearly, + highlight = false, + cta = {}, + 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://")); + + return ( +
    + {badge && ( +
    + + {badge} + +
    + )} + +
    +

    {name}

    + {description && ( +

    {description}

    + )} +
    + + + + {cta.text && cta.url && ( + + {cta.text} + {ctaIconSvg && } + + )} + + {features.length > 0 && ( +
      + {features.map((f, i) => )} +
    + )} +
    + ); +} + +export const PricingBlock = ({content = {}, design = {}, 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 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"; + + return ( +
    +
    + {(title || subtitle) && ( +
    + {title && ( +

    + )} + {subtitle && ( +

    + )} +

    + )} + + {showToggle && ( + + )} + + {/* pt-6 clears space for the absolutely-positioned badge on the highlighted tier */} +
    + {tiers.map((tier, i) => ( + + ))} +
    +
    +
    + ); +}; diff --git a/modules/blox/blox/pricing/manifest.json b/modules/blox/blox/pricing/manifest.json new file mode 100644 index 00000000..5225a419 --- /dev/null +++ b/modules/blox/blox/pricing/manifest.json @@ -0,0 +1,15 @@ +{ + "id": "pricing", + "name": "Pricing", + "version": "1.0.0", + "license": "MIT", + "category": "conversion", + "intent": ["convert", "inform"], + "verticals": ["startup", "saas", "agency", "dev-portfolio", "link-in-bio"], + "tags": ["pricing", "plans", "tiers", "billing", "saas", "subscription", "conversion"], + "description": "Display pricing tiers with optional monthly/yearly billing toggle, feature checklists, highlighted tiers, badges, and per-tier CTAs", + "author": "Hugo Blox", + "homepage": "https://hugoblox.com/blocks/", + "repository": "https://github.com/HugoBlox/kit", + "keywords": ["hugo", "static-site", "pricing", "plans", "saas", "billing"] +} diff --git a/modules/blox/layouts/_partials/blox/preact-wrapper.html b/modules/blox/layouts/_partials/blox/preact-wrapper.html index 0fb41b4b..58620a60 100644 --- a/modules/blox/layouts/_partials/blox/preact-wrapper.html +++ b/modules/blox/layouts/_partials/blox/preact-wrapper.html @@ -148,6 +148,23 @@ {{ $props = merge $props (dict "icon_svgs" $icon_svgs) }} {{ end }} +{{/* Resolve CTA icons from pricing tiers (content.tiers[*].cta.icon) */}} +{{ with $block.content.tiers }} + {{ range . }} + {{ with .cta.icon }} + {{ if not (index $icon_svgs .) }} + {{ $icon_data := partial "functions/get_icon_data" (dict "name" .) }} + {{ if $icon_data }} + {{ $icon_svgs = merge $icon_svgs (dict . $icon_data) }} + {{ end }} + {{ end }} + {{ end }} + {{ end }} +{{ end }} +{{ if gt (len $icon_svgs) 0 }} + {{ $props = merge $props (dict "icon_svgs" $icon_svgs) }} +{{ end }} + {{/* Convert props to JSON for client-side rendering */}} {{ $propsJSON := $props | jsonify }} diff --git a/templates/startup-landing-page/content/_index.md b/templates/startup-landing-page/content/_index.md index 2b816536..0de88726 100644 --- a/templates/startup-landing-page/content/_index.md +++ b/templates/startup-landing-page/content/_index.md @@ -81,6 +81,95 @@ sections: - name: Swappable Blocks icon: rectangle-group description: Build your pages with blocks - no coding required! + - block: pricing + id: pricing + content: + title: "Simple, transparent pricing" + subtitle: "Start for free. Upgrade when you're ready. No credit card required." + billing_toggle: + enabled: true + monthly_label: Monthly + yearly_label: Yearly + yearly_discount: "Save 20%" + tiers: + - name: Starter + description: "Perfect for personal projects and portfolios." + price: + monthly: "0" + yearly: "0" + currency: "$" + price_note: "Free forever" + highlight: false + cta: + text: Get started free + url: https://hugoblox.com/templates/ + icon: hero/arrow-right + style: outline + features: + - text: "5 pages" + included: true + - text: "1 GB storage" + included: true + - text: "Custom domain" + included: false + - text: Analytics dashboard + included: false + - text: Priority support + included: false + - name: Pro + badge: Most popular + description: "For freelancers and teams building seriously." + price: + monthly: "19" + yearly: "15" + currency: "$" + price_suffix: /month + price_note_monthly: "Billed monthly" + price_note_yearly: "Billed annually" + highlight: true + cta: + text: Start free trial + url: https://hugoblox.com/templates/ + icon: hero/arrow-right + style: primary + features: + - text: Unlimited pages + included: true + - text: "10 GB storage" + included: true + - text: Custom domain + included: true + - text: Analytics dashboard + included: true + - text: Priority support + included: false + - name: Business + description: "Scale with confidence and dedicated support." + price: + monthly: "49" + yearly: "39" + currency: "$" + price_suffix: /month + price_note_monthly: "Billed monthly" + price_note_yearly: "Billed annually" + highlight: false + cta: + text: Start free trial + url: https://hugoblox.com/templates/ + icon: hero/arrow-right + style: outline + features: + - text: Unlimited pages + included: true + - text: "100 GB storage" + included: true + - text: Custom domain + included: true + - text: Analytics dashboard + included: true + - text: Priority support + included: true + - block: cta-image-paragraph id: solutions content: