diff --git a/.changeset/page-builder-137-pricing-header.md b/.changeset/page-builder-137-pricing-header.md new file mode 100644 index 0000000..6613e95 --- /dev/null +++ b/.changeset/page-builder-137-pricing-header.md @@ -0,0 +1,11 @@ +--- +"@frontlit/page-builder": minor +--- + +Pricing and Header block fixes, and block icons (#137). + +- Pricing: the grid now has one column per plan (up to 4) on desktop, so 1–2 plans fill the row; a single plan is capped at `max-w-xl`. +- Pricing: optional Monthly/Yearly switch (`pricingSwitcher`, `monthlyPriceCaption`, `yearlyPriceCaption`, `defaultPeriod`) with per-tier `priceYearly` and `buttonActionYearly`, each falling back to the monthly value. Both prices are server-rendered; a small client island flips between them. +- Header: the mobile menu button is hidden when the header has no links or CTA. +- Header: optional GitHub link (`githubRepo` as `owner/repo` or a github.com URL) with an optional star count (`showGithubStars`), fetched in the browser. +- Every built-in block has its own icon (`metadata.icon`) in the "Add a block" picker and the block settings header, instead of one shared layout icon. diff --git a/lint-staged.config.cjs b/lint-staged.config.cjs index 29ff464..b50bd20 100644 --- a/lint-staged.config.cjs +++ b/lint-staged.config.cjs @@ -19,7 +19,8 @@ module.exports = (files) => { const eslintFiles = eligibleFiles.filter((file) => ESLINT_EXTENSIONS.test(file)); return [ - biomeFiles.length > 0 && `biome format --write ${biomeFiles.map(quote).join(" ")}`, + biomeFiles.length > 0 && + `biome format --write --no-errors-on-unmatched ${biomeFiles.map(quote).join(" ")}`, eslintFiles.length > 0 && `eslint --quiet --cache --fix ${eslintFiles.map(quote).join(" ")}`, ].filter(Boolean); diff --git a/packages/page-builder/AGENTS.md b/packages/page-builder/AGENTS.md index 8f6c026..2cdf5bd 100644 --- a/packages/page-builder/AGENTS.md +++ b/packages/page-builder/AGENTS.md @@ -1,3 +1,7 @@ ## Development Tips - Copy all page related packages like page-primitives, page-blocks etc., into this package. Converge all of them into this package. + +## Testing + +- When smoke-testing block or theme settings in the builder, don't stop at setting a value. Also reset each field you changed back to its original state, by clearing the text or with the field's reset button if it has one. Check that the change still saves ("Draft saved", not "Couldn't save changes") and that the canvas goes back to how it looked. The builder autosaves on every keystroke, so empty and partly typed values must save too. diff --git a/packages/page-builder/schema/page.v1.json b/packages/page-builder/schema/page.v1.json index 6c3310f..887f890 100644 --- a/packages/page-builder/schema/page.v1.json +++ b/packages/page-builder/schema/page.v1.json @@ -2243,6 +2243,26 @@ ], "default": "center" }, + "pricingSwitcher": { + "type": "boolean", + "description": "Show a Monthly/Yearly switch above the plans." + }, + "monthlyPriceCaption": { + "type": "string", + "description": "Defaults to \"Monthly\"." + }, + "yearlyPriceCaption": { + "type": "string", + "description": "Defaults to \"Yearly\", e.g. \"Yearly (2 months free)\"." + }, + "defaultPeriod": { + "type": "string", + "enum": [ + "monthly", + "yearly" + ], + "description": "Period selected on load. Defaults to yearly." + }, "tiers": { "type": "array", "items": { @@ -2253,7 +2273,11 @@ }, "price": { "type": "string", - "description": "Display string, e.g. \"$29/mo\"." + "description": "Display string, e.g. \"$29/month\". The monthly price when pricingSwitcher is on." + }, + "priceYearly": { + "type": "string", + "description": "Shown when the switch is on Yearly, e.g. \"$290/year\". Falls back to price." }, "description": { "type": "string" @@ -2271,6 +2295,10 @@ "type": "string", "description": "A site path (\"/pricing\"), anchor (\"/#faq\") or absolute URL." }, + "buttonActionYearly": { + "type": "string", + "description": "A site path (\"/pricing\"), anchor (\"/#faq\") or absolute URL." + }, "highlighted": { "type": "boolean" } diff --git a/packages/page-builder/schema/site.v1.json b/packages/page-builder/schema/site.v1.json index a45f699..5f579d5 100644 --- a/packages/page-builder/schema/site.v1.json +++ b/packages/page-builder/schema/site.v1.json @@ -3489,6 +3489,14 @@ "minimum": 0, "maximum": 8, "default": 2 + }, + "githubRepo": { + "type": "string", + "description": "GitHub repository as \"owner/repo\" or a github.com URL. Shows a GitHub icon link; other values are ignored." + }, + "showGithubStars": { + "type": "boolean", + "description": "Show the repository's star count next to the GitHub icon." } }, "additionalProperties": false, diff --git a/packages/page-builder/src/blocks/banner/metadata.ts b/packages/page-builder/src/blocks/banner/metadata.ts index db998b3..a0dc34b 100644 --- a/packages/page-builder/src/blocks/banner/metadata.ts +++ b/packages/page-builder/src/blocks/banner/metadata.ts @@ -5,4 +5,5 @@ export const bannerMetadata: WidgetMetadata = { displayName: "Banner", description: "A strong, punchy call-to-action strip.", compatibleWith: ["site", "landing", "blog", "post", "custom"], + icon: "Megaphone", }; diff --git a/packages/page-builder/src/blocks/content/metadata.ts b/packages/page-builder/src/blocks/content/metadata.ts index 691b4f1..57c927c 100644 --- a/packages/page-builder/src/blocks/content/metadata.ts +++ b/packages/page-builder/src/blocks/content/metadata.ts @@ -6,4 +6,5 @@ export const contentMetadata: WidgetMetadata = { description: "A rich-text section with an optional image, for things like an about-us or feature explainer.", compatibleWith: ["site", "landing", "blog", "post", "custom"], + icon: "Newspaper", }; diff --git a/packages/page-builder/src/blocks/data-slot/metadata.ts b/packages/page-builder/src/blocks/data-slot/metadata.ts index 0c5c028..6b8daf3 100644 --- a/packages/page-builder/src/blocks/data-slot/metadata.ts +++ b/packages/page-builder/src/blocks/data-slot/metadata.ts @@ -6,4 +6,5 @@ export const dataSlotMetadata: WidgetMetadata = { description: "A host-resolved data region. Its data source and presentation belong to the embedding application.", compatibleWith: ["site", "blog", "landing", "post", "custom"], + icon: "Database", }; diff --git a/packages/page-builder/src/blocks/email-form/metadata.ts b/packages/page-builder/src/blocks/email-form/metadata.ts index 2b8c26a..0c8438a 100644 --- a/packages/page-builder/src/blocks/email-form/metadata.ts +++ b/packages/page-builder/src/blocks/email-form/metadata.ts @@ -5,4 +5,5 @@ export const emailFormMetadata: WidgetMetadata = { displayName: "Email signup", description: "An email signup form with a title, description and email input.", compatibleWith: ["site", "landing", "blog", "post", "custom"], + icon: "Mail", }; diff --git a/packages/page-builder/src/blocks/embed/metadata.ts b/packages/page-builder/src/blocks/embed/metadata.ts index c24632a..6c8ee01 100644 --- a/packages/page-builder/src/blocks/embed/metadata.ts +++ b/packages/page-builder/src/blocks/embed/metadata.ts @@ -6,4 +6,5 @@ export const embedMetadata: WidgetMetadata = { description: "Embed a URL in an iframe, or paste a script/iframe snippet from a third-party tool.", compatibleWith: ["site", "landing", "blog", "post", "custom"], + icon: "AppWindow", }; diff --git a/packages/page-builder/src/blocks/faq/metadata.ts b/packages/page-builder/src/blocks/faq/metadata.ts index 733cf7c..172ebe8 100644 --- a/packages/page-builder/src/blocks/faq/metadata.ts +++ b/packages/page-builder/src/blocks/faq/metadata.ts @@ -5,4 +5,5 @@ export const faqMetadata: WidgetMetadata = { displayName: "FAQs", description: "A list of frequently asked questions, rendered as an accordion.", compatibleWith: ["site", "landing", "blog", "post", "custom"], + icon: "CircleHelp", }; diff --git a/packages/page-builder/src/blocks/featured/metadata.ts b/packages/page-builder/src/blocks/featured/metadata.ts index 693890b..976005a 100644 --- a/packages/page-builder/src/blocks/featured/metadata.ts +++ b/packages/page-builder/src/blocks/featured/metadata.ts @@ -6,4 +6,5 @@ export const featuredMetadata: WidgetMetadata = { description: "A grid of feature highlights, each with an icon, title and description.", compatibleWith: ["site", "landing", "custom"], + icon: "LayoutGrid", }; diff --git a/packages/page-builder/src/blocks/footer/metadata.ts b/packages/page-builder/src/blocks/footer/metadata.ts index 1c77913..b141cfa 100644 --- a/packages/page-builder/src/blocks/footer/metadata.ts +++ b/packages/page-builder/src/blocks/footer/metadata.ts @@ -5,4 +5,5 @@ export const footerMetadata: WidgetMetadata = { displayName: "Footer", description: "The site footer, pinned to the bottom of every page.", compatibleWith: ["site", "landing", "blog", "post", "custom"], + icon: "PanelBottom", }; diff --git a/packages/page-builder/src/blocks/grid/metadata.ts b/packages/page-builder/src/blocks/grid/metadata.ts index 0c30a1e..f50a838 100644 --- a/packages/page-builder/src/blocks/grid/metadata.ts +++ b/packages/page-builder/src/blocks/grid/metadata.ts @@ -6,4 +6,5 @@ export const gridMetadata: WidgetMetadata = { description: "A header with a page-level call to action, plus a responsive grid of cards — each with its own icon or image, title, description and link.", compatibleWith: ["site", "landing", "blog", "post", "custom"], + icon: "Grid2x2", }; diff --git a/packages/page-builder/src/blocks/header/editor.tsx b/packages/page-builder/src/blocks/header/editor.tsx index d5f793d..62683a9 100644 --- a/packages/page-builder/src/blocks/header/editor.tsx +++ b/packages/page-builder/src/blocks/header/editor.tsx @@ -83,6 +83,22 @@ export function HeaderEditor({ onChange={(ctaHref) => update({ ctaHref })} /> + + update({ githubRepo })} + /> + {settings.githubRepo && ( + update({ showGithubStars })} + /> + )} + (); + + useEffect(() => { + const controller = new AbortController(); + fetch(`https://api.github.com/repos/${repo}`, { signal: controller.signal }) + .then((response) => (response.ok ? response.json() : undefined)) + .then((data) => setStars(data?.stargazers_count)) + .catch(() => {}); + return () => controller.abort(); + }, [repo]); + + if (!stars) return null; + return {formatStars(stars)}; +} diff --git a/packages/page-builder/src/blocks/header/github.test.tsx b/packages/page-builder/src/blocks/header/github.test.tsx new file mode 100644 index 0000000..40e117d --- /dev/null +++ b/packages/page-builder/src/blocks/header/github.test.tsx @@ -0,0 +1,64 @@ +import { describe, expect, it } from "vitest"; +import React from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { themes } from "../../primitives/themes"; +import { HeaderWidget } from "./widget"; +import { formatStars, parseGithubRepo } from "./github"; +import type { HeaderSettings } from "./settings"; + +const state = { theme: themes[0] }; +const render = (settings: HeaderSettings) => + renderToStaticMarkup( + , + ); + +describe("header GitHub link", () => { + it.each([ + ["codelitdev/medialit", "codelitdev/medialit"], + ["https://github.com/codelitdev/medialit", "codelitdev/medialit"], + ["https://github.com/codelitdev/medialit/", "codelitdev/medialit"], + ["github.com/codelitdev/medialit", undefined], + ["medialit", undefined], + ["", undefined], + ])("parses %j", (value, repo) => { + expect(parseGithubRepo(value)).toBe(repo); + }); + + it("formats star counts compactly", () => { + expect(formatStars(20)).toBe("20"); + expect(formatStars(1234)).toBe("1.2k"); + }); + + it("is hidden by default", () => { + expect(render({})).not.toContain("github.com"); + }); + + it.each(["fixed", "floating", "centered"] as const)( + "links to the repo in a new tab (%s layout)", + (layout) => { + const html = render({ + layout, + githubRepo: "https://github.com/codelitdev/medialit", + }); + expect(html).toContain('href="https://github.com/codelitdev/medialit"'); + expect(html).toContain('target="_blank"'); + expect(html).toContain("GitHub repository"); + }, + ); +}); + +describe("header mobile menu", () => { + it("is hidden when there are no links or CTA", () => { + expect(render({ links: [] })).not.toContain("Toggle menu"); + expect(render({ links: [], ctaLabel: "Go" })).not.toContain("Toggle menu"); + }); + + it("shows for links or a CTA", () => { + expect(render({ links: [{ id: "1", label: "Docs", href: "/docs" }] })).toContain( + "Toggle menu", + ); + expect(render({ links: [], ctaLabel: "Go", ctaHref: "/go" })).toContain( + "Toggle menu", + ); + }); +}); diff --git a/packages/page-builder/src/blocks/header/github.ts b/packages/page-builder/src/blocks/header/github.ts new file mode 100644 index 0000000..d7c257e --- /dev/null +++ b/packages/page-builder/src/blocks/header/github.ts @@ -0,0 +1,17 @@ +/** `owner/repo` from `owner/repo` or a `https://github.com/owner/repo` URL. */ +export function parseGithubRepo(value?: string): string | undefined { + const match = value + ?.trim() + .match(/^(?:https?:\/\/(?:www\.)?github\.com\/)?([\w.-]+\/[\w.-]+?)(?:\.git)?\/?$/); + return match?.[1]; +} + +/** 1234 → "1.2k", like GitHub's own star count. */ +export function formatStars(count: number): string { + return new Intl.NumberFormat("en", { + notation: "compact", + maximumFractionDigits: 1, + }) + .format(count) + .toLowerCase(); +} diff --git a/packages/page-builder/src/blocks/header/metadata.ts b/packages/page-builder/src/blocks/header/metadata.ts index c3477cb..e300e25 100644 --- a/packages/page-builder/src/blocks/header/metadata.ts +++ b/packages/page-builder/src/blocks/header/metadata.ts @@ -5,4 +5,5 @@ export const headerMetadata: WidgetMetadata = { displayName: "Header", description: "The site navigation bar, pinned to the top of every page.", compatibleWith: ["site", "landing", "blog", "post", "custom"], + icon: "PanelTop", }; diff --git a/packages/page-builder/src/blocks/header/settings.ts b/packages/page-builder/src/blocks/header/settings.ts index 3e7d2b4..c5588fa 100644 --- a/packages/page-builder/src/blocks/header/settings.ts +++ b/packages/page-builder/src/blocks/header/settings.ts @@ -15,4 +15,7 @@ export interface HeaderSettings extends WidgetDefaultSettings { linkAlignment?: "left" | "center" | "right"; layout?: "fixed" | "floating" | "centered"; backdropBlur?: boolean; + /** `owner/repo` (or a github.com URL). Shows a GitHub icon link. */ + githubRepo?: string; + showGithubStars?: boolean; } diff --git a/packages/page-builder/src/blocks/header/widget.tsx b/packages/page-builder/src/blocks/header/widget.tsx index 83dcd23..730b264 100644 --- a/packages/page-builder/src/blocks/header/widget.tsx +++ b/packages/page-builder/src/blocks/header/widget.tsx @@ -1,5 +1,6 @@ import React from "react"; import clsx from "clsx"; +import { Github } from "lucide-react"; import type { WidgetProps, ThemeStyle } from "../../models"; import type { HeaderSettings } from "./settings"; import { Image } from "../../components/image"; @@ -7,6 +8,8 @@ import { NavLink } from "../../components/nav-link"; import { Button, Header4, Section } from "../../primitives"; import { HeaderLink } from "./header-link"; import { HeaderMobileMenu } from "./mobile-menu"; +import { GithubStars } from "./github-stars"; +import { parseGithubRepo } from "./github"; export function HeaderWidget({ settings: { @@ -17,6 +20,8 @@ export function HeaderWidget({ linkAlignment = "left", layout = "fixed", backdropBlur = false, + githubRepo, + showGithubStars, cssId, maxWidth, verticalPadding, @@ -40,6 +45,8 @@ export function HeaderWidget({ const resolvedLogoText = pageData.siteTitle; const resolvedLogoAlt = pageData.siteLogoAlt || resolvedLogoText || ""; const effectiveLogoCorners = logoCorners ?? 2; + const hasCta = Boolean(ctaLabel && ctaHref); + const repo = parseGithubRepo(githubRepo); const logo = ( @@ -66,12 +73,25 @@ export function HeaderWidget({ const ctaAndMenu = (
+ {repo && ( + + + + )} {buttonLinks.map((link) => (
))} - {ctaLabel && ctaHref && ( + {hasCta && (
)} -
- -
+ {(links.length > 0 || hasCta) && ( +
+ +
+ )}
); diff --git a/packages/page-builder/src/blocks/hero/metadata.ts b/packages/page-builder/src/blocks/hero/metadata.ts index 4e1d249..4860306 100644 --- a/packages/page-builder/src/blocks/hero/metadata.ts +++ b/packages/page-builder/src/blocks/hero/metadata.ts @@ -5,4 +5,5 @@ export const heroMetadata: WidgetMetadata = { displayName: "Hero", description: "A large introductory section with a title, copy and CTAs.", compatibleWith: ["site", "landing", "blog", "post", "custom"], + icon: "Sparkles", }; diff --git a/packages/page-builder/src/blocks/marquee/metadata.ts b/packages/page-builder/src/blocks/marquee/metadata.ts index 2d09bfa..56cce4f 100644 --- a/packages/page-builder/src/blocks/marquee/metadata.ts +++ b/packages/page-builder/src/blocks/marquee/metadata.ts @@ -6,4 +6,5 @@ export const marqueeMetadata: WidgetMetadata = { description: "An infinite horizontally-scrolling row of text or icons — useful for logo clouds or highlight tickers.", compatibleWith: ["site", "landing", "blog", "post", "custom"], + icon: "GalleryHorizontalEnd", }; diff --git a/packages/page-builder/src/blocks/media/metadata.ts b/packages/page-builder/src/blocks/media/metadata.ts index 136ad70..7effc0d 100644 --- a/packages/page-builder/src/blocks/media/metadata.ts +++ b/packages/page-builder/src/blocks/media/metadata.ts @@ -6,4 +6,5 @@ export const mediaMetadata: WidgetMetadata = { description: "A single image or video — YouTube, Vimeo or self-hosted — with an optional click-to-play preview.", compatibleWith: ["site", "landing", "blog", "post", "custom"], + icon: "Image", }; diff --git a/packages/page-builder/src/blocks/pricing/editor.tsx b/packages/page-builder/src/blocks/pricing/editor.tsx index 6c664c0..c23b817 100644 --- a/packages/page-builder/src/blocks/pricing/editor.tsx +++ b/packages/page-builder/src/blocks/pricing/editor.tsx @@ -54,6 +54,38 @@ export function PricingEditor({ ]} />
+ + update({ pricingSwitcher })} + /> + {settings.pricingSwitcher && ( + <> + update({ monthlyPriceCaption })} + /> + update({ yearlyPriceCaption })} + /> + update({ defaultPeriod })} + options={[ + { label: "Monthly", value: "monthly" }, + { label: "Yearly", value: "yearly" }, + ]} + /> + + )} + label="Tiers" @@ -69,11 +101,20 @@ export function PricingEditor({ onChange={(name) => updateTier({ name })} /> updateTier({ price })} /> + {settings.pricingSwitcher && ( + updateTier({ priceYearly })} + /> + )} updateTier({ buttonAction })} /> + {settings.pricingSwitcher && ( + updateTier({ buttonActionYearly })} + /> + )} (defaultPeriod); + const id = useId(); + const monthlyId = `${id}-monthly`; + const yearlyId = `${id}-yearly`; + + return ( +
+
+ + setPeriod(checked ? "yearly" : "monthly")} + aria-labelledby={yearlyId} + aria-describedby={monthlyId} + /> + +
+ {children} +
+ ); +} diff --git a/packages/page-builder/src/blocks/pricing/pricing.test.tsx b/packages/page-builder/src/blocks/pricing/pricing.test.tsx new file mode 100644 index 0000000..4357f71 --- /dev/null +++ b/packages/page-builder/src/blocks/pricing/pricing.test.tsx @@ -0,0 +1,88 @@ +import { describe, expect, it } from "vitest"; +import React from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { themes } from "../../primitives/themes"; +import { PricingWidget } from "./widget"; +import type { PricingSettings, PricingTier } from "./settings"; + +const state = { theme: themes[0] }; + +const tier = (n: number, extra: Partial = {}): PricingTier => ({ + name: `Plan ${n}`, + price: `$${n}/month`, + features: [], + buttonCaption: "Buy", + buttonAction: `/buy-${n}`, + ...extra, +}); + +const render = (settings: PricingSettings) => + renderToStaticMarkup( + , + ); + +describe("pricing columns", () => { + it.each([ + [1, "lg:grid-cols-1"], + [2, "lg:grid-cols-2"], + [3, "lg:grid-cols-3"], + [4, "lg:grid-cols-4"], + [5, "lg:grid-cols-4"], + ])("uses one column per plan for %i plans, up to 4", (count, cols) => { + const html = render({ + tiers: Array.from({ length: count }, (_, i) => tier(i + 1)), + }); + expect(html).toContain(cols); + expect(html.match(/lg:grid-cols-\d/g)).toHaveLength(1); + expect(html.includes("md:grid-cols-2")).toBe(count >= 2); + }); + + it("caps the width of a single plan", () => { + expect(render({ tiers: [tier(1)] })).toContain("max-w-xl"); + }); +}); + +describe("pricing period switch", () => { + it("shows a single price and no switch by default", () => { + const html = render({ tiers: [tier(1, { priceYearly: "$10/year" })] }); + expect(html).not.toContain('role="switch"'); + expect(html).not.toContain("$10/year"); + }); + + it("renders both prices and a switch defaulting to yearly", () => { + const html = render({ + pricingSwitcher: true, + yearlyPriceCaption: "Yearly (2 months free)", + tiers: [tier(1, { priceYearly: "$10/year" }), tier(2)], + }); + expect(html).toContain('role="switch"'); + expect(html).toContain('aria-checked="true"'); + expect(html).toContain('data-period="yearly"'); + expect(html).toContain("Monthly"); + expect(html).toContain("Yearly (2 months free)"); + expect(html).toContain("$1/month"); + expect(html).toContain("$10/year"); + // Without a yearly price, the monthly one is shown for both periods. + expect(html.match(/\$2\/month/g)).toHaveLength(2); + }); + + it("honours defaultPeriod", () => { + const html = render({ + pricingSwitcher: true, + defaultPeriod: "monthly", + tiers: [tier(1)], + }); + expect(html).toContain('data-period="monthly"'); + expect(html).toContain('aria-checked="false"'); + }); + + it("uses the yearly button link when set", () => { + const html = render({ + pricingSwitcher: true, + tiers: [tier(1, { buttonActionYearly: "/buy-1-yearly" }), tier(2)], + }); + expect(html).toContain('href="/buy-1"'); + expect(html).toContain('href="/buy-1-yearly"'); + expect(html.match(/href="\/buy-2"/g)).toHaveLength(1); + }); +}); diff --git a/packages/page-builder/src/blocks/pricing/settings.ts b/packages/page-builder/src/blocks/pricing/settings.ts index 23ef0b4..e18c854 100644 --- a/packages/page-builder/src/blocks/pricing/settings.ts +++ b/packages/page-builder/src/blocks/pricing/settings.ts @@ -5,14 +5,21 @@ import type { WidgetDefaultSettings, } from "../../models"; +export type PricingPeriod = "monthly" | "yearly"; + export interface PricingTier { name: string; - /** Plain display string, e.g. "$29/mo". No currency/period logic. */ + /** Plain display string, e.g. "$29". No currency/period logic. With the + * period switch on, this is the monthly price. */ price: string; + /** Shown when the switch is on Yearly. Falls back to `price`. */ + priceYearly?: string; description?: string; features: string[]; buttonCaption?: string; buttonAction?: string; + /** Button link when the switch is on Yearly. Falls back to `buttonAction`. */ + buttonActionYearly?: string; highlighted?: boolean; } @@ -22,4 +29,10 @@ export interface PricingSettings extends WidgetDefaultSettings, TitleSettings { headerAlignment?: Alignment; tiers?: PricingTier[]; layout?: "grid" | "stacked" | "highlight"; + /** Show a Monthly/Yearly switch above the plans. */ + pricingSwitcher?: boolean; + monthlyPriceCaption?: string; + yearlyPriceCaption?: string; + /** Period selected when the page loads. Defaults to yearly. */ + defaultPeriod?: PricingPeriod; } diff --git a/packages/page-builder/src/blocks/pricing/widget.tsx b/packages/page-builder/src/blocks/pricing/widget.tsx index 3503be0..a9e601e 100644 --- a/packages/page-builder/src/blocks/pricing/widget.tsx +++ b/packages/page-builder/src/blocks/pricing/widget.tsx @@ -1,7 +1,8 @@ import React from "react"; import clsx from "clsx"; import type { ThemeStyle, WidgetProps } from "../../models"; -import type { PricingSettings } from "./settings"; +import type { PricingSettings, PricingTier } from "./settings"; +import { PricingPeriodSwitch } from "./period-switch"; import { renderTitle, titleProps } from "../_shared/title"; import { NavLink } from "../../components/nav-link"; import { TextRenderer } from "../../components/text-renderer"; @@ -19,6 +20,19 @@ import { Text2, } from "../../primitives"; +// Static class names so Tailwind keeps them. 4+ plans wrap at 4 per row. +const lgColumns: Record = { + 1: "lg:grid-cols-1", + 2: "lg:grid-cols-2", + 3: "lg:grid-cols-3", + 4: "lg:grid-cols-4", +}; + +// With the period switch on, both values are rendered and the wrapper's +// `data-period` (set by `PricingPeriodSwitch`) picks which one shows. +const monthlyOnly = "group-data-[period=yearly]/pricing:hidden"; +const yearlyOnly = "hidden group-data-[period=yearly]/pricing:block"; + export function PricingWidget({ settings: { title, @@ -28,6 +42,10 @@ export function PricingWidget({ headerAlignment = "center", tiers, layout = "grid", + pricingSwitcher, + monthlyPriceCaption, + yearlyPriceCaption, + defaultPeriod = "yearly", cssId, maxWidth, verticalPadding, @@ -40,6 +58,90 @@ export function PricingWidget({ overriddenTheme.structure.section.padding.y = verticalPadding || theme.theme.structure.section.padding.y; + const plans = tiers && tiers.length > 0 && ( +
= 2 && "md:grid-cols-2", + lgColumns[Math.min(tiers.length, 4)], + ], + )} + > + {tiers.map((tier, index) => ( + + +
+ {tier.name} + {tier.highlighted && Most popular} +
+ + {pricingSwitcher ? ( + <> + {tier.price} + {tier.priceYearly || tier.price} + + ) : ( + tier.price + )} + + {tier.description && ( + {tier.description} + )} + {tier.features && tier.features.length > 0 && ( +
+ {tier.features.map((feature, i) => ( + + {feature} + + ))} +
+ )} + {pricingSwitcher && + tier.buttonActionYearly && + tier.buttonActionYearly !== tier.buttonAction ? ( + <> + + + + ) : ( + + )} +
+
+ ))} +
+ ); + return (
@@ -58,65 +160,46 @@ export function PricingWidget({ )}
- {tiers && tiers.length > 0 && ( -
- {tiers.map((tier, index) => ( - - -
- {tier.name} - {tier.highlighted && ( - Most popular - )} -
- {tier.price} - {tier.description && ( - {tier.description} - )} - {tier.features && tier.features.length > 0 && ( -
- {tier.features.map((feature, i) => ( - - {feature} - - ))} -
- )} - {tier.buttonAction && tier.buttonCaption && ( - - - - )} -
-
- ))} -
- )} + {tiers && + tiers.length > 0 && + (pricingSwitcher ? ( + + {plans} + + ) : ( + plans + ))}
); } + +function TierButton({ + tier, + href, + className, + theme, +}: { + tier: PricingTier; + href?: string; + className?: string; + theme: ThemeStyle; +}) { + if (!href || !tier.buttonCaption) return null; + return ( + + + + ); +} diff --git a/packages/page-builder/src/blocks/rich-text/metadata.ts b/packages/page-builder/src/blocks/rich-text/metadata.ts index 5a01f16..7776d67 100644 --- a/packages/page-builder/src/blocks/rich-text/metadata.ts +++ b/packages/page-builder/src/blocks/rich-text/metadata.ts @@ -6,4 +6,5 @@ export const richTextMetadata: WidgetMetadata = { description: "A block of formatted text — paragraphs, headings, lists and links — with no title or media.", compatibleWith: ["site", "landing", "blog", "post", "custom"], + icon: "Type", }; diff --git a/packages/page-builder/src/blocks/support-form/metadata.ts b/packages/page-builder/src/blocks/support-form/metadata.ts index 0ec39a4..4779071 100644 --- a/packages/page-builder/src/blocks/support-form/metadata.ts +++ b/packages/page-builder/src/blocks/support-form/metadata.ts @@ -6,4 +6,5 @@ export const supportFormMetadata: WidgetMetadata = { description: "Raises a support ticket, suggesting matching help articles before the visitor submits.", compatibleWith: ["site", "landing", "custom"], + icon: "LifeBuoy", }; diff --git a/packages/page-builder/src/builder/block-picker.test.ts b/packages/page-builder/src/builder/block-picker.test.ts new file mode 100644 index 0000000..00b9669 --- /dev/null +++ b/packages/page-builder/src/builder/block-picker.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from "vitest"; +import { LayoutTemplate } from "lucide-react"; +import { blockRegistry } from "../registry"; +import { resolveIcon } from "./block-picker"; + +describe("block icons", () => { + const blocks = Object.values(blockRegistry); + + it("gives every block a lucide icon of its own", () => { + for (const block of blocks) { + expect(resolveIcon(block.metadata.icon), block.metadata.name).not.toBe( + LayoutTemplate, + ); + } + const icons = blocks.map((block) => block.metadata.icon); + expect(new Set(icons).size).toBe(icons.length); + }); +}); diff --git a/packages/page-builder/src/builder/block-picker.tsx b/packages/page-builder/src/builder/block-picker.tsx index 38d57be..2012f09 100644 --- a/packages/page-builder/src/builder/block-picker.tsx +++ b/packages/page-builder/src/builder/block-picker.tsx @@ -4,11 +4,10 @@ import React from "react"; import * as Icons from "lucide-react"; import type { Widget } from "../models"; -function resolveIcon(name?: string): React.ComponentType<{ className?: string }> { +/** The block's `metadata.icon` (a lucide-react name), or a generic layout icon. */ +export function resolveIcon(name?: string): Icons.LucideIcon { if (name && name in Icons) { - return ( - Icons as unknown as Record> - )[name]; + return (Icons as unknown as Record)[name]; } return Icons.LayoutTemplate; } diff --git a/packages/page-builder/src/builder/page-builder.tsx b/packages/page-builder/src/builder/page-builder.tsx index 8835c51..2fa77dc 100644 --- a/packages/page-builder/src/builder/page-builder.tsx +++ b/packages/page-builder/src/builder/page-builder.tsx @@ -1,7 +1,6 @@ "use client"; import React, { useEffect, useMemo, useRef, useState } from "react"; -import { LayoutTemplate } from "lucide-react"; import type { PageData, PageSeo, Theme, WidgetInstance } from "../models"; import { PageRenderer } from "../renderer"; import { getAddableBlocks, getWidget } from "../registry"; @@ -18,7 +17,7 @@ import { cn } from "../primitives/lib/utils"; import type { PageBuilderLinkComponent } from "../components/nav-link"; import { BuilderTopBar, type PreviewDevice } from "./top-bar"; import { PreviewFrame } from "./preview-frame"; -import { BlockPicker } from "./block-picker"; +import { BlockPicker, resolveIcon } from "./block-picker"; import { ConfirmDialog } from "./confirm-dialog"; import { SettingsPanel } from "./settings-panel"; import { BlockSettings } from "./block-settings"; @@ -306,7 +305,7 @@ export function PageBuilder({ title={selectedDefinition.metadata.displayName} eyebrow="Block settings" description={selectedDefinition.metadata.description} - icon={LayoutTemplate} + icon={resolveIcon(selectedDefinition.metadata.icon)} onClose={closePanel} > { expect(canonicalizeLayout([{ name: "header" }])).toEqual([{ name: "header" }]); }); + it("accepts the pricing period switch", () => { + const settings = { + pricingSwitcher: true, + yearlyPriceCaption: "Yearly (2 months free)", + defaultPeriod: "monthly", + tiers: [ + { + name: "Pro", + price: "$10/month", + priceYearly: "$100/year", + features: [], + buttonAction: "/buy", + buttonActionYearly: "/buy?period=yearly", + }, + ], + }; + expect(canonicalizeLayout([{ name: "pricing", settings }])).toEqual([ + { name: "pricing", settings }, + ]); + }); + it("rejects unknown settings with a layout pointer", () => { const diagnostics = diagnosticsOf(() => canonicalizeLayout([{ name: "faq", settings: { items: [{ question: "Q" }] } }]), @@ -199,6 +220,17 @@ describe("site files", () => { expect(parsed.redirects).toEqual([{ from: "/old", to: "/new" }]); expect(serializeSiteFile(parseSiteFile(text))).toBe(text); }); + + it("accepts the header's GitHub repo, including a cleared one", () => { + for (const githubRepo of [ + "codelitdev/medialit", + "https://github.com/codelitdev/medialit", + "", + ]) { + const site = JSON.stringify({ header: { githubRepo, showGithubStars: true } }); + expect(parseSiteFile(site).header).toMatchObject({ githubRepo }); + } + }); }); describe("JSON Schemas", () => { diff --git a/packages/page-builder/tsup.islands.ts b/packages/page-builder/tsup.islands.ts index 0d754b5..1ba6de1 100644 --- a/packages/page-builder/tsup.islands.ts +++ b/packages/page-builder/tsup.islands.ts @@ -19,7 +19,9 @@ export const islands: Record = { "components/video-with-preview": "video-with-preview", "primitives/code-snippet": "code-snippet", "blocks/header/mobile-menu": "header-mobile-menu", + "blocks/header/github-stars": "header-github-stars", "blocks/marquee/marquee-track": "marquee-track", + "blocks/pricing/period-switch": "pricing-period-switch", "blocks/email-form/form": "email-form", "blocks/support-form/form": "support-form", };