Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
7f3abaa
Change wrong use of CMSLink to Button
rchlfryn Jan 22, 2026
0637a38
Remove unused variants
rchlfryn Jan 22, 2026
2a402ce
Update coding guide
rchlfryn Jan 26, 2026
fee1c8b
Remove inline variant
rchlfryn Jan 26, 2026
a907e71
Make clearIrrelevantLinkValues a utility function
rchlfryn Jan 27, 2026
565ecf3
Refactor linkToPageOrPost
rchlfryn Jan 27, 2026
73748b4
Refactor navLink to use linkToPageOrPost
rchlfryn Jan 27, 2026
63b0b88
Remove linkToPageOrPostWithLabel
rchlfryn Jan 27, 2026
92c4262
Refactor to use linkField
rchlfryn Jan 27, 2026
26333bc
Undo navLink refactor
rchlfryn Jan 27, 2026
b03b5c2
Remove TODOs
rchlfryn Jan 27, 2026
5fe4642
Clean up defaultLexical url validation
rchlfryn Jan 27, 2026
1016993
Change buttonField name
rchlfryn Jan 27, 2026
22d692c
Add ButtonLink component
rchlfryn Jan 27, 2026
849808e
Fix LinkFeature converter to allow builtInPage linking & update doc c…
rchlfryn Jan 28, 2026
474ba26
Update thumbnails for button, single event and post
rchlfryn Jan 28, 2026
3577168
Change appearance to variant
rchlfryn Jan 28, 2026
c3c27b6
Consolidate CMSLink into ButtonLink
rchlfryn Jan 28, 2026
8d46264
Use type guard instead of type assertions (#901)
busbyk Jan 28, 2026
0f4eec7
Merge branch 'main' into link-cleanup
rchlfryn Jan 28, 2026
199ca80
Update slug types (maybe from #897)
rchlfryn Jan 28, 2026
3871063
Readd button variants to button ui
rchlfryn Jan 28, 2026
a3989aa
Move constants to separate file
rchlfryn Jan 28, 2026
a48bbe7
using a builder function to generate all of the three link field conf…
busbyk Jan 30, 2026
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
44 changes: 44 additions & 0 deletions docs/coding-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -182,3 +182,47 @@ blocks
└── config.ts
```

## ButtonLink
Links styled as buttons with built-in analytics tracking. Supports both direct URLs and CMS-driven references.

**Key features:**
- Works with both simple `href` prop and CMS references (pages, posts, built-in pages)
- Automatically resolves internal/external URLs from CMS data
- Built-in Posthog analytics with `button_click` event tracking
- Intended for blocks, rich text editors, or any CMS-driven navigation
- Supports all Button styling options

**Props:**
- `href` - Direct URL (use this OR reference - not both)
- `reference` - CMS reference object (use this OR href - not both)
- `type` - `'internal'` or `'external'` (required if using `reference`)
- `label` - Button text (falls back to reference title or children)
- `newTab` - Opens in new tab
- `url` - CMS custom URL option

**Style props**
- Size: (`sm`, `default`, `lg`, `icon`, `clear`)
- Variants: (`default`, `secondary`, `ghost`, `outline`, `callout`)

**Examples:**
```tsx
// Simple link with direct href
<ButtonLink href="/about" variant="default">
Learn More
</ButtonLink>

// External link in new tab and external link icon
<ButtonLink href="https://nwac.us" newTab>
Visit NWAC
<ExternalLink className="h-3.5 w-3.5 flex-shrink-0 mt-0.5" />
</ButtonLink>

// CMS-driven link (internal page reference)
<ButtonLink
reference={{ relationTo: 'pages', value: pageData }}
variant="outline"
size="lg"
>
Read More
</ButtonLink>
```
Binary file added public/thumbnail/ButtonThumbnail.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified public/thumbnail/SingleBlogPostThumbnail.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified public/thumbnail/SingleEventThumbnail.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
18 changes: 10 additions & 8 deletions src/app/(frontend)/[center]/events/[slug]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,14 @@ import configPromise from '@payload-config'
import { draftMode } from 'next/headers'
import { getPayload } from 'payload'

import { ButtonLink } from '@/components/ButtonLink'
import { EventInfo } from '@/components/EventInfo'
import { Media } from '@/components/Media'
import { Button } from '@/components/ui/button'
import { formatDateTime } from '@/utilities/formatDateTime'
import { generateMetaForEvent } from '@/utilities/generateMeta'
import { cn } from '@/utilities/ui'
import { ExternalLink } from 'lucide-react'
import { Metadata, ResolvedMetadata } from 'next'
import Link from 'next/link'
import { redirect } from 'next/navigation'

export const dynamic = 'force-static'
Expand Down Expand Up @@ -123,12 +122,15 @@ export default async function Event({ params: paramsPromise }: Args) {
</p>
)}
{!isPastEvent && !isRegistrationClosed ? (
<Button asChild size="lg" className="w-full md:w-auto">
<Link href={event.registrationUrl} target="_blank" rel="noopener noreferrer">
Register for Event
<ExternalLink className="w-4 h-4 flex-shrink-0 ml-2 -mt-1.5 lg:-mt-0.5 text-muted" />
</Link>
</Button>
<ButtonLink
href={event.registrationUrl}
size="lg"
className="w-full md:w-auto"
newTab
>
Register for Event
<ExternalLink className="w-4 h-4 flex-shrink-0 ml-2 -mt-1.5 lg:-mt-0.5 text-muted" />
</ButtonLink>
) : (
<p className="text-destructive font-medium">
{isPastEvent ? 'This event has passed' : 'Registration is closed'}
Expand Down
16 changes: 7 additions & 9 deletions src/app/(frontend)/[center]/not-found.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,4 @@
import Link from 'next/link'

import { Button } from '@/components/ui/button'
import { ButtonLink } from '@/components/ButtonLink'
import NotFoundClient from './not-found.client'

export default function NotFound() {
Expand All @@ -17,12 +15,12 @@ export default function NotFound() {
</p>
</div>
<div className="space-y-4 sm:space-y-0 sm:space-x-4 sm:flex sm:justify-center">
<Button asChild size="lg">
<Link href="/">Back to home</Link>
</Button>
<Button asChild variant="outline" size="lg">
<Link href="/forecasts/avalanche">Check the avalanche forecast</Link>
</Button>
<ButtonLink href="/" size="lg">
Back to home
</ButtonLink>
<ButtonLink href="/forecasts/avalanche" variant="outline" size="lg">
Check the avalanche forecast
</ButtonLink>
</div>
</div>
</div>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { ButtonLink } from '@/components/ButtonLink'
import { NACWidget } from '@/components/NACWidget'
import ObservationsDisclaimer from '@/components/ObservationsDisclaimer'
import { Button } from '@/components/ui/button'
import Link from 'next/link'

export default function SingleObservationPage({
title,
Expand All @@ -20,12 +19,12 @@ export default function SingleObservationPage({
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-1 sm:gap-4 prose dark:prose-invert max-w-none">
<h1 className="font-bold">{title}</h1>
<div className="flex flex-row flex-wrap gap-2">
<Button asChild variant="secondary" className="no-underline">
<Link href="/observations">Recent Observations</Link>
</Button>
<Button asChild variant="secondary" className="no-underline">
<Link href="/observations/submit">Submit Observation</Link>
</Button>
<ButtonLink variant="secondary" className="no-underline" href="/observations">
Recent Observations
</ButtonLink>
<ButtonLink variant="secondary" className="no-underline" href="/observations/submit">
Submit Observation
</ButtonLink>
</div>
</div>
<ObservationsDisclaimer />
Expand Down
9 changes: 4 additions & 5 deletions src/app/(frontend)/[center]/observations/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,12 @@ import type { Metadata, ResolvedMetadata } from 'next/types'
import configPromise from '@payload-config'
import { getPayload } from 'payload'

import { ButtonLink } from '@/components/ButtonLink'
import { NACWidget } from '@/components/NACWidget'
import { WidgetRouterHandler } from '@/components/NACWidget/WidgetRouterHandler.client'
import ObservationsDisclaimer from '@/components/ObservationsDisclaimer'
import { Button } from '@/components/ui/button'
import { getAvalancheCenterPlatforms } from '@/services/nac/nac'
import { getNACWidgetsConfig } from '@/utilities/getNACWidgetsConfig'
import Link from 'next/link'
import { notFound } from 'next/navigation'
import { ObservationLinkHijacker } from './ObservationLinkHijacker.client'

Expand Down Expand Up @@ -55,9 +54,9 @@ export default async function Page({ params }: Args) {
<div className="container flex flex-col gap-4">
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-1 sm:gap-4 prose dark:prose-invert max-w-none">
<h1 className="font-bold">Observations</h1>
<Button asChild variant="secondary" className="no-underline">
<Link href="/observations/submit">Submit Observation</Link>
</Button>
<ButtonLink href="/observations/submit" variant="secondary">
Submit Observation
</ButtonLink>
</div>
<ObservationsDisclaimer />
</div>
Expand Down
2 changes: 0 additions & 2 deletions src/app/(frontend)/[center]/theme-preview/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -414,11 +414,9 @@ export default async function Page({ params }: Args) {
<h3 className="text-lg font-semibold">Button</h3>
<div className="flex flex-wrap gap-2">
<Button variant="default">Default</Button>
<Button variant="destructive">Destructive</Button>
<Button variant="outline">Outline</Button>
<Button variant="secondary">Secondary</Button>
<Button variant="ghost">Ghost</Button>
<Button variant="link">Link</Button>
</div>
</section>

Expand Down
10 changes: 4 additions & 6 deletions src/app/[...segmentsNotFound]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,4 @@
import Link from 'next/link'

import { Button } from '@/components/ui/button'
import { ButtonLink } from '@/components/ButtonLink'

// Prevent caching 404 responses so new routes can take over when content is created
export const dynamic = 'force-dynamic'
Expand All @@ -17,9 +15,9 @@ export default function NotFound() {
</p>
</div>
<div className="space-y-4 sm:space-y-0 sm:space-x-4 sm:flex sm:justify-center">
<Button asChild size="lg">
<Link href="/">Find your local avalanche center</Link>
</Button>
<ButtonLink href="/" size="lg">
Find your local avalanche center
</ButtonLink>
</div>
</div>
</div>
Expand Down
12 changes: 7 additions & 5 deletions src/blocks/BlogList/Component.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,15 @@
'use client'

import { ButtonLink } from '@/components/ButtonLink'
import { PostPreviewSmallRow } from '@/components/PostPreviewSmallRow'
import RichText from '@/components/RichText'
import { Button } from '@/components/ui/button'
import type { BlogListBlock as BlogListBlockProps, Post } from '@/payload-types'
import { useTenant } from '@/providers/TenantProvider'
import {
filterValidPublishedRelationships,
filterValidRelationships,
} from '@/utilities/relationships'
import { cn } from '@/utilities/ui'
import Link from 'next/link'
import { useEffect, useState } from 'react'

type BlogListComponentProps = BlogListBlockProps & {
Expand Down Expand Up @@ -121,9 +120,12 @@ export const BlogListBlockComponent = (args: BlogListComponentProps) => {
)}
</div>
{postOptions === 'dynamic' && (
<Button asChild className="not-prose md:self-start">
<Link href={`/blog?${postsPageParams.toString()}`}>View all {heading}</Link>
</Button>
<ButtonLink
href={`/blog?${postsPageParams.toString()}`}
className="not-prose md:self-start"
>
View all {heading}
</ButtonLink>
)}
</div>
</div>
Expand Down
4 changes: 2 additions & 2 deletions src/blocks/Button/Component.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { CMSLink } from '@/components/Link'
import { ButtonLink } from '@/components/ButtonLink'
import type { ButtonBlock as ButtonBlockProps } from 'src/payload-types'

export const ButtonBlockComponent = ({ button }: ButtonBlockProps) => {
return (
<div className="my-4">
<CMSLink className="no-underline me-4" {...button} />
<ButtonLink className="no-underline me-4" {...button} />
</div>
)
}
5 changes: 3 additions & 2 deletions src/blocks/Button/config.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { button } from '@/fields/button'
import { buttonField } from '@/fields/button'
import type { Block } from 'payload'

export const ButtonBlock: Block = {
slug: 'buttonBlock',
interfaceName: 'ButtonBlock',
fields: [button(['default', 'secondary', 'destructive', 'ghost', 'link', 'outline'])],
imageURL: '/thumbnail/ButtonThumbnail.jpg',
fields: [buttonField(['default', 'secondary', 'ghost', 'outline'])],
}
12 changes: 7 additions & 5 deletions src/blocks/EventList/Component.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,13 @@
'use client'

import { ButtonLink } from '@/components/ButtonLink'
import { EventPreviewSmallRow } from '@/components/EventPreviewSmallRow'
import RichText from '@/components/RichText'
import { Button } from '@/components/ui/button'
import type { Event, EventListBlock as EventListBlockProps } from '@/payload-types'
import { useTenant } from '@/providers/TenantProvider'
import { filterValidPublishedRelationships } from '@/utilities/relationships'
import { cn } from '@/utilities/ui'
import { format } from 'date-fns'
import Link from 'next/link'
import { useEffect, useState } from 'react'

type EventListComponentProps = EventListBlockProps & {
Expand Down Expand Up @@ -128,9 +127,12 @@ export const EventListBlockComponent = (args: EventListComponentProps) => {
)}
</div>
{eventOptions === 'dynamic' && (
<Button asChild className="not-prose md:self-start">
<Link href={`/events?${eventsPageParamsString}`}>View all {heading}</Link>
</Button>
<ButtonLink
href={`/events?${eventsPageParamsString}`}
className="not-prose md:self-start"
>
View all {heading}
</ButtonLink>
)}
</div>
</div>
Expand Down
11 changes: 2 additions & 9 deletions src/blocks/ImageLinkGrid/config.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { linkToPageOrPost } from '@/fields/linkToPageOrPost'
import { linkField } from '@/fields/linkField'
import { getImageTypeFilter } from '@/utilities/collectionFilters'
import type { Block } from 'payload'

Expand All @@ -22,14 +22,7 @@ export const ImageLinkGridBlock: Block = {
required: true,
filterOptions: getImageTypeFilter,
},
{
name: 'link',
type: 'group',
admin: {
hideGutter: true,
},
fields: [...linkToPageOrPost],
},
linkField(),
{
name: 'caption',
type: 'text',
Expand Down
4 changes: 2 additions & 2 deletions src/blocks/LinkPreview/Component.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { CMSLink } from '@/components/Link'
import { ButtonLink } from '@/components/ButtonLink'
import { Media } from '@/components/Media'
import RichText from '@/components/RichText'
import {
Expand Down Expand Up @@ -67,7 +67,7 @@ export const LinkPreviewBlockComponent = (props: LinkPreviewBlockProps) => {
</div>
</CardContent>
<CardFooter className="flex justify-between">
<CMSLink {...button} />
<ButtonLink {...button} />
</CardFooter>
</Card>
)
Expand Down
4 changes: 2 additions & 2 deletions src/blocks/LinkPreview/config.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { Block, Field } from 'payload'

import { button } from '@/fields/button'
import { buttonField } from '@/fields/button'
import colorPickerField from '@/fields/color'
import { titleField } from '@/fields/title'
import { getImageTypeFilter } from '@/utilities/collectionFilters'
Expand All @@ -25,7 +25,7 @@ const cardFields: Field[] = [
type: 'textarea',
required: true,
},
button(['default', 'secondary', 'outline']),
buttonField(['default', 'secondary', 'outline']),
]

export const LinkPreviewBlock: Block = {
Expand Down
11 changes: 2 additions & 9 deletions src/collections/Redirects/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { accessByTenantRole } from '@/access/byTenantRole'
import { filterByTenant } from '@/access/filterByTenant'
import { contentHashField } from '@/fields/contentHashField'
import { linkToPageOrPost } from '@/fields/linkToPageOrPost'
import { linkField } from '@/fields/linkField'
import { tenantField } from '@/fields/tenantField'
import { CollectionConfig } from 'payload'
import { revalidateRedirect, revalidateRedirectDelete } from './hooks/revalidateRedirect'
Expand Down Expand Up @@ -33,14 +33,7 @@ export const Redirects: CollectionConfig = {
beforeChange: [validateFrom],
},
},
{
name: 'to',
type: 'group',
admin: {
hideGutter: true,
},
fields: [...linkToPageOrPost],
},
linkField({ fieldName: 'to' }),
tenantField(),
contentHashField(),
],
Expand Down
Loading