Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
167 commits
Select commit Hold shift + click to select a range
5cdf8fa
ambassador: add "Erica Hargreave"
Infi-Knight Feb 5, 2026
82568c7
ambassador: unpublish "Erica Hargreave"
Infi-Knight Feb 5, 2026
951ef03
ambassador: add "Erica Hargreave"
Infi-Knight Feb 5, 2026
ad97191
cohort: add "Ambassadors 2025"
Infi-Knight Feb 5, 2026
6565975
cohort: delete "Ambassadors 2025"
Infi-Knight Feb 5, 2026
92414da
cohort: add "Ambassadors 2025"
Infi-Knight Feb 5, 2026
9198b13
ambassador: add "Caroline Sinders"
Infi-Knight Feb 5, 2026
b5098aa
cohort: delete "Ambassadors 2025"
Infi-Knight Feb 5, 2026
bc8eb19
cohort: add "Ambassadors 2024"
Infi-Knight Feb 5, 2026
d38c0a3
cohort: delete "Ambassadors 2024"
Infi-Knight Feb 5, 2026
0e06b04
cohort: add "Ambassadors 2025"
Infi-Knight Feb 5, 2026
bc883f7
page: add "test-ambassadors" (en)
Infi-Knight Feb 5, 2026
9095382
page: unpublish "test-ambassadors" (en)
Infi-Knight Feb 5, 2026
6afb932
page: add "test-ambassadors" (en)
Infi-Knight Feb 5, 2026
cb6b748
page: unpublish "test-ambassadors" (en)
Infi-Knight Feb 5, 2026
237a14f
page: add "test-ambassadors" (en)
Infi-Knight Feb 5, 2026
fbf19d8
page: unpublish "test-ambassadors" (en)
Infi-Knight Feb 5, 2026
228b9a7
page: add "test-ambassadors" (en)
Infi-Knight Feb 5, 2026
a439b54
page: delete "test-ambassadors" (en)
Infi-Knight Feb 5, 2026
04d5680
wip: ambassador and associated content
Infi-Knight Feb 5, 2026
96bb093
cohort: add "alumni"
Infi-Knight Feb 5, 2026
1ee4905
page: add "ambassadors" (en)
Infi-Knight Feb 5, 2026
28ce95e
page: unpublish "ambassadors" (en)
Infi-Knight Feb 5, 2026
9a14401
page: add "ambassadors" (en)
Infi-Knight Feb 5, 2026
2990cf6
page: unpublish "ambassadors" (en)
Infi-Knight Feb 5, 2026
4540989
page: add "ambassadors" (en)
Infi-Knight Feb 5, 2026
e392298
remove cohort
Infi-Knight Feb 5, 2026
cc16af9
page: unpublish "ambassadors" (en)
Infi-Knight Feb 5, 2026
3c968bb
page: add "ambassadors" (en)
Infi-Knight Feb 5, 2026
8d367f0
page: unpublish "ambassadors" (en)
Infi-Knight Feb 5, 2026
c03a099
page: add "ambassadors" (en)
Infi-Knight Feb 5, 2026
a05c2a0
page: unpublish "ambassadors" (en)
Infi-Knight Feb 5, 2026
da5a7b1
page: add "ambassadors" (en)
Infi-Knight Feb 5, 2026
e4b2347
page: unpublish "ambassadors" (en)
Infi-Knight Feb 5, 2026
42eb625
page: add "ambassadors" (en)
Infi-Knight Feb 5, 2026
8ae8dfd
page: unpublish "ambassadors" (en)
Infi-Knight Feb 5, 2026
8ab06ba
page: add "ambassadors" (en)
Infi-Knight Feb 5, 2026
6412f64
page: add "test-ambs" (en)
Infi-Knight Feb 5, 2026
f07126a
page: delete "test-ambs" (en)
Infi-Knight Feb 5, 2026
a4f9e2a
page: add "test-page" (en)
Infi-Knight Feb 5, 2026
5a116dc
page: unpublish "test-page" (en)
Infi-Knight Feb 6, 2026
f4e783f
page: add "test-page" (en)
Infi-Knight Feb 6, 2026
c82ea96
page: unpublish "test-page" (en)
Infi-Knight Feb 6, 2026
541861e
page: add "test-page" (en)
Infi-Knight Feb 6, 2026
581327d
ambassador: unpublish "Erica Hargreave"
Infi-Knight Feb 6, 2026
59eb537
ambassador: add "Erica Hargreave"
Infi-Knight Feb 6, 2026
f20fca0
ambassador: unpublish "Erica Hargreave"
Infi-Knight Feb 6, 2026
fa35db9
ambassador: add "Erica Hargreave"
Infi-Knight Feb 6, 2026
3bed095
setup lifecycle and page to serialise and render ambassador
Infi-Knight Feb 6, 2026
bce1e1d
page: unpublish "test-page" (en)
Infi-Knight Feb 6, 2026
7262308
page: add "test-page" (en)
Infi-Knight Feb 6, 2026
cd12c10
page: unpublish "test-page" (en)
Infi-Knight Feb 6, 2026
db50d0b
page: add "test-page" (en)
Infi-Knight Feb 6, 2026
a388291
use ordering from strapi instead of sorting
Infi-Knight Feb 6, 2026
c284555
page: unpublish "test-page" (en)
Infi-Knight Feb 6, 2026
c73273e
page: add "test-page" (en)
Infi-Knight Feb 6, 2026
ce0644d
page: unpublish "test-page" (en)
Infi-Knight Feb 6, 2026
12aa1cc
page: add "test-page" (en)
Infi-Knight Feb 6, 2026
ea90e05
fix page previews
Infi-Knight Feb 6, 2026
e13723d
fix linter error
Infi-Knight Feb 6, 2026
34016c1
lint and format
Infi-Knight Feb 6, 2026
8130b39
format & lint
Infi-Knight Feb 6, 2026
b6deb4b
ambassador: add "Jeremiah Lee"
Infi-Knight Feb 6, 2026
de23b1e
ambassador: add "Kokayi Issa"
Infi-Knight Feb 6, 2026
b96982e
page: unpublish "test-page" (en)
Infi-Knight Feb 6, 2026
79ad847
page: add "test-page" (en)
Infi-Knight Feb 6, 2026
28e23ce
page: unpublish "test-page" (en)
Infi-Knight Feb 6, 2026
29e9ca2
page: add "test-page" (en)
Infi-Knight Feb 6, 2026
3239f5c
page: unpublish "test-page" (en)
Infi-Knight Feb 9, 2026
06910d5
page: add "test-page" (en)
Infi-Knight Feb 9, 2026
7441007
page: unpublish "test-page" (en)
Infi-Knight Feb 9, 2026
c023e50
page: add "test-page" (en)
Infi-Knight Feb 9, 2026
6c73a7a
page: delete "test-page" (en)
Infi-Knight Feb 9, 2026
7095784
page: add "test page" (en)
Infi-Knight Feb 9, 2026
44527dc
page: unpublish "test page" (en)
Infi-Knight Feb 9, 2026
75d287c
page: add "test page" (en)
Infi-Knight Feb 9, 2026
e64c620
update readme with preview setup
Infi-Knight Feb 9, 2026
62cf975
page: unpublish "test page" (en)
Infi-Knight Feb 9, 2026
a5483ff
page: add "test page" (en)
Infi-Knight Feb 9, 2026
8caad53
page: unpublish "test page" (en)
Infi-Knight Feb 9, 2026
a3f02c0
page: add "test page" (en)
Infi-Knight Feb 9, 2026
8083c2c
page: unpublish "test page" (en)
Infi-Knight Feb 9, 2026
55967a4
page: add "test page" (en)
Infi-Knight Feb 9, 2026
0dccd37
page: unpublish "test page" (en)
Infi-Knight Feb 9, 2026
f186c18
page: add "test page" (en)
Infi-Knight Feb 9, 2026
384169e
page: unpublish "test page" (en)
Infi-Knight Feb 9, 2026
9ba6677
page: add "test page" (en)
Infi-Knight Feb 9, 2026
ff793b8
page: unpublish "test page" (en)
Infi-Knight Feb 9, 2026
90dfc40
page: add "test page" (en)
Infi-Knight Feb 9, 2026
c3ba778
page: unpublish "test page" (en)
Infi-Knight Feb 9, 2026
3c29210
page: add "test page" (en)
Infi-Knight Feb 9, 2026
792388d
page: unpublish "test page" (en)
Infi-Knight Feb 9, 2026
d062943
page: add "test page" (en)
Infi-Knight Feb 9, 2026
4f67873
page: unpublish "test page" (en)
Infi-Knight Feb 9, 2026
c704cbd
page: add "test page" (en)
Infi-Knight Feb 9, 2026
19be915
page: unpublish "test page" (en)
Infi-Knight Feb 9, 2026
07f6cb2
page: add "test page" (en)
Infi-Knight Feb 9, 2026
fe9e1d2
page: unpublish "test page" (en)
Infi-Knight Feb 9, 2026
b636e2b
page: add "test page" (en)
Infi-Knight Feb 9, 2026
8dfb330
page: unpublish "test page" (en)
Infi-Knight Feb 9, 2026
542748d
page: add "test page" (en)
Infi-Knight Feb 9, 2026
9e16a79
page: unpublish "test page" (en)
Infi-Knight Feb 9, 2026
4ab8a86
page: add "test page" (en)
Infi-Knight Feb 9, 2026
91830ad
wip: blockquote
Infi-Knight Feb 9, 2026
0f7798d
update readme
Infi-Knight Feb 9, 2026
1dd5d46
page: unpublish "test page" (en)
Infi-Knight Feb 10, 2026
f221c60
page: add "test page" (en)
Infi-Knight Feb 10, 2026
1c5244a
page: unpublish "test page" (en)
Infi-Knight Feb 10, 2026
50547db
page: add "test page" (en)
Infi-Knight Feb 10, 2026
b40755c
page: unpublish "test page" (en)
Infi-Knight Feb 10, 2026
0c43d07
page: add "test page" (en)
Infi-Knight Feb 10, 2026
14f3751
page: unpublish "test page" (en)
Infi-Knight Feb 10, 2026
04bf7ca
page: add "test page" (en)
Infi-Knight Feb 10, 2026
55e89f9
page: unpublish "test page" (en)
Infi-Knight Feb 10, 2026
f08b729
page: add "test page" (en)
Infi-Knight Feb 10, 2026
29de6a3
use non-rich text for quote src, styling fixes
Infi-Knight Feb 10, 2026
2414ff7
page: unpublish "test page" (en)
Infi-Knight Feb 11, 2026
5e91732
page: add "test page" (en)
Infi-Knight Feb 11, 2026
66115ab
page: unpublish "test page" (en)
Infi-Knight Feb 11, 2026
ae0c0ad
page: add "test page" (en)
Infi-Knight Feb 11, 2026
02a6880
page: unpublish "test page" (en)
Infi-Knight Feb 11, 2026
410f49c
page: add "test page" (en)
Infi-Knight Feb 11, 2026
eefe333
page: unpublish "test page" (en)
Infi-Knight Feb 11, 2026
47f1869
page: add "test page" (en)
Infi-Knight Feb 11, 2026
7c5f937
page: unpublish "test page" (en)
Infi-Knight Feb 11, 2026
8e9d116
page: add "test page" (en)
Infi-Knight Feb 11, 2026
363353b
use css var for text color on cta button
Infi-Knight Feb 12, 2026
3ced2dc
wip: use css layers for organising base css
Infi-Knight Feb 16, 2026
9108f43
core setup
Infi-Knight Feb 18, 2026
5a3d53e
page: unpublish "test page" (en)
Infi-Knight Feb 18, 2026
ed7afe8
page: add "test page" (en)
Infi-Knight Feb 18, 2026
912a247
page: unpublish "test page" (en)
Infi-Knight Feb 18, 2026
f842cc2
page: add "test page" (en)
Infi-Knight Feb 18, 2026
7370954
page: unpublish "test page" (en)
Infi-Knight Feb 18, 2026
d1b545f
page: add "test page" (en)
Infi-Knight Feb 18, 2026
5e7e119
page: unpublish "test page" (en)
Infi-Knight Feb 18, 2026
0be9ab1
page: add "test page" (en)
Infi-Knight Feb 18, 2026
3530c34
page: unpublish "test page" (en)
Infi-Knight Feb 18, 2026
4255e65
page: add "test page" (en)
Infi-Knight Feb 18, 2026
3a28166
page: unpublish "test page" (en)
Infi-Knight Feb 18, 2026
26be0ec
page: add "test page" (en)
Infi-Knight Feb 18, 2026
0f3f804
page: unpublish "test page" (en)
Infi-Knight Feb 18, 2026
4c23bac
page: add "test page" (en)
Infi-Knight Feb 18, 2026
79154b1
page: unpublish "test page" (en)
Infi-Knight Feb 18, 2026
a5de358
page: add "test page" (en)
Infi-Knight Feb 18, 2026
407ba0b
page: unpublish "test page" (en)
Infi-Knight Feb 18, 2026
15c265e
page: add "test page" (en)
Infi-Knight Feb 18, 2026
cf86b58
page: unpublish "test page" (en)
Infi-Knight Feb 18, 2026
1b68a00
page: add "test page" (en)
Infi-Knight Feb 18, 2026
20f0013
page: unpublish "test page" (en)
Infi-Knight Feb 18, 2026
abfa8a0
page: add "test page" (en)
Infi-Knight Feb 18, 2026
748320e
page: unpublish "test page" (en)
Infi-Knight Feb 18, 2026
1c54de1
page: add "test page" (en)
Infi-Knight Feb 18, 2026
4546a03
page: unpublish "test page" (en)
Infi-Knight Feb 18, 2026
5383009
page: add "test page" (en)
Infi-Knight Feb 18, 2026
bbc6d50
page: unpublish "test page" (en)
Infi-Knight Feb 18, 2026
0e2be77
page: add "test page" (en)
Infi-Knight Feb 18, 2026
f8d490b
refactor to remove duplication between theme and variables
Infi-Knight Feb 18, 2026
60b4297
page: unpublish "test page" (en)
Infi-Knight Feb 18, 2026
edd6adb
page: add "test page" (en)
Infi-Knight Feb 18, 2026
9185fdc
page: unpublish "test page" (en)
Infi-Knight Feb 18, 2026
9bc2acb
page: add "test page" (en)
Infi-Knight Feb 18, 2026
03eea52
page: unpublish "test page" (en)
Infi-Knight Feb 18, 2026
2da4144
page: add "test page" (en)
Infi-Knight Feb 18, 2026
bab71c8
Merge origin/staging into rs/component-cta_button
Infi-Knight Mar 3, 2026
a3d2add
format
Infi-Knight Mar 3, 2026
31da0f7
cleanup
Infi-Knight Mar 3, 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
2 changes: 1 addition & 1 deletion astro.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,7 @@ export default defineConfig({
themes: ['github-dark-dimmed'],
styleOverrides: {
borderColor: 'transparent',
borderRadius: 'var(--border-radius)'
borderRadius: 'var(--radius)'
},
defaultProps: {
wrap: true
Expand Down
111 changes: 111 additions & 0 deletions cms/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -319,6 +319,117 @@ The parent selector (`blockquote`) retains Astro's scoped attribute, so styles o

See `Blockquote.astro` for an example using Option B.

### Content Preview

Content authors can preview draft pages before publishing. The preview system uses server-side rendering (SSR) to fetch draft content directly from Strapi at runtime, bypassing the build-time MDX pipeline entirely.

#### How it works

1. A content author makes changes in the Strapi admin panel
2. They click **Save / Draft**
3. They click **Open preview** (the button is disabled until the draft is saved or the content is published)
4. Strapi calls the `preview.handler` in `config/admin.ts`, which reads the document from the database and builds a preview URL based on the content type (e.g. `/page-preview?documentId=abc123`)
5. The browser opens `{CLIENT_URL}/page-preview?documentId=abc123` on the Astro dev/SSR server
6. The Astro preview route (`src/pages/page-preview.astro`, which has `prerender = false`) fetches the **draft** content from the Strapi API using the `documentId`
7. The page is rendered at runtime using the `DynamicZone` component, which maps Strapi block types to Astro components
8. The author can edit, save, and re-preview as many times as needed before publishing

**Note:** Preview requires saving first because Strapi reads the document from its database to generate the preview URL. Unsaved changes only exist in the browser and are not available to the preview handler.

This is intentionally separate from the published content flow. Published pages are statically generated from MDX files at build time and have no runtime dependency on Strapi.

#### Setup

**Strapi side** (`cms/.env`):

```env
CLIENT_URL=http://localhost:1103 # Must match the Astro dev server URL
```

The preview handler is configured in `cms/config/admin.ts`. To add preview support for a new content type, add a case to the `getPreviewPathname` switch statement.

**Astro side** (`.env` in project root):

```env
STRAPI_URL=http://localhost:1337
STRAPI_PREVIEW_TOKEN=<your-api-token>
```

The token must have read access to the content types you want to preview (including draft status). You can generate one in the Strapi admin under Settings > API Tokens.

#### Adding preview for a new content type

1. Add a case in `cms/config/admin.ts` `getPreviewPathname()` that returns the preview route path
2. Create an SSR page in `src/pages/` (e.g. `my-type-preview.astro`) with `export const prerender = false`
3. In that page, use `fetchStrapi()` with `status=draft` to fetch the draft content and render it

#### Adding a new block to the page dynamic zone

When you add a new block component to the page content dynamic zone, you **must** also add it to the populate params in `src/pages/page-preview.astro`. In Strapi v5, using `on` (component-specific population) for a dynamic zone acts as a filter — only block types listed in `on` clauses are returned in the API response. Unlisted types are silently excluded.

For blocks with only scalar fields (richtext, string, enum):

```js
'populate[content][on][blocks.my-block][populate]': '*'
```

For blocks with nested components or relations:

```js
'populate[content][on][blocks.my-block][populate][myRelation][populate]': '*'
```

If you forget this step, the block will not appear in the preview even though it exists in Strapi.

#### Component architecture: presentational vs block components

Every Strapi dynamic zone block type needs a corresponding **block component** in `src/components/blocks/` so that the `DynamicZone` component can render it during preview. Whether you also need a separate **presentational component** depends on the data shape.

**When a single component is enough:**

If the Strapi API data can be used directly with minimal transformation, one component can serve both preview and published content. For example, `Paragraph.astro` accepts a `content` string and handles the markdown-to-HTML conversion internally — no separate block adapter needed.

**When you need two components:**

If the Strapi API returns a different shape than what the presentational component expects (nested objects, markdown fields that need conversion, etc.), you need a block adapter to bridge the gap. For example:

- `src/components/ambassadors/Ambassador.astro` — **Presentational component**. Accepts simple, flat props (`name`, `description` as HTML string, `photo` as URL string) and renders the UI. Used by both the published site and preview. Has no knowledge of where the data comes from.

- `src/components/blocks/AmbassadorBlock.astro` — **Block adapter for preview**. Used only by `DynamicZone` during SSR preview. Receives raw Strapi API data (nested `photo` object, markdown `description`) and transforms it into the simple props the presentational component expects (extracts `photo.url`, converts markdown to HTML via `marked`).

For published content, the MDX lifecycle hook in Strapi handles these transformations at publish time, so the presentational component is used directly in the generated MDX. The block adapters are only needed for preview.

**Rule of thumb:** If you need to transform Strapi's API response before rendering (flatten nested objects, convert markdown to HTML, resolve relations), create a block adapter in `src/components/blocks/` that does the transformation and delegates to a presentational component. Otherwise, a single component in `src/components/blocks/` is fine.

#### Styling rendered HTML from `set:html`

Components that render Strapi richtext fields use `set:html` to inject HTML converted from markdown. Since this injected HTML doesn't receive Astro's scoped data attributes, child elements can inherit unwanted styles from page-level prose selectors (e.g. `[&_strong]:text-primary`).

There are two approaches to control styling of `set:html` content:

**Option A — Tailwind arbitrary variants on container elements:**

```html
<blockquote
class="[&_strong]:text-inherit [&_p]:mb-0 [&_em]:italic"
></blockquote>
```

Consistent with the pattern used in `[...page].astro` and `Paragraph.astro`. Keeps everything in the template but can get verbose with many overrides.

**Option B — Astro scoped `<style>` with `:global()`:**

```css
<style>
blockquote :global(strong) { color: inherit; }
blockquote :global(p) { margin-bottom: 0; }
</style>
```

The parent selector (`blockquote`) retains Astro's scoped attribute, so styles only apply within that component — they won't leak to other parts of the page. `:global()` removes scoping from the child selector so it can reach the injected HTML. Cleaner when there are multiple overrides.

See `Blockquote.astro` for an example using Option B.

## Development Workflow

1. **Start the CMS**:
Expand Down
23 changes: 23 additions & 0 deletions cms/src/components/blocks/cta-button.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
{
"collectionName": "components_blocks_cta_buttons",
"info": {
"displayName": "CTA Button",
"icon": "cursor",
"description": "Call-to-action button with link. Special tokens: <front> (home), <nolink> (text only), <button> (non-navigating button)"
},
"options": {},
"attributes": {
"text": {
"type": "string",
"required": true
},
"link": {
"type": "string",
"required": true,
"description": "Enter URL or special token: <front> for home page, <nolink> for text only, <button> for keyboard-accessible text"
},
"analytics_event_label": {
"type": "string"
}
}
}
26 changes: 26 additions & 0 deletions cms/src/serializers/blocks/cta-button.serializer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import jsesc from 'jsesc'

const esc = (v: string) => (v ? jsesc(v, { quotes: 'double' }) : '')

interface CtaButtonBlock {
__component: 'blocks.cta-button'
text: string
link: string
analytics_event_label?: string
}

export function serialize(block: CtaButtonBlock): string {
if (!block.text || !block.link) return ''

const attrs = [
`text="${esc(block.text)}"`,
`link="${esc(block.link)}"`,
block.analytics_event_label
? `analytics_event_label="${esc(block.analytics_event_label)}"`
: null
]
.filter(Boolean)
.join(' ')

return `<CtaButton ${attrs} />`
}
4 changes: 3 additions & 1 deletion cms/src/serializers/blocks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { serialize as ambassador } from './ambassador.serializer'
import { serialize as ambassadorsGrid } from './ambassadors-grid.serializer'
import { serialize as blockquote } from './blockquote.serializer'
import { serialize as calloutText } from './callout-text.serializer'
import { serialize as ctaButton } from './cta-button.serializer'

const SERIALIZERS: Record<string, (block: unknown) => string> = {
'blocks.cards-grid': cardsGrid,
Expand All @@ -24,7 +25,8 @@ const SERIALIZERS: Record<string, (block: unknown) => string> = {
'blocks.ambassador': ambassador,
'blocks.ambassadors-grid': ambassadorsGrid,
'blocks.blockquote': blockquote,
'blocks.callout-text': calloutText
'blocks.callout-text': calloutText,
'blocks.cta-button': ctaButton
}

export function serializeContent(
Expand Down
3 changes: 2 additions & 1 deletion cms/src/utils/pageLifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -197,7 +197,8 @@ async function fetchPublished(
},
'blocks.ambassadors-grid': {
populate: { ambassadors: true }
}
},
'blocks.cta-button': {}
}
}
}
Expand Down
2 changes: 1 addition & 1 deletion src/components/blocks/Carousel.astro
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ const carouselId = `carousel-${Math.random().toString(36).substr(2, 9)}`
{
items.map((item, index) => (
<div
class="hidden animate-[fadeIn_500ms_ease-in-out] data-[active=true]:block"
class="hidden animate-fade-in data-[active=true]:block"
data-index={index}
data-carousel-slide
data-active={index === 0 ? 'true' : 'false'}
Expand Down
22 changes: 22 additions & 0 deletions src/components/blocks/CtaButtonBlock.astro
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
---
/**
* CTA Button Block for Dynamic Zones (SSR Preview)
* Wrapper that passes Strapi data to the CtaButton presentation component
*/

import CtaButton from '../buttons/CtaButton.astro'

interface Props {
text: string
link: string
analytics_event_label?: string
}

const { text, link, analytics_event_label } = Astro.props
---

<CtaButton
text={text}
link={link}
analyticsEventLabel={analytics_event_label}
/>
2 changes: 2 additions & 0 deletions src/components/blocks/DynamicZone.astro
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import CardsGrid from './CardsGrid.astro'
import CardLinksGrid from './CardLinksGrid.astro'
import Carousel from './Carousel.astro'
import CtaBanner from './CtaBanner.astro'
import CtaButtonBlock from './CtaButtonBlock.astro'
import AmbassadorBlock from './AmbassadorBlock.astro'
import AmbassadorsGridBlock from './AmbassadorsGridBlock.astro'
import BlockquoteBlock from './BlockquoteBlock.astro'
Expand All @@ -35,6 +36,7 @@ const componentMap: Record<string, any> = {
'blocks.card-links-grid': CardLinksGrid,
'blocks.carousel': Carousel,
'blocks.cta-banner': CtaBanner,
'blocks.cta-button': CtaButtonBlock,
'blocks.ambassador': AmbassadorBlock,
'blocks.ambassadors-grid': AmbassadorsGridBlock,
'blocks.blockquote': BlockquoteBlock,
Expand Down
7 changes: 6 additions & 1 deletion src/components/blocks/Paragraph.astro
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,12 @@ const alignmentClass =
: ''
---

{
/* :where(a) keeps specificity low so styled components (e.g. CtaButton)
can override these prose defaults with their own Tailwind classes */
}
<div
class={`mx-auto max-w-narrow ${alignmentClass} [&_p]:mb-space-s [&_p]:leading-relaxed [&_a]:text-primary [&_a]:underline [&_a:hover]:no-underline [&_h2]:mt-space-l [&_h2]:mb-space-s [&_h2]:text-[1.75rem] [&_h2]:font-semibold [&_h2]:text-primary [&_h3]:mt-space-m [&_h3]:mb-space-xs [&_h3]:text-[1.25rem] [&_h3]:font-semibold [&_ul]:mb-space-s [&_ul]:ps-space-m [&_ol]:mb-space-s [&_ol]:ps-space-m [&_li]:mb-space-xs`}
data-prose
class={`mx-auto max-w-narrow ${alignmentClass}`}
set:html={htmlContent}
/>
121 changes: 121 additions & 0 deletions src/components/buttons/CtaButton.astro
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
---
/**
* CTA Button Component
* Styled call-to-action button with optional arrow icon
*
* Special link tokens:
* - <front> → Links to home page (/)
* - <nolink> → Displays text without link
* - <button> → Keyboard-accessible button (no navigation)
*/

interface Props {
text: string
link: string
analyticsEventLabel?: string
}

const { text, link: rawLink, analyticsEventLabel } = Astro.props

// Handle special link tokens
let link = rawLink
let isNoLink = false
let isButton = false

if (rawLink === '<front>') {
link = '/'
} else if (rawLink === '<nolink>') {
isNoLink = true
} else if (rawLink === '<button>') {
isButton = true
}

// Auto-detect external links
const isExternal =
!isNoLink &&
!isButton &&
(link.startsWith('http://') ||
link.startsWith('https://') ||
link.startsWith('//'))

const buttonClasses =
'inline-flex text-btn-txt items-center gap-space-2xs rounded-pill px-space-s py-space-xs text-step-0 font-medium no-underline bg-primary transition-all duration-fast focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary group hover:underline'

// For <nolink>, use plain text styling (no button appearance)
const noLinkClasses = 'text-step-0 font-medium'
---

{
/* Regular link */
!isNoLink && !isButton && (
<a
href={link}
class={buttonClasses}
data-umami-event={analyticsEventLabel ?? ''}
{...(isExternal && {
target: '_blank',
rel: 'noopener noreferrer',
'aria-label': `${text} (opens in new window)`
})}
>
{text}

{
/* Arrow icon - internal links only */
!isExternal && (
<svg
class="h-[0.6em] w-[0.6em] flex-none transition-transform duration-fast group-hover:translate-x-1"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 12 12"
fill="currentColor"
aria-hidden="true"
>
<path d="M11.92 5.62a1.001 1.001 0 0 0-.21-.33l-5-5a1.004 1.004 0 0 0-1.42 1.42L8.59 5H1a1 1 0 0 0 0 2h7.59l-3.3 3.29a1.002 1.002 0 0 0 .325 1.639 1 1 0 0 0 1.095-.219l5-5a1 1 0 0 0 .21-.33 1 1 0 0 0 0-.76Z" />
</svg>
)
}

{
/* External link icon */
isExternal && (
<svg
class="h-[0.6em] w-[0.6em] flex-none"
fill="none"
viewBox="0 0 18 18"
aria-hidden="true"
>
<path
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M7 3H3a2 2 0 0 0-2 2v10a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2v-4M11 1h6m0 0v6m0-6L7 11"
/>
</svg>
)
}
</a>
)
}

{
/* <button> token - real button element */
isButton && (
<button
type="button"
class={buttonClasses}
data-umami-event={analyticsEventLabel ?? ''}
>
{text}
</button>
)
}

{
/* <nolink> token - plain text, no interactive styling */
isNoLink && (
<span class={noLinkClasses} data-umami-event={analyticsEventLabel ?? ''}>
{text}
</span>
)
}
Loading
Loading