first commit

This commit is contained in:
Matt Kane
2026-04-01 10:44:22 +01:00
commit 43fcb9a131
1789 changed files with 395041 additions and 0 deletions

View File

@@ -0,0 +1,33 @@
---
/**
* Custom Portable Text renderer for marketing blocks.
*
* This component maps custom block types (marketing.hero, marketing.features, etc.)
* to their corresponding Astro components. Pass it to the PortableText component
* via the `components` prop.
*/
import type { PortableTextBlock } from "emdash";
import { PortableText } from "emdash/ui";
import Hero from "./blocks/Hero.astro";
import Features from "./blocks/Features.astro";
import Testimonials from "./blocks/Testimonials.astro";
import Pricing from "./blocks/Pricing.astro";
import FAQ from "./blocks/FAQ.astro";
interface Props {
value: PortableTextBlock[];
}
const { value } = Astro.props;
// Custom block type components
const marketingTypes = {
"marketing.hero": Hero,
"marketing.features": Features,
"marketing.testimonials": Testimonials,
"marketing.pricing": Pricing,
"marketing.faq": FAQ,
};
---
<PortableText value={value} components={{ type: marketingTypes }} />

View File

@@ -0,0 +1,114 @@
---
interface Props {
node: {
_key?: string;
headline?: string;
items: Array<{
question: string;
answer: string;
}>;
};
}
const { node } = Astro.props;
const { _key, headline, items } = node;
---
<section class="faq section" id={_key}>
<div class="container">
{headline && (
<header class="faq-header">
<h2 class="faq-headline">{headline}</h2>
</header>
)}
<div class="faq-list">
{items.map((item) => (
<details class="faq-item" name="faq">
<summary class="faq-question">
<span>{item.question}</span>
<svg class="faq-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M6 9l6 6 6-6" />
</svg>
</summary>
<div class="faq-answer">
<p>{item.answer}</p>
</div>
</details>
))}
</div>
</div>
</section>
<style>
.faq-header {
text-align: center;
margin-bottom: var(--spacing-4xl);
}
.faq-headline {
font-size: var(--font-size-3xl);
font-weight: 800;
}
.faq-list {
max-width: var(--max-width);
margin: 0 auto;
display: flex;
flex-direction: column;
gap: var(--spacing-md);
}
.faq-item {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius);
overflow: hidden;
transition: border-color var(--transition-fast);
}
.faq-item:hover {
border-color: var(--color-muted);
}
.faq-item[open] {
border-color: var(--color-primary-light);
}
.faq-question {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--spacing-md);
padding: var(--spacing-lg);
font-size: var(--font-size-base);
font-weight: 600;
cursor: pointer;
list-style: none;
}
.faq-question::-webkit-details-marker {
display: none;
}
.faq-icon {
width: 20px;
height: 20px;
flex-shrink: 0;
color: var(--color-muted);
transition: transform var(--transition-base);
}
.faq-item[open] .faq-icon {
transform: rotate(180deg);
}
.faq-answer {
padding: 0 var(--spacing-lg) var(--spacing-lg);
}
.faq-answer p {
font-size: var(--font-size-sm);
color: var(--color-muted);
line-height: 1.7;
}
</style>

View File

