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">