Repository: NWACus/web
Pull request #185: Blocks: membership
Author: login: rchlfryn
Created: 2025-05-09T22:16:54Z | Merged: 2025-05-16T14:48:08Z

### Description
Adds a dynamic membership block with the ability to add an optional callout

TODO: Decide button colors - in figma it is a brand color but we will need to change this

### Issue
Resolves #108 

### Screenshots
| with callout | without callout |
|---|---|
| <img width="1531" alt="Screenshot 2025-05-09 at 14 43 57" src="https://github.com/user-attachments/assets/dd914e10-4132-4419-a339-a692819a6829" /> | <img width="1340" alt="Screenshot 2025-05-09 at 14 45 22" src="https://github.com/user-attachments/assets/34a7da7a-abb2-466a-b765-01924607008a" /> | 






Linked issue #108: Blocks: Membership
Create block for AC staff to edit membership information
NOTE: show already member blurb

![Image](https://github.com/user-attachments/assets/f8cb738d-8ba9-4471-bb72-9457fdb9dd68)
![Image](https://github.com/user-attachments/assets/0e4571cf-b0b6-49b0-8e11-b3d4c2c81bf6)
Linked issue comments: user: login: stevekuznetsov
created_at: 2025-04-14T13:07:15Z
body: @rchlfryn wonder how much of what's in the screenshots you expect to be driven by custom blocks in the CMS - a lot of the details in the copy (from who to contact to whether or not the center is a non-profit) is hyper-specific to the center, so it's not super clear to me if it could be driven by a form-fill. 

The duo of buttons to become a member / renew could be a custom block, we'd just need to backfill support for checking membership and tracking users without a login. That might be a v2 thing, depends on how we're able to integrate with the centers' CRMs.
author_association: CONTRIBUTOR
user: login: rchlfryn
created_at: 2025-04-17T23:03:07Z
body: @stevekuznetsov I am also curious how much control we will want to give a center when they fill this out. I think the intention was linking the buttons to a different page. I was just going to make it with dynamic components that can be altered. So title, top text, button, bottom text and blue box.
author_association: COLLABORATOR
user: login: rustynwac
created_at: 2025-05-08T17:17:10Z
body: Chiming in here. I think we want to give them lots of control, so dynamic components sounds great. 

- Not all centers do membership + donation
- NWAC is likely pivoting some of it's model/messaging around membership vs donation—so the language around join vs renew will likely go away for NWAC specifically
- Integrating CRM for giving data or any type of 'check my membership status'/'check my donation history'/'update my recurring gift' is not necessary for V1 or even for the first version of NWAC launch
author_association: NONE

Diff:
diff --git a/src/app/(payload)/admin/importMap.js b/src/app/(payload)/admin/importMap.js
index dda4590fd..9d287cb65 100644
--- a/src/app/(payload)/admin/importMap.js
+++ b/src/app/(payload)/admin/importMap.js
@@ -70,6 +70,8 @@ export const importMap = {
   '@/components/ColorPicker#default': default_55a7d1ebef7afeed563b856ae2e2cbf4,
   '@payloadcms/richtext-lexical/client#HorizontalRuleFeatureClient':
     HorizontalRuleFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
+  '@payloadcms/richtext-lexical/client#BlocksFeatureClient':
+    BlocksFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
   '@payloadcms/plugin-seo/client#OverviewComponent':
     OverviewComponent_a8a977ebc872c5d5ea7ee689724c0860,
   '@payloadcms/plugin-seo/client#MetaTitleComponent':
@@ -81,8 +83,6 @@ export const importMap = {
   '@payloadcms/plugin-seo/client#PreviewComponent':
     PreviewComponent_a8a977ebc872c5d5ea7ee689724c0860,
   '@/fields/slug/SlugComponent#SlugComponent': SlugComponent_92cc057d0a2abb4f6cf0307edf59f986,
-  '@payloadcms/richtext-lexical/client#BlocksFeatureClient':
-    BlocksFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
   '@/collections/Roles/components/CollectionsField#CollectionsField':
     CollectionsField_49c0311020325b59204cc21d2f536b8d,
   '@/collections/Roles/components/RulesCell#RulesCell': RulesCell_649699f5b285e7a5429592dc58fd6f0c,
diff --git a/src/blocks/Buttons/Component.tsx b/src/blocks/Buttons/Component.tsx
new file mode 100644
index 000000000..56903f9da
--- /dev/null
+++ b/src/blocks/Buttons/Component.tsx
@@ -0,0 +1,12 @@
+import { CMSLink } from '@/components/Link'
+import type { ButtonsBlock as ButtonBlockProps } from 'src/payload-types'
+
+export const ButtonsBlock = ({ buttons }: ButtonBlockProps) => {
+  return (
+    <div>
+      {buttons?.map((button) => {
+        return <CMSLink key={button.id} className="no-underline me-4" {...button.button} />
+      })}
+    </div>
+  )
+}
diff --git a/src/blocks/Buttons/config.ts b/src/blocks/Buttons/config.ts
new file mode 100644
index 000000000..96954a86d
--- /dev/null
+++ b/src/blocks/Buttons/config.ts
@@ -0,0 +1,20 @@
+import { button } from '@/fields/button'
+import type { Block } from 'payload'
+
+export const ButtonsBlock: Block = {
+  slug: 'buttonsBlock',
+  interfaceName: 'ButtonsBlock',
+  fields: [
+    {
+      name: 'buttons',
+      type: 'array',
+      label: false,
+      labels: {
+        singular: 'Button',
+        plural: 'Buttons',
+      },
+      fields: [button(['default', 'secondary', 'destructive', 'ghost', 'link', 'outline'])],
+      required: true,
+    },
+  ],
+}
diff --git a/src/blocks/CallToAction/Component.tsx b/src/blocks/CallToAction/Component.tsx
deleted file mode 100644
index 3d79af704..000000000
--- a/src/blocks/CallToAction/Component.tsx
+++ /dev/null
@@ -1,21 +0,0 @@
-import type { CallToActionBlock as CTABlockProps } from '@/payload-types'
-
-import { CMSLink } from '@/components/Link'
-import RichText from '@/components/RichText'
-
-export const CallToActionBlock = ({ links, richText }: CTABlockProps) => {
-  return (
-    <div className="container">
-      <div className="bg-card rounded border-border border p-4 flex flex-col gap-8 md:flex-row md:justify-between md:items-center">
-        <div className="max-w-[48rem] flex items-center">
-          {richText && <RichText className="mb-0" data={richText} enableGutter={false} />}
-        </div>
-        <div className="flex flex-col gap-8">
-          {(links || []).map(({ link }, i) => {
-            return <CMSLink key={i} size="lg" {...link} />
-          })}
-        </div>
-      </div>
-    </div>
-  )
-}
diff --git a/src/blocks/CallToAction/config.ts b/src/blocks/CallToAction/config.ts
deleted file mode 100644
index 2923e8a6f..000000000
--- a/src/blocks/CallToAction/config.ts
+++ /dev/null
@@ -1,42 +0,0 @@
-import type { Block } from 'payload'
-
-import {
-  FixedToolbarFeature,
-  HeadingFeature,
-  InlineToolbarFeature,
-  lexicalEditor,
-} from '@payloadcms/richtext-lexical'
-
-import { linkGroup } from '@/fields/linkGroup'
-
-export const CallToAction: Block = {
-  slug: 'cta',
-  interfaceName: 'CallToActionBlock',
-  fields: [
-    {
-      name: 'richText',
-      type: 'richText',
-      editor: lexicalEditor({
-        features: ({ rootFeatures }) => {
-          return [
-            ...rootFeatures,
-            HeadingFeature({ enabledHeadingSizes: ['h1', 'h2', 'h3', 'h4'] }),
-            FixedToolbarFeature(),
-            InlineToolbarFeature(),
-          ]
-        },
-      }),
-      label: false,
-    },
-    linkGroup({
-      appearances: ['default', 'outline'],
-      overrides: {
-        maxRows: 2,
-      },
-    }),
-  ],
-  labels: {
-    plural: 'Calls to Action',
-    singular: 'Call to Action',
-  },
-}
diff --git a/src/blocks/Content/config.ts b/src/blocks/Content/config.ts
index e629040b8..f2491c9fa 100644
--- a/src/blocks/Content/config.ts
+++ b/src/blocks/Content/config.ts
@@ -2,12 +2,14 @@ import type { Block } from 'payload'
 
 import colorPickerField from '@/fields/color'
 import {
+  BlocksFeature,
   FixedToolbarFeature,
   HeadingFeature,
   HorizontalRuleFeature,
   InlineToolbarFeature,
   lexicalEditor,
 } from '@payloadcms/richtext-lexical'
+import { ButtonsBlock } from '../Buttons/config'
 
 export const Content: Block = {
   slug: 'content',
@@ -40,6 +42,9 @@ export const Content: Block = {
             features: ({ rootFeatures }) => {
               return [
                 ...rootFeatures,
+                BlocksFeature({
+                  blocks: [ButtonsBlock],
+                }),
                 HorizontalRuleFeature(),
                 HeadingFeature({ enabledHeadingSizes: ['h2', 'h3', 'h4'] }),
                 FixedToolbarFeature(),
diff --git a/src/blocks/ContentWithCallout/Component.tsx b/src/blocks/ContentWithCallout/Component.tsx
new file mode 100644
index 000000000..57b0066b4
--- /dev/null
+++ b/src/blocks/ContentWithCallout/Component.tsx
@@ -0,0 +1,18 @@
+import type { ContentWithCalloutBlock as ContentWithCalloutBlockProps } from '@/payload-types'
+
+import RichText from '@/components/RichText'
+
+export const ContentWithCalloutBlock = ({ callout, richText }: ContentWithCalloutBlockProps) => {
+  return (
+    <div className="container">
+      <div className="flex flex-col md:flex-row items-center gap-4">
+        {richText && <RichText className="mb-0" data={richText} enableGutter={false} />}
+        {callout && (
+          <div className="p-6 bg-brand-200 rounded-sm">
+            <RichText className="mb-0" data={callout} enableGutter={false} />
+          </div>
+        )}
+      </div>
+    </div>
+  )
+}
diff --git a/src/blocks/ContentWithCallout/config.ts b/src/blocks/ContentWithCallout/config.ts
new file mode 100644
index 000000000..50ad62b3a
--- /dev/null
+++ b/src/blocks/ContentWithCallout/config.ts
@@ -0,0 +1,50 @@
+import type { Block } from 'payload'
+
+import {
+  BlocksFeature,
+  FixedToolbarFeature,
+  HeadingFeature,
+  lexicalEditor,
+} from '@payloadcms/richtext-lexical'
+import { ButtonsBlock } from '../Buttons/config'
+
+export const ContentWithCallout: Block = {
+  slug: 'contentWithCallout',
+  interfaceName: 'ContentWithCalloutBlock',
+  fields: [
+    {
+      name: 'richText',
+      type: 'richText',
+      editor: lexicalEditor({
+        features: ({ rootFeatures }) => {
+          return [
+            ...rootFeatures,
+            FixedToolbarFeature(),
+            BlocksFeature({
+              blocks: [ButtonsBlock],
+            }),
+            HeadingFeature({ enabledHeadingSizes: ['h2', 'h3', 'h4'] }),
+          ]
+        },
+      }),
+      label: false,
+    },
+    {
+      name: 'callout',
+      type: 'richText',
+      label: 'Callout',
+      editor: lexicalEditor({
+        features: ({ rootFeatures }) => {
+          return [
+            ...rootFeatures,
+            FixedToolbarFeature(),
+            BlocksFeature({
+              blocks: [ButtonsBlock],
+            }),
+            HeadingFeature({ enabledHeadingSizes: ['h2', 'h3', 'h4'] }),
+          ]
+        },
+      }),
+    },
+  ],
+}
diff --git a/src/blocks/RenderBlocks.tsx b/src/blocks/RenderBlocks.tsx
index 51205700c..410d15a36 100644
--- a/src/blocks/RenderBlocks.tsx
+++ b/src/blocks/RenderBlocks.tsx
@@ -3,8 +3,8 @@ import { Fragment } from 'react'
 import type { Page } from '@/payload-types'
 
 import { BiographyBlock } from '@/blocks/Biography/Biography'
-import { CallToActionBlock } from '@/blocks/CallToAction/Component'
 import { ContentBlock } from '@/blocks/Content/Component'
+import { ContentWithCalloutBlock } from '@/blocks/ContentWithCallout/Component'
 import { FormBlock } from '@/blocks/Form/Component'
 import { ImageLinkGrid } from '@/blocks/ImageLinkGrid/Component'
 import { ImageQuote } from '@/blocks/ImageQuote/Component'
@@ -42,8 +42,6 @@ export const RenderBlock = ({ block, payload }: { block: Page['layout'][0]; payl
   switch (blockType) {
     case 'biography':
       return <BiographyBlock {...block} payload={payload} />
-    case 'cta':
-      return <CallToActionBlock {...block} />
     case 'content':
       return <ContentBlock {...block} />
     case 'formBlock':
@@ -60,6 +58,8 @@ export const RenderBlock = ({ block, payload }: { block: Page['layout'][0]; payl
       return <LinkPreviewBlock {...block} />
     case 'mediaBlock':
       return <MediaBlock {...block} />
+    case 'contentWithCallout':
+      return <ContentWithCalloutBlock {...block} />
     case 'team':
       return <TeamBlock {...block} payload={payload} />
   }
diff --git a/src/collections/Pages/index.ts b/src/collections/Pages/index.ts
index 1640980e9..350b8e561 100644
--- a/src/collections/Pages/index.ts
+++ b/src/collections/Pages/index.ts
@@ -1,7 +1,8 @@
 import type { CollectionConfig } from 'payload'
 
-import { CallToAction } from '@/blocks/CallToAction/config'
+import { BiographyBlock } from '@/blocks/Biography/config'
 import { Content } from '@/blocks/Content/config'
+import { ContentWithCallout } from '@/blocks/ContentWithCallout/config'
 import { FormBlock } from '@/blocks/Form/config'
 import { ImageLinkGrid } from '@/blocks/ImageLinkGrid/config'
 import { ImageQuote } from '@/blocks/ImageQuote/config'
@@ -16,11 +17,10 @@ import { revalidateDelete, revalidatePage } from './hooks/revalidatePage'
 
 import { accessByTenantOrReadPublished } from '@/access/byTenantOrReadPublished'
 import { filterByTenant } from '@/access/filterByTenant'
-import { BiographyBlock } from '@/blocks/Biography/config'
 import { TeamBlock } from '@/blocks/Team/config'
+
 import { contentHashField } from '@/fields/contentHashField'
 import { tenantField } from '@/fields/tenantField'
-import { Tenant } from '@/payload-types'
 import {
   MetaDescriptionField,
   MetaImageField,
@@ -29,6 +29,8 @@ import {
   PreviewField,
 } from '@payloadcms/plugin-seo/fields'
 
+import { Tenant } from '@/payload-types'
+
 export const Pages: CollectionConfig<'pages'> = {
   slug: 'pages',
   access: accessByTenantOrReadPublished('pages'),
@@ -97,8 +99,8 @@ export const Pages: CollectionConfig<'pages'> = {
               type: 'blocks',
               blocks: [
                 BiographyBlock,
-                CallToAction,
                 Content,
+                ContentWithCallout,
                 FormBlock,
                 ImageLinkGrid,
                 ImageQuote,
diff --git a/src/components/RichText/index.tsx b/src/components/RichText/index.tsx
index d66fab858..286fbf7da 100644
--- a/src/components/RichText/index.tsx
+++ b/src/components/RichText/index.tsx
@@ -8,21 +8,21 @@ import { SerializedEditorState } from '@payloadcms/richtext-lexical/lexical'
 import {
   JSXConvertersFunction,
   LinkJSXConverter,
-  RichText as RichTextWithoutBlocks,
+  RichText as RichTextLexical,
 } from '@payloadcms/richtext-lexical/react'
 
 import { BannerBlock } from '@/blocks/Banner/Component'
-import { CallToActionBlock } from '@/blocks/CallToAction/Component'
+import { ButtonsBlock } from '@/blocks/Buttons/Component'
 import type {
   BannerBlock as BannerBlockProps,
-  CallToActionBlock as CTABlockProps,
+  ButtonsBlock as ButtonsBlockProps,
   MediaBlock as MediaBlockProps,
 } from '@/payload-types'
 import { cn } from '@/utilities/ui'
 
 type NodeTypes =
   | DefaultNodeTypes
-  | SerializedBlockNode<CTABlockProps | MediaBlockProps | BannerBlockProps>
+  | SerializedBlockNode<BannerBlockProps | ButtonsBlockProps | MediaBlockProps>
 
 const internalDocToHref = ({ linkNode }: { linkNode: SerializedLinkNode }) => {
   const { value, relationTo } = linkNode.fields.doc || {}
@@ -38,6 +38,7 @@ const jsxConverters: JSXConvertersFunction<NodeTypes> = ({ defaultConverters })
   ...LinkJSXConverter({ internalDocToHref }),
   blocks: {
     banner: ({ node }) => <BannerBlock className="col-start-2 mb-4" {...node.fields} />,
+    buttonsBlock: ({ node }) => <ButtonsBlock {...node.fields} />,
     mediaBlock: ({ node }) => (
       <MediaBlock
         className="col-start-1 col-span-3"
@@ -48,7 +49,6 @@ const jsxConverters: JSXConvertersFunction<NodeTypes> = ({ defaultConverters })
         disableInnerContainer={true}
       />
     ),
-    cta: ({ node }) => <CallToActionBlock {...node.fields} />,
   },
 })
 
@@ -61,7 +61,7 @@ type Props = {
 export default function RichText(props: Props) {
   const { className, enableProse = true, enableGutter = true, ...rest } = props
   return (
-    <RichTextWithoutBlocks
+    <RichTextLexical
       converters={jsxConverters}
       className={cn(
         {
diff --git a/src/endpoints/seed/blocks/content-with-callout.ts b/src/endpoints/seed/blocks/content-with-callout.ts
new file mode 100644
index 000000000..3a09ead4e
--- /dev/null
+++ b/src/endpoints/seed/blocks/content-with-callout.ts
@@ -0,0 +1,273 @@
+import { RequiredDataFromCollectionSlug } from 'payload'
+
+export const contentWithCallout: RequiredDataFromCollectionSlug<'pages'>['layout'] = [
+  {
+    blockType: 'contentWithCallout',
+    richText: {
+      root: {
+        children: [
+          {
+            children: [
+              {
+                detail: 0,
+                format: 0,
+                mode: 'normal',
+                style: '',
+                text: 'Become an NWAC Member',
+                type: 'text',
+                version: 1,
+              },
+            ],
+            direction: 'ltr',
+            format: '',
+            indent: 0,
+            type: 'heading',
+            version: 1,
+            tag: 'h2',
+          },
+
+          {
+            children: [
+              {
+                detail: 0,
+                format: 0,
+                mode: 'normal',
+                style: '',
+                text: 'NWAC is a community-supported avalanche center. As an NWAC member, you directly support the forecast you use on every backcountry adventure.',
+                type: 'text',
+                version: 1,
+              },
+            ],
+            direction: 'ltr',
+            format: '',
+            indent: 0,
+            type: 'paragraph',
+            version: 1,
+            textFormat: 0,
+            textStyle: '',
+          },
+
+          {
+            children: [
+              {
+                detail: 0,
+                format: 0,
+                mode: 'normal',
+                style: '',
+                text: 'Looking for more info on how to edit, update, or cancel your recurring membership? Check out this helpful article.',
+                type: 'text',
+                version: 1,
+              },
+            ],
+            direction: 'ltr',
+            format: '',
+            indent: 0,
+            type: 'paragraph',
+            version: 1,
+            textFormat: 0,
+            textStyle: '',
+          },
+          {
+            type: 'block',
+            version: 2,
+            format: '',
+            fields: {
+              blockName: '',
+              buttons: [
+                {
+                  button: {
+                    type: 'custom',
+                    appearance: 'secondary',
+                    label: 'Become a member',
+                    url: '/home',
+                  },
+                },
+
+                {
+                  button: {
+                    type: 'custom',
+                    appearance: 'default',
+                    label: 'Renew your membership',
+                    url: '/home',
+                  },
+                },
+              ],
+              blockType: 'buttonsBlock',
+            },
+          },
+          {
+            children: [
+              {
+                detail: 0,
+                format: 0,
+                mode: 'normal',
+                style: '',
+                text: 'Wanting to change your monthly or annual membership contribution to NWAC? Check out our handy infographic to make changes to your recurring NWAC membership.',
+                type: 'text',
+                version: 1,
+              },
+            ],
+            direction: 'ltr',
+            format: '',
+            indent: 0,
+            type: 'paragraph',
+            version: 1,
+            textFormat: 0,
+            textStyle: '',
+          },
+
+          {
+            children: [
+              {
+                detail: 0,
+                format: 0,
+                mode: 'normal',
+                style: '',
+                text: 'Please contact ',
+                type: 'text',
+                version: 1,
+              },
+
+              {
+                type: 'autolink',
+
+                children: [
+                  {
+                    detail: 0,
+                    format: 0,
+                    mode: 'normal',
+                    style: '',
+                    text: 'info@nwac.us',
+                    type: 'text',
+                    version: 1,
+                  },
+                ],
+                direction: 'ltr',
+
+                fields: {
+                  linkType: 'custom',
+                  url: 'mailto:info@nwac.us',
+                },
+                format: '',
+                indent: 0,
+                version: 2,
+              },
+
+              {
+                detail: 0,
+                format: 0,
+                mode: 'normal',
+                style: '',
+                text: ' if you have any questions. Have questions about your membership or want to upgrade to a higher-level membership? Please contact ',
+                type: 'text',
+                version: 1,
+              },
+              {
+                type: 'autolink',
+                children: [
+                  {
+                    detail: 0,
+                    format: 0,
+                    mode: 'normal',
+                    style: '',
+                    text: 'liz@nwac.us',
+                    type: 'text',
+                    version: 1,
+                  },
+                ],
+                direction: 'ltr',
+                fields: {
+                  linkType: 'custom',
+                  url: 'mailto:liz@nwac.us',
+                },
+                format: '',
+                indent: 0,
+                version: 2,
+              },
+            ],
+            direction: 'ltr',
+            format: '',
+            indent: 0,
+            type: 'paragraph',
+            version: 1,
+            textFormat: 0,
+            textStyle: '',
+          },
+        ],
+        direction: 'ltr',
+        format: '',
+        indent: 0,
+        type: 'root',
+        version: 1,
+      },
+    },
+    callout: {
+      root: {
+        children: [
+          {
+            children: [
+              {
+                detail: 0,
+                format: 0,
+                mode: 'normal',
+                style: '',
+                text: 'Already a member? ',
+                type: 'text',
+                version: 1,
+              },
+            ],
+            direction: 'ltr',
+            format: '',
+            indent: 0,
+            type: 'heading',
+            version: 1,
+            tag: 'h3',
+          },
+          {
+            children: [
+              {
+                detail: 0,
+                format: 0,
+                mode: 'normal',
+                style: '',
+                text: 'NWAC is a 501-c3 Non-Profit. Our tax identification number is #91-1971688.',
+                type: 'text',
+                version: 1,
+              },
+            ],
+            direction: 'ltr',
+            format: '',
+            indent: 0,
+            type: 'paragraph',
+            version: 1,
+            textFormat: 0,
+            textStyle: '',
+          },
+          {
+            type: 'block',
+            version: 2,
+            format: '',
+            fields: {
+              buttons: [
+                {
+                  button: {
+                    type: 'custom',
+                    appearance: 'default',
+                    label: 'Make an Additional Donation',
+                    url: '/home',
+                  },
+                },
+              ],
+              blockType: 'buttonsBlock',
+            },
+          },
+        ],
+        direction: 'ltr',
+        format: '',
+        indent: 0,
+        type: 'root',
+        version: 1,
+      },
+    },
+  },
+]
diff --git a/src/endpoints/seed/pages/all-blocks-page.ts b/src/endpoints/seed/pages/all-blocks-page.ts
index 39d34eff7..c8423aec9 100644
--- a/src/endpoints/seed/pages/all-blocks-page.ts
+++ b/src/endpoints/seed/pages/all-blocks-page.ts
@@ -1,5 +1,6 @@
 import type { Media, Tenant } from '@/payload-types'
 import { RequiredDataFromCollectionSlug } from 'payload'
+import { contentWithCallout } from '../blocks/content-with-callout'
 import { imageLinkGrid } from '../blocks/image-link-grid'
 import { imageQuote } from '../blocks/image-quote'
 import { imageText } from '../blocks/image-text'
@@ -24,6 +25,7 @@ export const allBlocksPage: (
       ...imageText(image1),
       ...imageTextList(image1),
       ...linkPreview(image1),
+      ...contentWithCallout,
     ],
     meta: {
       title: null,
diff --git a/src/fields/linkGroup.ts b/src/fields/linkGroup.ts
deleted file mode 100644
index e6927922f..000000000
--- a/src/fields/linkGroup.ts
+++ /dev/null
@@ -1,28 +0,0 @@
-import type { ArrayField, Field } from 'payload'
-
-import type { LinkAppearances } from './link'
-
-import merge from 'lodash.merge'
-import { link } from './link'
-
-type LinkGroupType = (options?: {
-  appearances?: LinkAppearances[] | false
-  overrides?: Partial<ArrayField>
-}) => Field
-
-export const linkGroup: LinkGroupType = ({ appearances, overrides = {} } = {}) => {
-  const generatedLinkGroup: Field = {
-    name: 'links',
-    type: 'array',
-    fields: [
-      link({
-        appearances,
-      }),
-    ],
-    admin: {
-      initCollapsed: true,
-    },
-  }
-
-  return merge(generatedLinkGroup, overrides)
-}
diff --git a/src/payload-types.ts b/src/payload-types.ts
index fa656bec0..61350094d 100644
--- a/src/payload-types.ts
+++ b/src/payload-types.ts
@@ -278,8 +278,9 @@ export interface Page {
   title: string;
   layout: (
     | BiographyBlock
-    | CallToActionBlock
+    | ButtonsBlock
     | ContentBlock
+    | ContentWithCalloutBlock
     | FormBlock
     | ImageLinkGrid
     | ImageQuote
@@ -405,51 +406,34 @@ export interface RoleAssignment {
 }
 /**
  * This interface was referenced by `Config`'s JSON-Schema
- * via the `definition` "CallToActionBlock".
+ * via the `definition` "ButtonsBlock".
  */
-export interface CallToActionBlock {
-  richText?: {
-    root: {
-      type: string;
-      children: {
-        type: string;
-        version: number;
-        [k: string]: unknown;
-      }[];
-      direction: ('ltr' | 'rtl') | null;
-      format: 'left' | 'start' | 'center' | 'right' | 'end' | 'justify' | '';
-      indent: number;
-      version: number;
+export interface ButtonsBlock {
+  buttons: {
+    button: {
+      type?: ('reference' | 'custom') | null;
+      newTab?: boolean | null;
+      reference?:
+        | ({
+            relationTo: 'pages';
+            value: number | Page;
+          } | null)
+        | ({
+            relationTo: 'posts';
+            value: number | Post;
+          } | null);
+      url?: string | null;
+      label: string;
+      /**
+       * Choose how the link should be rendered.
+       */
+      appearance?: ('default' | 'secondary' | 'destructive' | 'ghost' | 'link' | 'outline') | null;
     };
-    [k: string]: unknown;
-  } | null;
-  links?:
-    | {
-        link: {
-          type?: ('reference' | 'custom') | null;
-          newTab?: boolean | null;
-          reference?:
-            | ({
-                relationTo: 'pages';
-                value: number | Page;
-              } | null)
-            | ({
-                relationTo: 'posts';
-                value: number | Post;
-              } | null);
-          url?: string | null;
-          label: string;
-          /**
-           * Choose how the link should be rendered.
-           */
-          appearance?: ('default' | 'outline') | null;
-        };
-        id?: string | null;
-      }[]
-    | null;
+    id?: string | null;
+  }[];
   id?: string | null;
   blockName?: string | null;
-  blockType: 'cta';
+  blockType: 'buttonsBlock';
 }
 /**
  * This interface was referenced by `Config`'s JSON-Schema
@@ -528,6 +512,45 @@ export interface ContentBlock {
   blockName?: string | null;
   blockType: 'content';
 }
+/**
+ * This interface was referenced by `Config`'s JSON-Schema
+ * via the `definition` "ContentWithCalloutBlock".
+ */
+export interface ContentWithCalloutBlock {
+  richText?: {
+    root: {
+      type: string;
+      children: {
+        type: string;
+        version: number;
+        [k: string]: unknown;
+      }[];
+      direction: ('ltr' | 'rtl') | null;
+      format: 'left' | 'start' | 'center' | 'right' | 'end' | 'justify' | '';
+      indent: number;
+      version: number;
+    };
+    [k: string]: unknown;
+  } | null;
+  callout?: {
+    root: {
+      type: string;
+      children: {
+        type: string;
+        version: number;
+        [k: string]: unknown;
+      }[];
+      direction: ('ltr' | 'rtl') | null;
+      format: 'left' | 'start' | 'center' | 'right' | 'end' | 'justify' | '';
+      indent: number;
+      version: number;
+    };
+    [k: string]: unknown;
+  } | null;
+  id?: string | null;
+  blockName?: string | null;
+  blockType: 'contentWithCallout';
+}
 /**
  * This interface was referenced by `Config`'s JSON-Schema
  * via the `definition` "FormBlock".
@@ -1774,8 +1797,9 @@ export interface PagesSelect<T extends boolean = true> {
     | T
     | {
         biography?: T | BiographyBlockSelect<T>;
-        cta?: T | CallToActionBlockSelect<T>;
+        buttonsBlock?: T | ButtonsBlockSelect<T>;
         content?: T | ContentBlockSelect<T>;
+        contentWithCallout?: T | ContentWithCalloutBlockSelect<T>;
         formBlock?: T | FormBlockSelect<T>;
         imageLinkGrid?: T | ImageLinkGridSelect<T>;
         imageQuote?: T | ImageQuoteSelect<T>;
@@ -1812,14 +1836,13 @@ export interface BiographyBlockSelect<T extends boolean = true> {
 }
 /**
  * This interface was referenced by `Config`'s JSON-Schema
- * via the `definition` "CallToActionBlock_select".
+ * via the `definition` "ButtonsBlock_select".
  */
-export interface CallToActionBlockSelect<T extends boolean = true> {
-  richText?: T;
-  links?:
+export interface ButtonsBlockSelect<T extends boolean = true> {
+  buttons?:
     | T
     | {
-        link?:
+        button?:
           | T
           | {
               type?: T;
@@ -1849,6 +1872,16 @@ export interface ContentBlockSelect<T extends boolean = true> {
   id?: T;
   blockName?: T;
 }
+/**
+ * This interface was referenced by `Config`'s JSON-Schema
+ * via the `definition` "ContentWithCalloutBlock_select".
+ */
+export interface ContentWithCalloutBlockSelect<T extends boolean = true> {
+  richText?: T;
+  callout?: T;
+  id?: T;
+  blockName?: T;
+}
 /**
  * This interface was referenced by `Config`'s JSON-Schema
  * via the `definition` "FormBlock_select".
diff --git a/tailwind.config.mjs b/tailwind.config.mjs
index 67ebc3cea..59b421874 100644
--- a/tailwind.config.mjs
+++ b/tailwind.config.mjs
@@ -153,6 +153,11 @@ export default {
                 fontWeight: 'normal',
                 marginBottom: '0.25em',
               },
+              p: {
+                marginTop: '1em',
+                marginBottom: '1em',
+                lineHeight: 1.2,
+              },
             },
           ],
         },


Reviews:
user: login: busbyk
body: The mapping of button field variant -> shadcn buttom component variant -> semantic classname is currently correct. Although I wonder if renaming "default" to "primary" would be more clear. Not sure on that though. 

Based on the conversation in #108: should this be an even more generic component? If there's nothing membership-specific to this, we could call this like "ContentWithCallout" or something along those lines. If we did that maybe it would make sense to always have the callout too instead of enabling it with the checkbox since if a content editor didn't want the callout they could just use the Content block with a single column. 

If we did this maybe we'd want to:
1. Enable all variants of the `buttonBlock` in ContentWithCallout
2. and/or enable the `buttonBlock` in the Content component too -- thinking that content editors may want to use buttons for links in other content. 
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-12T16:25:17Z
user: login: rchlfryn
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-15T15:35:40Z
user: login: rchlfryn
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-15T15:36:42Z
user: login: rchlfryn
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-15T16:12:48Z
user: login: rchlfryn
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-15T16:22:27Z
user: login: busbyk
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-15T20:11:19Z
user: login: stevekuznetsov
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-15T22:15:50Z
user: login: rchlfryn
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-16T00:32:28Z
user: login: rchlfryn
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-16T00:33:06Z
user: login: rchlfryn
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-16T00:37:11Z
user: login: busbyk
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-16T07:14:07Z
user: login: busbyk
body: 🚀 
state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-05-16T07:14:49Z

Review comments:
diff_hunk: @@ -0,0 +1,21 @@
+import { button } from '@/fields/button'
+import type { Block } from 'payload'
+
+export const ButtonBlock: Block = {
path: src/blocks/Button/config.ts
user: login: busbyk
body: Should this be plural since its an array and could be multiple buttons?
created_at: 2025-05-12T16:15:04Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,21 @@
+import { button } from '@/fields/button'
+import type { Block } from 'payload'
+
+export const ButtonBlock: Block = {
+  slug: 'buttonBlock',
+  interfaceName: 'ButtonBlock',
+  fields: [
+    {
+      name: 'buttons',
+      type: 'array',
+      label: false,
+      labels: {
+        singular: 'Button',
+        plural: 'Buttons',
+      },
+      fields: [button(['default', 'secondary'])],
+      maxRows: 2,
path: src/blocks/Button/config.ts
user: login: busbyk
body: What about removing maxRows and adding flex-wrap to the ButtonBlock Component's container div? I know there's only two buttons in the membership design but if this is going to be a generic component editors may want to add more than two buttons. 

Unless we're intentionally trying to restrict users based on a design mentality that more than two buttons is too much?
created_at: 2025-05-12T16:18:19Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,22 @@
+import type { MembershipBlock as MembershipBlockProps } from '@/payload-types'
+
+import RichText from '@/components/RichText'
+
+export const MembershipBlock = ({ callout, enableCallout, richText }: MembershipBlockProps) => {
+  return (
+    <div className="container">
+      {enableCallout ? (
+        <div className="flex flex-col md:flex-row items-center gap-4">
+          {richText && <RichText className="mb-0" data={richText} enableGutter={false} />}
+          {callout && (
+            <div className="p-6 bg-slate-200 rounded-sm">
path: src/blocks/Membership/Component.tsx
user: login: busbyk
body: What about bg-brand-200 instead of bg-slate-200? I think that would fit with our intention for those classes in our themes. The NWAC theme specifically uses slate for the brand shades so it will be an exact match but I think it would work in other themes as well. 
created_at: 2025-05-12T16:21:03Z
author_association: COLLABORATOR
diff_hunk: @@ -8,21 +8,21 @@ import { SerializedEditorState } from '@payloadcms/richtext-lexical/lexical'
 import {
   JSXConvertersFunction,
   LinkJSXConverter,
-  RichText as RichTextWithoutBlocks,
+  RichText as RichTextWithConverters,
path: src/components/RichText/index.tsx
user: login: busbyk
body: I think this rename is just to avoid conflicting with the exported component name in this file. What about `RichTextPayload` or `RichTextLexical`?

Since we add the converters to this import on line 65 I think this name is a little confusing. 
created_at: 2025-05-12T16:23:45Z
author_association: COLLABORATOR
diff_hunk: @@ -153,6 +153,11 @@ export default {
                 fontWeight: 'normal',
                 marginBottom: '0.25em',
               },
+              p: {
+                marginTop: '1em',
+                marginBottom: '1em',
+                lineHeight: 1.2,
+              },
path: tailwind.config.mjs
user: login: busbyk
body: 🙌
created_at: 2025-05-12T16:25:09Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,21 @@
+import { button } from '@/fields/button'
+import type { Block } from 'payload'
+
+export const ButtonBlock: Block = {
+  slug: 'buttonBlock',
+  interfaceName: 'ButtonBlock',
+  fields: [
+    {
+      name: 'buttons',
+      type: 'array',
+      label: false,
+      labels: {
+        singular: 'Button',
+        plural: 'Buttons',
+      },
+      fields: [button(['default', 'secondary'])],
+      maxRows: 2,
path: src/blocks/Button/config.ts
user: login: rchlfryn
body: I think we can remove the `maxRows`. There is a flex already on the components so I was more worries about restricting users to not add 5 buttons, but I don't think that will happen. 
created_at: 2025-05-15T15:35:40Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,22 @@
+import type { MembershipBlock as MembershipBlockProps } from '@/payload-types'
+
+import RichText from '@/components/RichText'
+
+export const MembershipBlock = ({ callout, enableCallout, richText }: MembershipBlockProps) => {
+  return (
+    <div className="container">
+      {enableCallout ? (
+        <div className="flex flex-col md:flex-row items-center gap-4">
+          {richText && <RichText className="mb-0" data={richText} enableGutter={false} />}
+          {callout && (
+            <div className="p-6 bg-slate-200 rounded-sm">
path: src/blocks/Membership/Component.tsx
user: login: rchlfryn
body: I wrote and stashed the code before the themeing PR. I will update to `bg-brand-200`
created_at: 2025-05-15T15:36:42Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,21 @@
+import { button } from '@/fields/button'
+import type { Block } from 'payload'
+
+export const ButtonBlock: Block = {
path: src/blocks/Button/config.ts
user: login: rchlfryn
body: Updated it to be plural.
created_at: 2025-05-15T16:12:48Z
author_association: COLLABORATOR
diff_hunk: @@ -8,21 +8,21 @@ import { SerializedEditorState } from '@payloadcms/richtext-lexical/lexical'
 import {
   JSXConvertersFunction,
   LinkJSXConverter,
-  RichText as RichTextWithoutBlocks,
+  RichText as RichTextWithConverters,
path: src/components/RichText/index.tsx
user: login: rchlfryn
body: Woah - I don't know what I was thinking here. Updated to `RichTextLexical`
created_at: 2025-05-15T16:22:27Z
author_association: COLLABORATOR
path: src/fields/richText.ts
user: login: busbyk
body: We have richText type fields in a few places.
<img width="430" alt="image" src="https://github.com/user-attachments/assets/1cfeb541-a38e-426a-a1b6-ac241bea7028" />

If we're going to create a re-usable field I think we should make it a function that accepts some parameters like name and a features list. We could use this as a way to have a default list of features but then be able to override or add to the features list. 

We could also have re-usable richText fields for more specific uses like this one that has the Buttons block that maybe we wouldn't include in the default richText field?

Thoughts?
created_at: 2025-05-15T20:02:00Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,35 @@
+import type { Block } from 'payload'
+
+import { richText } from '@/fields/richText'
+import {
+  BlocksFeature,
+  FixedToolbarFeature,
+  HeadingFeature,
+  lexicalEditor,
+} from '@payloadcms/richtext-lexical'
+import { ButtonsBlock } from '../Buttons/config'
+
+export const ContentWithCallout: Block = {
+  slug: 'contentWithCallout',
+  interfaceName: 'ContentWithCalloutBlock',
+  fields: [
+    richText,
+    {
+      name: 'callout',
+      type: 'richText',
+      label: 'Callout',
+      editor: lexicalEditor({
+        features: ({ rootFeatures }) => {
+          return [
+            ...rootFeatures,
+            FixedToolbarFeature(),
+            BlocksFeature({
+              blocks: [ButtonsBlock],
+            }),
+            HeadingFeature({ enabledHeadingSizes: ['h2', 'h3', 'h4'] }),
+          ]
+        },
+      }),
+    },
path: src/blocks/ContentWithCallout/config.ts
user: login: busbyk
body: Based on my other comment about re-usable richText fields config - when to use a re-usable richText field function instead of just writing this out? 

I guess in this case this has fewer features than `@/fields/richText.ts` so it makes sense to just write it out? 🤷 
created_at: 2025-05-15T20:05:22Z
author_association: COLLABORATOR
diff_hunk: @@ -42,8 +43,8 @@ export const RenderBlock = ({ block, payload }: { block: Page['layout'][0]; payl
   switch (blockType) {
     case 'biography':
       return <BiographyBlock {...block} payload={payload} />
-    case 'cta':
-      return <CallToActionBlock {...block} />
+    case 'buttonsBlock':
+      return <ButtonsBlock {...block} />
path: src/blocks/RenderBlocks.tsx
user: login: busbyk
body: The rest of these blocks are "section-level" blocks but I don't think `src/blocks/Buttons/Component.tsx` would render well as a section-level block right? It doesn't use the `container` class or have any padding. 

If we want to allow this as a section-level block maybe add some props that will add the same classes we're using for other section-level block containers like `container py-16`?
created_at: 2025-05-15T20:09:28Z
author_association: COLLABORATOR
diff_hunk: @@ -97,8 +100,9 @@ export const Pages: CollectionConfig<'pages'> = {
               type: 'blocks',
               blocks: [
                 BiographyBlock,
-                CallToAction,
+                ButtonsBlock,
path: src/collections/Pages/index.ts
user: login: busbyk
body: Same thought as my comment in `src/blocks/RenderBlocks.tsx`
created_at: 2025-05-15T20:10:15Z
author_association: COLLABORATOR
path: src/fields/richText.ts
user: login: stevekuznetsov
body: The list of features is the only thing that this field currently has an opinion on, without that it's ~4 lines of code:

```ts
export const richText: Field = {
  name: 'richText',
  type: 'richText',
  editor: lexicalEditor({  }),
  label: false,
}
```

In cases like this, I prefer to follow the paradigm of "a little copying is better than a lot of dependency" - I would personally prefer to just not have the helper, since it does very little for us if we're not standardizing around a common set of features - we wouldn't be DRY-ing out anything of significant value. 

Conversely, if we _are_ standardizing around a specific set of features, and not making it configurable, I would expect this to have a semantically meaningful name (what's special about _this_ rich text field? why is it different than another? when would I choose to use it?)
created_at: 2025-05-15T22:15:49Z
author_association: CONTRIBUTOR
diff_hunk: @@ -97,8 +100,9 @@ export const Pages: CollectionConfig<'pages'> = {
               type: 'blocks',
               blocks: [
                 BiographyBlock,
-                CallToAction,
+                ButtonsBlock,
path: src/collections/Pages/index.ts
user: login: rchlfryn
body: Great catch!
created_at: 2025-05-16T00:32:28Z
author_association: COLLABORATOR
diff_hunk: @@ -42,8 +43,8 @@ export const RenderBlock = ({ block, payload }: { block: Page['layout'][0]; payl
   switch (blockType) {
     case 'biography':
       return <BiographyBlock {...block} payload={payload} />
-    case 'cta':
-      return <CallToActionBlock {...block} />
+    case 'buttonsBlock':
+      return <ButtonsBlock {...block} />
path: src/blocks/RenderBlocks.tsx
user: login: rchlfryn
body: great catch! I will take it out of section blocks for now
created_at: 2025-05-16T00:33:06Z
author_association: COLLABORATOR
path: src/fields/richText.ts
user: login: rchlfryn
body: I was just trying to make a `content richText` field but I will revert this change.
I think @stevekuznetsov makes a great point and copy and pasting a few lines is fine for now. If we want to make it a reusable component, that should be a separate PR.
created_at: 2025-05-16T00:37:11Z
author_association: COLLABORATOR
path: src/fields/richText.ts
user: login: busbyk
body: All sounds good to me! Agreed. 
created_at: 2025-05-16T07:14:07Z
author_association: COLLABORATOR

PR comments:
user: login: rchlfryn
created_at: 2025-05-15T15:43:47Z
body: > The mapping of button field variant -> shadcn buttom component variant -> semantic classname is currently correct. Although I wonder if renaming "default" to "primary" would be more clear. Not sure on that though.

I am not sure on this either. Maybe just change the label and leave a comment? What is the drive to use primary?

> Based on the conversation in #108: should this be an even more generic component? If there's nothing membership-specific to this, we could call this like "ContentWithCallout" or something along those lines. If we did that maybe it would make sense to always have the callout too instead of enabling it with the checkbox since if a content editor didn't want the callout they could just use the Content block with a single column.
> 
> If we did this maybe we'd want to:
> 
> 1. Enable all variants of the `buttonBlock` in ContentWithCallout
> 2. and/or enable the `buttonBlock` in the Content component too -- thinking that content editors may want to use buttons for links in other content.

1. I love the name change. And thats makes sense to me to add all variants
2. I like keeping the two components separate, so I will add the `buttonsBlock` to Content and also alter `ContentWithCallout` to not have a check box.
I made `richText` into its own field component so we wont have to update in multiple places
author_association: COLLABORATOR
user: login: github-actions[bot]
created_at: 2025-05-15T16:38:02Z
body: Preview deployment: https://blocksxmembership.preview.avy-fx.org
author_association: CONTRIBUTOR
user: login: busbyk
created_at: 2025-05-15T20:12:17Z
body: > > The mapping of button field variant -> shadcn buttom component variant -> semantic classname is currently correct. Although I wonder if renaming "default" to "primary" would be more clear. Not sure on that though.
> 
> I am not sure on this either. Maybe just change the label and leave a comment? What is the drive to use primary?

I think just to stay consistent with the theme classnames. But let's just leave it as "default". 

author_association: COLLABORATOR