Skip to content

Commit 4c13c45

Browse files
committed
feat(blox): add pricing block
New block with 2–4 column tier grid, monthly/yearly billing toggle (per-billing price_note_monthly/yearly fields), highlighted tier with primary tint + scale, per-tier feature checklists (included/excluded + optional note), and per-tier CTA buttons. startup-landing-page template wired up with a 3-tier Starter/Pro/Business example.
1 parent 6aa2898 commit 4c13c45

5 files changed

Lines changed: 365 additions & 0 deletions

File tree

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import {render} from "preact";
2+
import {PricingBlock} from "./component.jsx";
3+
4+
function renderPricingBlocks() {
5+
const blocks = document.querySelectorAll('[data-block-type="pricing"]');
6+
blocks.forEach((block) => {
7+
const propsData = block.dataset.props;
8+
if (propsData) {
9+
const props = JSON.parse(propsData);
10+
render(<PricingBlock {...props} />, block);
11+
}
12+
});
13+
}
14+
15+
renderPricingBlocks();
Lines changed: 229 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,229 @@
1+
import {useState} from "preact/hooks";
2+
import {Icon} from "../../shared/components/Icon.jsx";
3+
4+
const CHECK_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"><path fill-rule="evenodd" d="M16.704 4.153a.75.75 0 01.143 1.052l-8 10.5a.75.75 0 01-1.127.075l-4.5-4.5a.75.75 0 011.06-1.06l3.894 3.893 7.48-9.817a.75.75 0 011.05-.143z" clip-rule="evenodd"/></svg>`;
5+
const X_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"><path d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z"/></svg>`;
6+
7+
function renderText(text) {
8+
if (!text) return "";
9+
return String(text)
10+
.replace(/\*\*(.*?)\*\*/g, "<strong>$1</strong>")
11+
.replace(/\*(.*?)\*/g, "<em>$1</em>")
12+
.replace(/`(.*?)`/g, "<code>$1</code>");
13+
}
14+
15+
function BillingToggle({billing, setBilling, toggle}) {
16+
const isYearly = billing === "yearly";
17+
return (
18+
<div class="flex items-center justify-center gap-4 mb-10">
19+
<span class={`text-sm transition-colors ${isYearly ? "font-normal text-gray-400 dark:text-gray-500" : "font-semibold text-gray-900 dark:text-white"}`}>
20+
{toggle.monthly_label || "Monthly"}
21+
</span>
22+
<button
23+
role="switch"
24+
aria-checked={isYearly}
25+
onClick={() => setBilling(isYearly ? "monthly" : "yearly")}
26+
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"}`}
27+
>
28+
<span class="sr-only">Toggle billing period</span>
29+
<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"}`} />
30+
</button>
31+
<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"}`}>
32+
{toggle.yearly_label || "Yearly"}
33+
{toggle.yearly_discount && (
34+
<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">
35+
{toggle.yearly_discount}
36+
</span>
37+
)}
38+
</span>
39+
</div>
40+
);
41+
}
42+
43+
function PriceDisplay({price = {}, price_suffix, price_note, price_note_monthly, price_note_yearly, billing}) {
44+
const raw = billing === "yearly" && price.yearly != null ? price.yearly : price.monthly;
45+
const currency = price.currency ?? "$";
46+
const isContact = raw === "" || raw == null;
47+
const isFree = String(raw) === "0";
48+
49+
// billing-aware note: per-billing field → static fallback
50+
const note = billing === "yearly"
51+
? (price_note_yearly ?? price_note)
52+
: (price_note_monthly ?? price_note);
53+
54+
if (isContact) {
55+
return (
56+
<div class="mb-6">
57+
<p class="text-3xl font-bold text-gray-900 dark:text-white">
58+
{price_note || "Contact us"}
59+
</p>
60+
</div>
61+
);
62+
}
63+
64+
if (isFree) {
65+
return (
66+
<div class="mb-6">
67+
<p class="text-5xl font-bold tracking-tight text-gray-900 dark:text-white">Free</p>
68+
{note && <p class="mt-1 text-sm text-gray-500 dark:text-gray-400">{note}</p>}
69+
</div>
70+
);
71+
}
72+
73+
return (
74+
<div class="mb-6">
75+
<div class="flex items-baseline gap-x-1">
76+
{currency && <span class="text-2xl font-semibold text-gray-900 dark:text-white">{currency}</span>}
77+
<span class="text-5xl font-bold tracking-tight text-gray-900 dark:text-white">{raw}</span>
78+
{price_suffix && <span class="text-base text-gray-500 dark:text-gray-400 ml-1">{price_suffix}</span>}
79+
</div>
80+
{note && <p class="mt-1 text-sm text-gray-500 dark:text-gray-400">{note}</p>}
81+
</div>
82+
);
83+
}
84+
85+
function FeatureRow({feature}) {
86+
const text = typeof feature === "string" ? feature : (feature.text ?? "");
87+
const included = typeof feature === "string" ? true : (feature.included !== false);
88+
const note = typeof feature === "object" ? feature.note : null;
89+
90+
return (
91+
<li class="flex items-start gap-3">
92+
<span
93+
class={`mt-0.5 h-5 w-5 flex-shrink-0 ${included ? "text-green-500 dark:text-green-400" : "text-gray-300 dark:text-gray-600"}`}
94+
dangerouslySetInnerHTML={{__html: included ? CHECK_SVG : X_SVG}}
95+
/>
96+
<span class={`text-sm leading-6 ${included ? "text-gray-700 dark:text-gray-300" : "text-gray-400 dark:text-gray-500"}`}>
97+
{text}
98+
{note && <span class="ml-1 text-xs text-gray-400 dark:text-gray-500">({note})</span>}
99+
</span>
100+
</li>
101+
);
102+
}
103+
104+
function PricingTier({tier, billing, icon_svgs}) {
105+
const {
106+
name = "",
107+
badge = "",
108+
description = "",
109+
price = {},
110+
price_suffix = "",
111+
price_note = "",
112+
price_note_monthly,
113+
price_note_yearly,
114+
highlight = false,
115+
cta = {},
116+
features = [],
117+
} = tier;
118+
119+
const ctaStyle = cta.style || (highlight ? "primary" : "outline");
120+
const ctaIconSvg = cta.icon ? (icon_svgs?.[cta.icon] ?? null) : null;
121+
const isExternalCta = cta.url && (cta.url.startsWith("http://") || cta.url.startsWith("https://"));
122+
123+
return (
124+
<div
125+
class={`relative flex flex-col rounded-3xl p-8 transition-shadow duration-200 ${
126+
highlight
127+
? "ring-2 ring-primary-500 shadow-2xl bg-primary-50 dark:bg-primary-900/10 scale-[1.03]"
128+
: "ring-1 ring-gray-200 dark:ring-gray-700 bg-white dark:bg-gray-800 hover:shadow-xl"
129+
}`}
130+
>
131+
{badge && (
132+
<div class="absolute -top-4 inset-x-0 flex justify-center">
133+
<span class="inline-flex items-center rounded-full bg-primary-600 px-4 py-1 text-xs font-semibold text-white shadow-sm">
134+
{badge}
135+
</span>
136+
</div>
137+
)}
138+
139+
<div class="mb-5">
140+
<h3 class="text-lg font-bold text-gray-900 dark:text-white">{name}</h3>
141+
{description && (
142+
<p class="mt-1.5 text-sm text-gray-500 dark:text-gray-400">{description}</p>
143+
)}
144+
</div>
145+
146+
<PriceDisplay
147+
price={price}
148+
price_suffix={price_suffix}
149+
price_note={price_note}
150+
price_note_monthly={price_note_monthly}
151+
price_note_yearly={price_note_yearly}
152+
billing={billing}
153+
/>
154+
155+
{cta.text && cta.url && (
156+
<a
157+
href={cta.url}
158+
{...(isExternalCta ? {target: "_blank", rel: "noopener noreferrer"} : {})}
159+
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 ${
160+
ctaStyle === "primary"
161+
? "bg-primary-600 text-white hover:bg-primary-700 shadow-sm hover:shadow-md"
162+
: "ring-1 ring-inset ring-primary-500 text-primary-600 dark:text-primary-400 hover:bg-primary-50 dark:hover:bg-primary-900/20"
163+
}`}
164+
>
165+
{cta.text}
166+
{ctaIconSvg && <Icon svg={ctaIconSvg} attributes={{class: "w-4 h-4 flex-shrink-0"}} />}
167+
</a>
168+
)}
169+
170+
{features.length > 0 && (
171+
<ul class="mt-auto space-y-3 border-t border-gray-100 dark:border-gray-700 pt-6">
172+
{features.map((f, i) => <FeatureRow key={i} feature={f} />)}
173+
</ul>
174+
)}
175+
</div>
176+
);
177+
}
178+
179+
export const PricingBlock = ({content = {}, design = {}, icon_svgs = {}}) => {
180+
const {title, subtitle, billing_toggle = {}, tiers = []} = content;
181+
182+
const hasYearlyPrices = tiers.some(
183+
(t) => t.price?.yearly != null && t.price.yearly !== t.price.monthly
184+
);
185+
const showToggle = billing_toggle.enabled && hasYearlyPrices;
186+
187+
const [billing, setBilling] = useState("monthly");
188+
189+
const colsClass =
190+
tiers.length === 1
191+
? "max-w-sm mx-auto"
192+
: tiers.length === 2
193+
? "max-w-4xl mx-auto grid-cols-1 sm:grid-cols-2"
194+
: "grid-cols-1 md:grid-cols-3";
195+
196+
return (
197+
<div class="py-16 sm:py-24 px-4 sm:px-6 lg:px-8">
198+
<div class="max-w-7xl mx-auto">
199+
{(title || subtitle) && (
200+
<div class="text-center max-w-3xl mx-auto mb-10 lg:mb-14">
201+
{title && (
202+
<h2
203+
class="text-3xl sm:text-4xl lg:text-5xl font-bold text-gray-900 dark:text-white tracking-tight mb-4"
204+
dangerouslySetInnerHTML={{__html: renderText(title)}}
205+
/>
206+
)}
207+
{subtitle && (
208+
<p
209+
class="text-lg text-gray-600 dark:text-gray-400"
210+
dangerouslySetInnerHTML={{__html: renderText(subtitle)}}
211+
/>
212+
)}
213+
</div>
214+
)}
215+
216+
{showToggle && (
217+
<BillingToggle billing={billing} setBilling={setBilling} toggle={billing_toggle} />
218+
)}
219+
220+
{/* pt-6 clears space for the absolutely-positioned badge on the highlighted tier */}
221+
<div class={`grid gap-8 pt-6 ${colsClass}`}>
222+
{tiers.map((tier, i) => (
223+
<PricingTier key={i} tier={tier} billing={billing} icon_svgs={icon_svgs} />
224+
))}
225+
</div>
226+
</div>
227+
</div>
228+
);
229+
};
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
{
2+
"id": "pricing",
3+
"name": "Pricing",
4+
"version": "1.0.0",
5+
"license": "MIT",
6+
"category": "conversion",
7+
"intent": ["convert", "inform"],
8+
"verticals": ["startup", "saas", "agency", "dev-portfolio", "link-in-bio"],
9+
"tags": ["pricing", "plans", "tiers", "billing", "saas", "subscription", "conversion"],
10+
"description": "Display pricing tiers with optional monthly/yearly billing toggle, feature checklists, highlighted tiers, badges, and per-tier CTAs",
11+
"author": "Hugo Blox",
12+
"homepage": "https://hugoblox.com/blocks/",
13+
"repository": "https://github.com/HugoBlox/kit",
14+
"keywords": ["hugo", "static-site", "pricing", "plans", "saas", "billing"]
15+
}

modules/blox/layouts/_partials/blox/preact-wrapper.html

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,23 @@
148148
{{ $props = merge $props (dict "icon_svgs" $icon_svgs) }}
149149
{{ end }}
150150

151+
{{/* Resolve CTA icons from pricing tiers (content.tiers[*].cta.icon) */}}
152+
{{ with $block.content.tiers }}
153+
{{ range . }}
154+
{{ with .cta.icon }}
155+
{{ if not (index $icon_svgs .) }}
156+
{{ $icon_data := partial "functions/get_icon_data" (dict "name" .) }}
157+
{{ if $icon_data }}
158+
{{ $icon_svgs = merge $icon_svgs (dict . $icon_data) }}
159+
{{ end }}
160+
{{ end }}
161+
{{ end }}
162+
{{ end }}
163+
{{ end }}
164+
{{ if gt (len $icon_svgs) 0 }}
165+
{{ $props = merge $props (dict "icon_svgs" $icon_svgs) }}
166+
{{ end }}
167+
151168
{{/* Convert props to JSON for client-side rendering */}}
152169
{{ $propsJSON := $props | jsonify }}
153170

templates/startup-landing-page/content/_index.md

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,95 @@ sections:
8181
- name: Swappable Blocks
8282
icon: rectangle-group
8383
description: Build your pages with blocks - no coding required!
84+
- block: pricing
85+
id: pricing
86+
content:
87+
title: "Simple, transparent pricing"
88+
subtitle: "Start for free. Upgrade when you're ready. No credit card required."
89+
billing_toggle:
90+
enabled: true
91+
monthly_label: Monthly
92+
yearly_label: Yearly
93+
yearly_discount: "Save 20%"
94+
tiers:
95+
- name: Starter
96+
description: "Perfect for personal projects and portfolios."
97+
price:
98+
monthly: "0"
99+
yearly: "0"
100+
currency: "$"
101+
price_note: "Free forever"
102+
highlight: false
103+
cta:
104+
text: Get started free
105+
url: https://hugoblox.com/templates/
106+
icon: hero/arrow-right
107+
style: outline
108+
features:
109+
- text: "5 pages"
110+
included: true
111+
- text: "1 GB storage"
112+
included: true
113+
- text: "Custom domain"
114+
included: false
115+
- text: Analytics dashboard
116+
included: false
117+
- text: Priority support
118+
included: false
119+
- name: Pro
120+
badge: Most popular
121+
description: "For freelancers and teams building seriously."
122+
price:
123+
monthly: "19"
124+
yearly: "15"
125+
currency: "$"
126+
price_suffix: /month
127+
price_note_monthly: "Billed monthly"
128+
price_note_yearly: "Billed annually"
129+
highlight: true
130+
cta:
131+
text: Start free trial
132+
url: https://hugoblox.com/templates/
133+
icon: hero/arrow-right
134+
style: primary
135+
features:
136+
- text: Unlimited pages
137+
included: true
138+
- text: "10 GB storage"
139+
included: true
140+
- text: Custom domain
141+
included: true
142+
- text: Analytics dashboard
143+
included: true
144+
- text: Priority support
145+
included: false
146+
- name: Business
147+
description: "Scale with confidence and dedicated support."
148+
price:
149+
monthly: "49"
150+
yearly: "39"
151+
currency: "$"
152+
price_suffix: /month
153+
price_note_monthly: "Billed monthly"
154+
price_note_yearly: "Billed annually"
155+
highlight: false
156+
cta:
157+
text: Start free trial
158+
url: https://hugoblox.com/templates/
159+
icon: hero/arrow-right
160+
style: outline
161+
features:
162+
- text: Unlimited pages
163+
included: true
164+
- text: "100 GB storage"
165+
included: true
166+
- text: Custom domain
167+
included: true
168+
- text: Analytics dashboard
169+
included: true
170+
- text: Priority support
171+
included: true
172+
84173
- block: cta-image-paragraph
85174
id: solutions
86175
content:

0 commit comments

Comments
 (0)