From 3ef7a70302e1628b77acebef897a3ee4ec5c7730 Mon Sep 17 00:00:00 2001 From: Paul Bratslavsky Date: Thu, 30 Jul 2026 16:06:44 -0700 Subject: [PATCH] feat(ui): add CTA card side column to blog posts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Blog posts rendered as a single centred article, so the only promo surface was an inline section at the bottom that readers reach only after finishing the piece. This adds a side column beside the article for marketing CTAs. Content model: - `cards.cta-card` is a generic, reusable component (title, optional image, description, one link, primary/secondary button). It is named for its structure rather than a use case, so an editor can make it a demo promo, a sales prompt or a docs link without a schema change, and it can be dropped into any dynamic zone. - The link is the existing `utilities.link`, so a CTA points at either an internal page relation or an external URL with `newTab` — the same choice the hero section already offers, with the same conditional fields. - `sidebar` is a repeatable `cards.cta-card` on both the Blog single type and Blog Post. The single type holds the blog-wide default; a post overrides it outright when it sets its own. Evergreen cards would not survive being re-added to every post by hand, and new posts would otherwise publish with an empty column. Layout: - Article and column are centred as one group inside the standard page container, so the column's right edge lands on the same grid as the hero and navbar. The share rail is now anchored to the article rather than a viewport offset that only cleared above 1536px. - The column shows from xl and sticks on scroll like the share rail. Below xl the cards flow into the article width instead of vanishing or keeping the rail's narrow column. - With a column present the article narrows from 864px to 616px, which is what lets four tracks fit the grid. Posts without a sidebar are unchanged. Generated types were hand-written in typegen's format because `strapi ts:generate-types` currently fails on main with "e.charAt is not a function"; they should be regenerated once that is fixed. Co-Authored-By: Claude Opus 5 (1M context) --- .../content-types/blog-post/schema.json | 5 + .../api/blog/content-types/blog/schema.json | 5 + .../strapi/src/components/cards/cta-card.json | 37 ++++++ apps/strapi/types/generated/components.d.ts | 27 ++++ apps/strapi/types/generated/contentTypes.d.ts | 2 + apps/ui/src/components/blog/BlogSidebar.tsx | 69 +++++++++++ .../components/layouts/StrapiBlogPostView.tsx | 117 +++++++++++++----- .../components/cards/StrapiCtaCard.tsx | 66 ++++++++++ apps/ui/src/lib/strapi-api/content/server.ts | 10 ++ 9 files changed, 309 insertions(+), 29 deletions(-) create mode 100644 apps/strapi/src/components/cards/cta-card.json create mode 100644 apps/ui/src/components/blog/BlogSidebar.tsx create mode 100644 apps/ui/src/components/page-builder/components/cards/StrapiCtaCard.tsx diff --git a/apps/strapi/src/api/blog-post/content-types/blog-post/schema.json b/apps/strapi/src/api/blog-post/content-types/blog-post/schema.json index 316aa6bd..f54eed41 100644 --- a/apps/strapi/src/api/blog-post/content-types/blog-post/schema.json +++ b/apps/strapi/src/api/blog-post/content-types/blog-post/schema.json @@ -76,6 +76,11 @@ "content": { "type": "richtext" }, + "sidebar": { + "type": "component", + "component": "cards.cta-card", + "repeatable": true + }, "sections": { "type": "dynamiczone", "components": [ diff --git a/apps/strapi/src/api/blog/content-types/blog/schema.json b/apps/strapi/src/api/blog/content-types/blog/schema.json index 55455f75..81e04f7f 100644 --- a/apps/strapi/src/api/blog/content-types/blog/schema.json +++ b/apps/strapi/src/api/blog/content-types/blog/schema.json @@ -29,6 +29,11 @@ "type": "relation", "relation": "oneToOne", "target": "api::blog-post.blog-post" + }, + "sidebar": { + "type": "component", + "component": "cards.cta-card", + "repeatable": true } } } diff --git a/apps/strapi/src/components/cards/cta-card.json b/apps/strapi/src/components/cards/cta-card.json new file mode 100644 index 00000000..e814b171 --- /dev/null +++ b/apps/strapi/src/components/cards/cta-card.json @@ -0,0 +1,37 @@ +{ + "collectionName": "components_cards_cta_cards", + "info": { + "displayName": "CTA Card", + "icon": "cursor", + "description": "Reusable call-to-action card: title, optional image, description and one link. Usable in any dynamic zone." + }, + "options": {}, + "attributes": { + "title": { + "type": "string", + "required": true, + "maxLength": 60 + }, + "description": { + "type": "text", + "maxLength": 160 + }, + "image": { + "type": "component", + "component": "utilities.basic-image", + "repeatable": false + }, + "ctaLink": { + "type": "component", + "component": "utilities.link", + "repeatable": false, + "required": true + }, + "buttonStyle": { + "type": "enumeration", + "enum": ["primary", "secondary"], + "default": "secondary", + "required": true + } + } +} diff --git a/apps/strapi/types/generated/components.d.ts b/apps/strapi/types/generated/components.d.ts index e27da35a..5b40ba82 100644 --- a/apps/strapi/types/generated/components.d.ts +++ b/apps/strapi/types/generated/components.d.ts @@ -112,6 +112,32 @@ export interface CardsContentCard extends Struct.ComponentSchema { } } +export interface CardsCtaCard extends Struct.ComponentSchema { + collectionName: "components_cards_cta_cards" + info: { + description: "Reusable call-to-action card: title, optional image, description and one link. Usable in any dynamic zone." + displayName: "CTA Card" + icon: "cursor" + } + attributes: { + buttonStyle: Schema.Attribute.Enumeration<["primary", "secondary"]> & + Schema.Attribute.Required & + Schema.Attribute.DefaultTo<"secondary"> + ctaLink: Schema.Attribute.Component<"utilities.link", false> & + Schema.Attribute.Required + description: Schema.Attribute.Text & + Schema.Attribute.SetMinMaxLength<{ + maxLength: 160 + }> + image: Schema.Attribute.Component<"utilities.basic-image", false> + title: Schema.Attribute.String & + Schema.Attribute.Required & + Schema.Attribute.SetMinMaxLength<{ + maxLength: 60 + }> + } +} + export interface CardsFeatureCard extends Struct.ComponentSchema { collectionName: "components_cards_feature_card" info: { @@ -1707,6 +1733,7 @@ declare module "@strapi/strapi" { "blog.resource-cta": BlogResourceCta "cards.case-study-card": CardsCaseStudyCard "cards.content-card": CardsContentCard + "cards.cta-card": CardsCtaCard "cards.feature-card": CardsFeatureCard "cms.field-entry": CmsFieldEntry "elements.brand-logo-grid-item": ElementsBrandLogoGridItem diff --git a/apps/strapi/types/generated/contentTypes.d.ts b/apps/strapi/types/generated/contentTypes.d.ts index 283da2b0..10a7e110 100644 --- a/apps/strapi/types/generated/contentTypes.d.ts +++ b/apps/strapi/types/generated/contentTypes.d.ts @@ -574,6 +574,7 @@ export interface ApiBlogPostBlogPost extends Struct.CollectionTypeSchema { ] > seo: Schema.Attribute.Component<"shared.seo", false> + sidebar: Schema.Attribute.Component<"cards.cta-card", true> slug: Schema.Attribute.UID<"title"> & Schema.Attribute.Required tags: Schema.Attribute.Relation<"manyToMany", "api::post-tag.post-tag"> title: Schema.Attribute.String & Schema.Attribute.Required @@ -659,6 +660,7 @@ export interface ApiBlogBlog extends Struct.SingleTypeSchema { navigation: Schema.Attribute.Component<"blog.navigation", false> newsletter: Schema.Attribute.Component<"forms.newsletter", false> publishedAt: Schema.Attribute.DateTime + sidebar: Schema.Attribute.Component<"cards.cta-card", true> updatedAt: Schema.Attribute.DateTime updatedBy: Schema.Attribute.Relation<"oneToOne", "admin::user"> & Schema.Attribute.Private diff --git a/apps/ui/src/components/blog/BlogSidebar.tsx b/apps/ui/src/components/blog/BlogSidebar.tsx new file mode 100644 index 00000000..a91453eb --- /dev/null +++ b/apps/ui/src/components/blog/BlogSidebar.tsx @@ -0,0 +1,69 @@ +import type { Data } from "@repo/strapi-types" + +import { StrapiCtaCard } from "@/components/page-builder/components/cards/StrapiCtaCard" +import { cn } from "@/lib/styles" + +type CtaCard = Data.Component<"cards.cta-card"> + +interface BlogSidebarProps { + /** Cards set on the post itself. When non-empty these replace the default. */ + readonly postCards?: CtaCard[] | null + /** Blog-wide default cards from the Blog single type. */ + readonly defaultCards?: CtaCard[] | null + /** + * `column` is the fixed-width sticky rail beside the article. + * `inline` flows the cards into the article width once there is no room + * beside it — without this they keep the rail's narrow width and look broken. + */ + readonly variant?: "column" | "inline" + readonly className?: string + /** Applied to the card wrapper — used to make the column stick on scroll. */ + readonly innerClassName?: string +} + +/** + * Blog post side column. + * + * Cards are configured blog-wide on the Blog single type and can be overridden + * per post. The override is all-or-nothing rather than a merge: merge order + * would be ambiguous and invisible to the editor. + * + * Renders nothing when neither source has cards, so posts without a sidebar + * keep the original centered layout. + */ +export function BlogSidebar({ + postCards, + defaultCards, + variant = "column", + className, + innerClassName, +}: BlogSidebarProps) { + const cards = postCards?.length ? postCards : (defaultCards ?? []) + + if (cards.length === 0) { + return null + } + + return ( + + ) +} diff --git a/apps/ui/src/components/layouts/StrapiBlogPostView.tsx b/apps/ui/src/components/layouts/StrapiBlogPostView.tsx index fe254608..c2167e71 100644 --- a/apps/ui/src/components/layouts/StrapiBlogPostView.tsx +++ b/apps/ui/src/components/layouts/StrapiBlogPostView.tsx @@ -9,6 +9,7 @@ import { } from "@/components/blog/BlogAuthorBanner" import { BlogAutoRelatedPosts } from "@/components/blog/BlogAutoRelatedPosts" import { BlogContent } from "@/components/blog/BlogContent" +import { BlogSidebar } from "@/components/blog/BlogSidebar" import { BlogSocialShare } from "@/components/blog/BlogSocialShare" import { Container } from "@/components/elementary/Container" import { StrapiSeoStructuredDataFromSeo } from "@/components/page-builder/components/seo-utilities/StrapiSeoStructuredData" @@ -17,8 +18,9 @@ import { extractHeadings, type BlogPost } from "@/lib/blog-utils" import { getEnvVar } from "@/lib/env-vars" import { routing } from "@/lib/navigation" import { SECTION_SPACING } from "@/lib/section-spacing" -import { fetchBlogPost } from "@/lib/strapi-api/content/server" +import { fetchBlog, fetchBlogPost } from "@/lib/strapi-api/content/server" import { buildBlogPostingJsonLd } from "@/lib/structured-data/blog-posting" +import { cn } from "@/lib/styles" import { BlogNavbar } from "../blog/BlogNavbar" import { BlogPostHeader } from "../blog/BlogPostHeader" @@ -43,12 +45,22 @@ export function StrapiBlogPostView({ params }: Props) { setRequestLocale(locale) const response = use(fetchBlogPost(slug, locale)) + // Blog-wide sidebar defaults, overridden per post when the post sets its own. + const blogSettings = use(fetchBlog(locale)) const post = response?.data as BlogPost | null | undefined if (!post) { notFound() } + // Same resolution the sidebar itself applies: a post overrides the blog-wide + // default outright. Needed here too, because the surrounding layout changes + // when a side column is present. + const sidebarCards = post.sidebar?.length + ? post.sidebar + : (blogSettings?.data?.sidebar ?? []) + const hasSidebar = sidebarCards.length > 0 + const sections = post.sections const author = post.author as BlogAuthor | null const content = post.content @@ -100,36 +112,83 @@ export function StrapiBlogPostView({ params }: Props) { {content && (
- {headings.length > 0 && ( - - )} - - - - -
- {content} -
- - - -
+ {/* ml clears the share rail, which is absolutely positioned + just outside the article and would otherwise sit flush + against the cards. */} + {/* A sticky element can only travel through space its + container has left over. The share rail is short so it + always has room; a tall card stack does not — hence the + viewport cap, which keeps the column pinned (scrolling + internally if needed) instead of drifting off-screen. + Bottom padding is kept small for the same reason. */} + +
)} diff --git a/apps/ui/src/components/page-builder/components/cards/StrapiCtaCard.tsx b/apps/ui/src/components/page-builder/components/cards/StrapiCtaCard.tsx new file mode 100644 index 00000000..cda7d0a2 --- /dev/null +++ b/apps/ui/src/components/page-builder/components/cards/StrapiCtaCard.tsx @@ -0,0 +1,66 @@ +import type { Data } from "@repo/strapi-types" +import type { ComponentProps } from "react" + +import { StrapiBasicImage } from "@/components/page-builder/components/utilities/StrapiBasicImage" +import { StrapiLink } from "@/components/page-builder/components/utilities/StrapiLink" +import { cn } from "@/lib/styles" + +interface StrapiCtaCardProps extends ComponentProps<"div"> { + readonly component: Data.Component<"cards.cta-card"> +} + +/** + * Reusable call-to-action card — title, optional image, description and one + * link. `ctaLink` carries the internal-page vs external-URL choice, so this + * component never needs to know which kind of destination it points at. + */ +export function StrapiCtaCard({ + component, + className, + ...rest +}: StrapiCtaCardProps) { + if (!component.title || !component.ctaLink) { + return null + } + + const isPrimary = component.buttonStyle === "primary" + + return ( +
+

+ {component.title} +

+ + {component.image && ( + + )} + + {component.description && ( +

+ {component.description} +

+ )} + + +
+ ) +} diff --git a/apps/ui/src/lib/strapi-api/content/server.ts b/apps/ui/src/lib/strapi-api/content/server.ts index 95a58192..e2dcc915 100644 --- a/apps/ui/src/lib/strapi-api/content/server.ts +++ b/apps/ui/src/lib/strapi-api/content/server.ts @@ -80,6 +80,14 @@ const seoPopulate = { }, } +// `cards.cta-card` is a plain repeatable component rather than a dynamic zone, +// so it is not covered by the backend populateDynamicZone config and has to be +// populated explicitly. `ctaLink.page` supplies fullPath for internal links. +const ctaCardPopulate = { + image: { populate: { media: true } }, + ctaLink: { populate: { page: { fields: ["fullPath"] } } }, +} as Record + // ------ Page fetching functions export async function fetchPage( @@ -172,6 +180,7 @@ export async function fetchBlogPost( }, tags: true, seo: seoPopulate, + sidebar: { populate: ctaCardPopulate }, } as Record, populateDynamicZone: { sections: true }, }, @@ -753,6 +762,7 @@ export async function fetchBlog(locale: Locale) { featuredBlogPost: { populate: blogListPopulate, }, + sidebar: { populate: ctaCardPopulate }, }, } satisfies FindFirst<"api::blog.blog">