Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changeset/page-builder-137-pricing-header.md
Original file line number Diff line number Diff line change
@@ -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.
3 changes: 2 additions & 1 deletion lint-staged.config.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
4 changes: 4 additions & 0 deletions packages/page-builder/AGENTS.md
Original file line number Diff line number Diff line change
@@ -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.
30 changes: 29 additions & 1 deletion packages/page-builder/schema/page.v1.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand All @@ -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"
Expand All @@ -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"
}
Expand Down
8 changes: 8 additions & 0 deletions packages/page-builder/schema/site.v1.json
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions packages/page-builder/src/blocks/banner/metadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
};
1 change: 1 addition & 0 deletions packages/page-builder/src/blocks/content/metadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
};
1 change: 1 addition & 0 deletions packages/page-builder/src/blocks/data-slot/metadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
};
1 change: 1 addition & 0 deletions packages/page-builder/src/blocks/email-form/metadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
};
1 change: 1 addition & 0 deletions packages/page-builder/src/blocks/embed/metadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
};
1 change: 1 addition & 0 deletions packages/page-builder/src/blocks/faq/metadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
};
1 change: 1 addition & 0 deletions packages/page-builder/src/blocks/featured/metadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
};
1 change: 1 addition & 0 deletions packages/page-builder/src/blocks/footer/metadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
};
1 change: 1 addition & 0 deletions packages/page-builder/src/blocks/grid/metadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
};
16 changes: 16 additions & 0 deletions packages/page-builder/src/blocks/header/editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,22 @@ export function HeaderEditor({
onChange={(ctaHref) => update({ ctaHref })}
/>
</FieldGroup>
<FieldGroup title="GitHub">
<TextField
label="Repository"
value={settings.githubRepo}
placeholder="owner/repo"
hint="Shows a GitHub icon linking to the repository."
onChange={(githubRepo) => update({ githubRepo })}
/>
{settings.githubRepo && (
<ToggleField
label="Show star count"
value={settings.showGithubStars}
onChange={(showGithubStars) => update({ showGithubStars })}
/>
)}
</FieldGroup>
<FieldGroup title="Layout">
<SelectField
label="Link alignment"
Expand Down
25 changes: 25 additions & 0 deletions packages/page-builder/src/blocks/header/github-stars.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
"use client";

import { useEffect, useState } from "react";
import { formatStars } from "./github";

/**
* Star count for the header's GitHub link. Fetched in the browser, like
* CourseLit, because unauthenticated GitHub API calls are limited per IP.
* Renders nothing until loaded, on error, or for 0 stars.
*/
export function GithubStars({ repo }: { repo: string }) {
const [stars, setStars] = useState<number>();

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 <span>{formatStars(stars)}</span>;
}
64 changes: 64 additions & 0 deletions packages/page-builder/src/blocks/header/github.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<HeaderWidget settings={settings} state={state} pageData={{ siteTitle: "Acme" }} />,
);

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",
);
});
});
17 changes: 17 additions & 0 deletions packages/page-builder/src/blocks/header/github.ts
Original file line number Diff line number Diff line change
@@ -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();
}
1 change: 1 addition & 0 deletions packages/page-builder/src/blocks/header/metadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
};
3 changes: 3 additions & 0 deletions packages/page-builder/src/blocks/header/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
40 changes: 31 additions & 9 deletions packages/page-builder/src/blocks/header/widget.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
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";
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: {
Expand All @@ -17,6 +20,8 @@ export function HeaderWidget({
linkAlignment = "left",
layout = "fixed",
backdropBlur = false,
githubRepo,
showGithubStars,
cssId,
maxWidth,
verticalPadding,
Expand All @@ -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 = (
<NavLink href="/" className="no-underline">
Expand All @@ -66,12 +73,25 @@ export function HeaderWidget({

const ctaAndMenu = (
<div className="flex items-center gap-3">
{repo && (
<a
href={`https://github.com/${repo}`}
target="_blank"
rel="noopener noreferrer"
>
<Button theme={overriddenTheme} variant="ghost" size="sm" className="gap-1.5">
<Github className="h-4 w-4" aria-hidden />
<span className="sr-only">GitHub repository</span>
{showGithubStars && <GithubStars repo={repo} />}
</Button>
</a>
)}
{buttonLinks.map((link) => (
<div className="hidden lg:!block" key={link.id}>
<HeaderLink link={link} theme={overriddenTheme} />
</div>
))}
{ctaLabel && ctaHref && (
{hasCta && (
<div className="hidden lg:!block">
<NavLink href={ctaHref}>
<Button theme={overriddenTheme} size="sm">
Expand All @@ -80,14 +100,16 @@ export function HeaderWidget({
</NavLink>
</div>
)}
<div className="lg:hidden">
<HeaderMobileMenu
links={links}
ctaLabel={ctaLabel}
ctaHref={ctaHref}
theme={overriddenTheme}
/>
</div>
{(links.length > 0 || hasCta) && (
<div className="lg:hidden">
<HeaderMobileMenu
links={links}
ctaLabel={ctaLabel}
ctaHref={ctaHref}
theme={overriddenTheme}
/>
</div>
)}
</div>
);

Expand Down
1 change: 1 addition & 0 deletions packages/page-builder/src/blocks/hero/metadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
};
1 change: 1 addition & 0 deletions packages/page-builder/src/blocks/marquee/metadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
};
1 change: 1 addition & 0 deletions packages/page-builder/src/blocks/media/metadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
};
Loading
Loading