@@ -0,0 +1,134 @@
---
interface Props {
node: {
_key?: string;
headline?: string;
subheadline?: string;
features: Array<{
icon: string;
title: string;
description: string;
}>;
};
}
const { node } = Astro.props;
const { _key, headline, subheadline, features } = node;
// Map feature icon names to Phosphor icon names
const iconMap: Record<string, string> = {
zap: "lightning",
shield: "shield-check",
users: "users-three",
chart: "chart-bar",
code: "code",
globe: "globe",
heart: "heart",
star: "star",
check: "check-circle",
lock: "lock",
clock: "clock",
cloud: "cloud",
};
---
<section class="features section" id={_key}>
<div class="container">
{(headline || subheadline) && (
<header class="features-header">
{headline && <h2 class="features-headline">{headline}</h2>}
{subheadline && <p class="features-subheadline">{subheadline}</p>}
</header>
)}
<div class="features-grid">
{features.map((feature) => (
<div class="feature-card">
<div class="feature-icon">
<i class={`ph ph-${iconMap[feature.icon] || "sparkle"}`} aria-hidden="true"></i>
</div>
<h3 class="feature-title">{feature.title}</h3>
<p class="feature-description">{feature.description}</p>
</div>
))}
</div>
</div>
</section>
<style>
.features-header {
text-align: center;
max-width: var(--max-width);
margin: 0 auto var(--spacing-4xl);
}
.features-headline {
font-size: var(--font-size-3xl);
font-weight: 800;
margin-bottom: var(--spacing-md);
}
.features-subheadline {
font-size: var(--font-size-lg);
color: var(--color-muted);
}
.features-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: var(--spacing-xl);
}
.feature-card {
padding: var(--spacing-xl);
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
transition:
transform var(--transition-base),
box-shadow var(--transition-base),
border-color var(--transition-base);
}
.feature-card:hover {
transform: translateY(-4px);
box-shadow: var(--shadow-lg);
border-color: var(--color-primary-light);
}
.feature-icon {
width: 48px;
height: 48px;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.5rem;
color: white;
background: linear-gradient(135deg, var(--color-primary), var(--color-accent));
border-radius: var(--radius);
margin-bottom: var(--spacing-lg);
}
.feature-title {
font-size: var(--font-size-lg);
font-weight: 700;
margin-bottom: var(--spacing-sm);
}
.feature-description {
font-size: var(--font-size-sm);
color: var(--color-muted);
line-height: 1.6;
}
@media (max-width: 900px) {
.features-grid {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 600px) {
.features-grid {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -0,0 +1,167 @@
---
interface Props {
node: {
headline: string;
subheadline?: string;
primaryCta?: { label: string; url: string };
secondaryCta?: { label: string; url: string };
image?: { url: string; alt?: string };
centered?: boolean;
};
}
const { node } = Astro.props;
const { headline, subheadline, primaryCta, secondaryCta, image, centered } = node;
---
<section class:list={["hero", { "hero-centered": centered, "hero-with-image": !!image }]}>
<div class="hero-content">
<h1 class="hero-headline">{headline}</h1>
{subheadline && <p class="hero-subheadline">{subheadline}</p>}
{(primaryCta || secondaryCta) && (
<div class="hero-actions">
{primaryCta && (
<a href={primaryCta.url} class="btn btn-primary btn-lg">
{primaryCta.label}
</a>
)}
{secondaryCta && (
<a href={secondaryCta.url} class="btn btn-secondary btn-lg">
{secondaryCta.label}
</a>
)}
</div>
)}
</div>
{image ? (
<div class="hero-image">
<img src={image.url} alt={image.alt || ""} loading="eager" />
</div>
) : !centered && (
<div class="hero-visual" aria-hidden="true">
<img src="/hero-visual.svg" alt="" width="800" height="800" />
</div>
)}
</section>
<style>
.hero {
max-width: var(--wide-width);
margin: 0 auto;
padding: var(--spacing-4xl) var(--spacing-lg);
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--spacing-2xl);
align-items: center;
}
.hero-centered {
grid-template-columns: 1fr;
text-align: center;
max-width: var(--max-width);
padding-top: var(--spacing-4xl);
padding-bottom: var(--spacing-xl);
}
.hero-centered .hero-content {
max-width: 100%;
}
.hero-centered .hero-actions {
justify-content: center;
}
.hero-content {
display: flex;
flex-direction: column;
gap: var(--spacing-lg);
max-width: 560px;
}
.hero-headline {
font-size: var(--font-size-5xl);
font-weight: 800;
line-height: 1.1;
letter-spacing: -0.03em;
background: linear-gradient(135deg, var(--color-text) 0%, var(--color-muted) 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.hero-subheadline {
font-size: var(--font-size-xl);
line-height: 1.6;
color: var(--color-muted);
}
.hero-actions {
display: flex;
gap: var(--spacing-md);
flex-wrap: wrap;
}
.hero-image {
position: relative;
}
.hero-image img {
width: 100%;
height: auto;
border-radius: var(--radius-lg);
box-shadow: var(--shadow-xl);
}
.hero-image::before {
content: "";
position: absolute;
inset: -10px;
background: linear-gradient(135deg, var(--color-primary-light) 0%, var(--color-accent-light) 100%);
border-radius: var(--radius-lg);
z-index: -1;
opacity: 0.3;
filter: blur(20px);
}
/* Hero visual (external SVG image) */
.hero-visual {
position: relative;
width: 100%;
max-width: 550px;
justify-self: center;
}
.hero-visual img {
width: 100%;
height: auto;
}
@media (max-width: 1024px) {
.hero {
grid-template-columns: 1fr;
padding: var(--spacing-2xl) var(--spacing-lg);
gap: var(--spacing-2xl);
}
.hero-headline {
font-size: var(--font-size-4xl);
}
.hero-subheadline {
font-size: var(--font-size-lg);
}
.hero-with-image {
text-align: center;
}
.hero-with-image .hero-actions {
justify-content: center;
}
.hero-image,
.hero-visual {
order: -1;
}
}
</style>

View File

@@ -0,0 +1,187 @@
---
interface Props {
node: {
headline?: string;
plans: Array<{
name: string;
price: string;
period?: string;
description?: string;
features: string[];
cta: { label: string; url: string };
highlighted?: boolean;
}>;
};
}
const { node } = Astro.props;
const { headline, plans } = node;
---
<section class="pricing section">
<div class="container">
{headline && (
<header class="pricing-header">
<h2 class="pricing-headline">{headline}</h2>
</header>
)}
<div class="pricing-grid">
{plans.map((plan) => (
<div class:list={["pricing-card", { "pricing-highlighted": plan.highlighted }]}>
{plan.highlighted && <div class="pricing-badge">Most popular</div>}
<div class="pricing-plan-header">
<h3 class="pricing-name">{plan.name}</h3>
<div class="pricing-price">
<span class="pricing-amount">{plan.price}</span>
{plan.period && <span class="pricing-period">{plan.period}</span>}
</div>
{plan.description && (
<p class="pricing-description">{plan.description}</p>
)}
</div>
<ul class="pricing-features">
{plan.features.map((feature) => (
<li>
<svg class="check-icon" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd" />
</svg>
{feature}
</li>
))}
</ul>
<a
href={plan.cta.url}
class:list={["btn", "btn-lg", "pricing-cta", { "btn-primary": plan.highlighted, "btn-secondary": !plan.highlighted }]}
>
{plan.cta.label}
</a>
</div>
))}
</div>
</div>
</section>
<style>
.pricing-header {
text-align: center;
margin-bottom: var(--spacing-2xl);
}
.pricing-headline {
font-size: var(--font-size-3xl);
font-weight: 800;
}
.pricing-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: var(--spacing-xl);
align-items: start;
}
.pricing-card {
position: relative;
padding: var(--spacing-xl);
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
display: flex;
flex-direction: column;
gap: var(--spacing-lg);
}
.pricing-highlighted {
background: var(--color-bg);
border-color: var(--color-primary);
box-shadow: var(--shadow-xl);
transform: scale(1.02);
}
.pricing-badge {
position: absolute;
top: 0;
left: 50%;
transform: translate(-50%, -50%);
padding: var(--spacing-xs) var(--spacing-md);
font-size: var(--font-size-xs);
font-weight: 600;
color: white;
background: linear-gradient(135deg, var(--color-primary) 0%, var(--color-accent) 100%);
border-radius: var(--radius-full);
}
.pricing-plan-header {
text-align: center;
padding-bottom: var(--spacing-lg);
border-bottom: 1px solid var(--color-border);
}
.pricing-name {
font-size: var(--font-size-lg);
font-weight: 700;
margin-bottom: var(--spacing-sm);
}
.pricing-price {
display: flex;
align-items: baseline;
justify-content: center;
gap: var(--spacing-xs);
margin-bottom: var(--spacing-sm);
}
.pricing-amount {
font-size: var(--font-size-4xl);
font-weight: 800;
letter-spacing: -0.03em;
}
.pricing-period {
font-size: var(--font-size-sm);
color: var(--color-muted);
}
.pricing-description {
font-size: var(--font-size-sm);
color: var(--color-muted);
}
.pricing-features {
list-style: none;
flex: 1;
display: flex;
flex-direction: column;
gap: var(--spacing-sm);
}
.pricing-features li {
display: flex;
align-items: flex-start;
gap: var(--spacing-sm);
font-size: var(--font-size-sm);
}
.check-icon {
width: 18px;
height: 18px;
flex-shrink: 0;
color: var(--color-success);
margin-top: 2px;
}
.pricing-cta {
width: 100%;
}
@media (max-width: 900px) {
.pricing-grid {
grid-template-columns: 1fr;
max-width: 400px;
margin: 0 auto;
}
.pricing-highlighted {
transform: none;
}
}
</style>

View File

@@ -0,0 +1,135 @@
---
interface Props {
node: {
headline?: string;
testimonials: Array<{
quote: string;
author: string;
role?: string;
company?: string;
avatar?: string;
}>;
};
}
const { node } = Astro.props;
const { headline, testimonials } = node;
---
<section class="testimonials section">
<div class="container">
{headline && (
<header class="testimonials-header">
<h2 class="testimonials-headline">{headline}</h2>
</header>
)}
<div class="testimonials-grid">
{testimonials.map((testimonial) => (
<div class="testimonial-card">
<blockquote class="testimonial-quote">
"{testimonial.quote}"
</blockquote>
<div class="testimonial-author">
{testimonial.avatar && (
<img
src={testimonial.avatar}
alt={testimonial.author}
class="testimonial-avatar"
loading="lazy"
/>
)}
<div class="testimonial-info">
<span class="testimonial-name">{testimonial.author}</span>
{(testimonial.role || testimonial.company) && (
<span class="testimonial-role">
{testimonial.role}
{testimonial.role && testimonial.company && " at "}
{testimonial.company}
</span>
)}
</div>
</div>
</div>
))}
</div>
</div>
</section>
<style>
.testimonials {
background: var(--color-surface);
}
.testimonials-header {
text-align: center;
margin-bottom: var(--spacing-4xl);
}
.testimonials-headline {
font-size: var(--font-size-3xl);
font-weight: 800;
}
.testimonials-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: var(--spacing-xl);
}
.testimonial-card {
padding: var(--spacing-xl);
background: var(--color-bg);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
display: flex;
flex-direction: column;
gap: var(--spacing-lg);
}
.testimonial-quote {
font-size: var(--font-size-lg);
line-height: 1.6;
color: var(--color-text);
flex: 1;
}
.testimonial-author {
display: flex;
align-items: center;
gap: var(--spacing-md);
}
.testimonial-avatar {
width: 48px;
height: 48px;
border-radius: var(--radius-full);
object-fit: cover;
}
.testimonial-info {
display: flex;
flex-direction: column;
}
.testimonial-name {
font-weight: 600;
font-size: var(--font-size-sm);
}
.testimonial-role {
font-size: var(--font-size-xs);
color: var(--color-muted);
}
@media (max-width: 900px) {
.testimonials-grid {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 600px) {
.testimonials-grid {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -0,0 +1,5 @@
export { default as Hero } from "./Hero.astro";
export { default as Features } from "./Features.astro";
export { default as Testimonials } from "./Testimonials.astro";
export { default as Pricing } from "./Pricing.astro";
export { default as FAQ } from "./FAQ.astro";