feat: implement forms, SEO, sitemap, and other infrastructure improvements
Some checks failed
Build and Deploy to Storj / build-and-deploy (push) Failing after 0s

This commit is contained in:
Scott Scoble 2026-05-01 22:34:38 -07:00
parent e9af3a9c27
commit e68db9c26c
14 changed files with 791 additions and 102 deletions

24
public/manifest.json Normal file
View file

@ -0,0 +1,24 @@
{
"name": "Scott Scoble",
"short_name": "Scott Scoble",
"description": "Human Cultivation Expert for the AI Age",
"start_url": "/",
"display": "standalone",
"background_color": "#faf8f5",
"theme_color": "#3d5a3e",
"orientation": "portrait-primary",
"icons": [
{
"src": "/icons/icon-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "/icons/icon-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"
}
]
}

19
public/robots.txt Normal file
View file

@ -0,0 +1,19 @@
# robots.txt for scott.scoble.me
# https://scott.scoble.me
User-agent: *
Allow: /
# Sitemap
Sitemap: https://scott.scoble.me/sitemap.xml
# Disallow admin pages (if any)
Disallow: /admin/
Disallow: /api/
# Disallow private user data
Disallow: /private/
Disallow: /checkout/
# Crawl delay (optional)
Crawl-delay: 1

View file

@ -9,11 +9,13 @@ const currentYear = new Date().getFullYear();
<div class="footer-brand"> <div class="footer-brand">
<span class="footer-logo">Scott Scoble</span> <span class="footer-logo">Scott Scoble</span>
<p class="footer-tagline">The human cultivation expert for the AI age.</p> <p class="footer-tagline">The human cultivation expert for the AI age.</p>
<p class="footer-email">📧 <a href="mailto:scott@scoble.me">scott@scoble.me</a></p>
<div class="footer-social"> <div class="footer-social">
<a href="https://linkedin.com/in/scottscoble" target="_blank" rel="noopener" aria-label="LinkedIn"> <a href="https://linkedin.com/in/scottscoble" target="_blank" rel="noopener" aria-label="LinkedIn">
<svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor"><path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433a2.062 2.062 0 01-2.063-2.065 2.064 2.064 0 112.063 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z"/></svg> <svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor"><path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433a2.062 2.062 0 01-2.063-2.065 2.064 2.064 0 112.063 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z"/></svg>
</a> </a>
<a href="https://twitter.com/scottscoble" target="_blank" rel="noopener" aria-label="X/Twitter"> <a href="https://x.com/scottscoble" target="_blank" rel="noopener" aria-label="X/Twitter">
<svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor"><path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"/></svg> <svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor"><path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"/></svg>
</a> </a>
<a href="https://github.com/sscoble" target="_blank" rel="noopener" aria-label="GitHub"> <a href="https://github.com/sscoble" target="_blank" rel="noopener" aria-label="GitHub">
@ -22,6 +24,16 @@ const currentYear = new Date().getFullYear();
</div> </div>
</div> </div>
<div class="footer-newsletter">
<h4>Stay in the loop</h4>
<p>Get updates on new talks, workshops, and writings.</p>
<form class="newsletter-form">
<input type="email" placeholder="Your email" required />
<button type="submit">Subscribe</button>
</form>
<p class="newsletter-note">No spam. Unsubscribe anytime.</p>
</div>
<div class="footer-links"> <div class="footer-links">
<div class="footer-col"> <div class="footer-col">
<h4>Pages</h4> <h4>Pages</h4>
@ -61,8 +73,8 @@ const currentYear = new Date().getFullYear();
.footer-main { .footer-main {
display: grid; display: grid;
grid-template-columns: 2fr 1fr; grid-template-columns: 1.5fr 1.5fr 1fr;
gap: 4rem; gap: 3rem;
padding-bottom: 3rem; padding-bottom: 3rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.1); border-bottom: 1px solid rgba(255, 255, 255, 0.1);
margin-bottom: 2rem; margin-bottom: 2rem;
@ -83,6 +95,21 @@ const currentYear = new Date().getFullYear();
font-size: 1rem; font-size: 1rem;
} }
.footer-email {
margin: 0 0 1.5rem;
font-size: 0.9375rem;
color: rgba(255, 255, 255, 0.8);
}
.footer-email a {
color: var(--color-amber);
text-decoration: none;
}
.footer-email a:hover {
text-decoration: underline;
}
.footer-social { .footer-social {
display: flex; display: flex;
gap: 1rem; gap: 1rem;
@ -97,9 +124,71 @@ const currentYear = new Date().getFullYear();
color: var(--color-amber); color: var(--color-amber);
} }
.footer-newsletter {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.footer-newsletter h4 {
font-size: 0.875rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.1em;
color: rgba(255, 255, 255, 0.5);
margin: 0 0 0.25rem;
}
.footer-newsletter p {
color: rgba(255, 255, 255, 0.8);
font-size: 0.9375rem;
margin: 0 0 1rem;
}
.newsletter-form {
display: flex;
gap: 0.5rem;
}
.newsletter-form input {
flex: 1;
padding: 0.625rem 0.875rem;
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 4px;
background: rgba(0, 0, 0, 0.2);
color: white;
font-size: 0.9375rem;
}
.newsletter-form input:focus {
outline: none;
border-color: var(--color-amber);
}
.newsletter-form button {
padding: 0.625rem 1.25rem;
background: var(--color-amber);
color: white;
border: none;
border-radius: 4px;
font-weight: 600;
font-size: 0.9375rem;
cursor: pointer;
}
.newsletter-form button:hover {
background: var(--color-amber-light);
}
.newsletter-note {
font-size: 0.75rem;
color: rgba(255, 255, 255, 0.5);
margin: 0;
}
.footer-links { .footer-links {
display: flex; display: flex;
gap: 3rem; gap: 2rem;
} }
.footer-col { .footer-col {
@ -136,6 +225,12 @@ const currentYear = new Date().getFullYear();
color: rgba(255, 255, 255, 0.5); color: rgba(255, 255, 255, 0.5);
} }
@media (max-width: 960px) {
.footer-main {
grid-template-columns: 1fr 1fr;
}
}
@media (max-width: 640px) { @media (max-width: 640px) {
.footer-main { .footer-main {
grid-template-columns: 1fr; grid-template-columns: 1fr;

View file

@ -0,0 +1,293 @@
---
import { useState } from 'astro:collection-utils';
interface FormProps {
type: 'speaking' | 'contact' | 'waitlist';
onSubmit?: () => void;
}
const { type } = Astro.props;
// Formspree endpoint - Scott will update with his Formspree form ID
const FORMSPREE_ENDPOINT = import.meta.env.PUBLIC_FORMSPREE_ENDPOINT || '';
// Contact form topic routing
const TOPIC_EMAILS = {
speaking: 'scott@scoble.me',
consulting: 'scott@scoble.me',
general: 'scott@scoble.me',
};
// Form field definitions based on type
const getFormFields = () => {
switch (type) {
case 'speaking':
return [
{ name: 'name', label: 'Name *', type: 'text', required: true },
{ name: 'email', label: 'Email *', type: 'email', required: true },
{ name: 'event_date', label: 'Event Date', type: 'date', required: false },
{ name: 'event_type', label: 'Event Type', type: 'select', required: false, options: [
{ value: '', label: 'Select...' },
{ value: 'conference', label: 'Conference' },
{ value: 'corporate', label: 'Corporate Event' },
{ value: 'workshop', label: 'Workshop' },
{ value: 'other', label: 'Other' },
] },
{ name: 'message', label: 'Message *', type: 'textarea', required: true, rows: 4 },
];
case 'contact':
return [
{ name: 'name', label: 'Name *', type: 'text', required: true },
{ name: 'email', label: 'Email *', type: 'email', required: true },
{ name: 'topic', label: 'Topic', type: 'select', required: false, options: [
{ value: '', label: 'Select...' },
{ value: 'speaking', label: 'Speaking Inquiry' },
{ value: 'consulting', label: 'Consulting Inquiry' },
{ value: 'general', label: 'General' },
] },
{ name: 'message', label: 'Message *', type: 'textarea', required: true, rows: 5 },
];
case 'waitlist':
return [
{ name: 'email', label: 'Email *', type: 'email', required: true },
];
}
};
const isWaiting = useState(false);
const isSuccess = useState(false);
const errorMessage = useState('');
const handleSubmit = async (e: any) => {
e.preventDefault();
if (isWaiting.value) return;
isWaiting.value = true;
isSuccess.value = false;
errorMessage.value = '';
const form = e.target as HTMLFormElement;
const formData = new FormData(form);
// Add form type for routing
formData.append('form_type', type);
try {
if (FORMSPREE_ENDPOINT) {
const response = await fetch(FORMSPREE_ENDPOINT, {
method: 'POST',
body: formData,
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.error?.message || 'Form submission failed');
}
}
isSuccess.value = true;
// Track success event for Umami
if (typeof window !== 'undefined' && window.umami) {
window.umami.track('form_submitted', {
type: type,
success: true,
});
}
} catch (error) {
errorMessage.value = error instanceof Error ? error.message : 'An error occurred';
} finally {
isWaiting.value = false;
}
};
// Check for URL query param for contact form topic pre-selection
const topicFromUrl = typeof window !== 'undefined' && type === 'contact'
? new URL(window.location.href).searchParams.get('topic')
: null;
---
<form
onSubmit={handleSubmit}
data-form-type={type}
novalidate
{ ...Astro.attrs }
>
{import.meta.env.PUBLIC_FORMSPREE_HONEYPOT && (
<div style="position: absolute; left: -9999px;">
<input
type="text"
name={import.meta.env.PUBLIC_FORMSPREE_HONEYPOT}
tabIndex={-1}
autoComplete="off"
/>
</div>
)}
{isSuccess ? (
<div class="form-success">
<h4>Thank you!</h4>
<p>Your message has been sent. I'll get back to you shortly.</p>
</div>
) : (
<>
{getFormFields().map((field) => (
<div class="form-group">
<label htmlFor={field.name}>
{field.label}
{field.required && <span class="required">*</span>}
</label>
{field.type === 'select' ? (
<select
id={field.name}
name={field.name}
required={field.required}
defaultValue={topicFromUrl || ''}
>
{field.options?.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
) : field.type === 'textarea' ? (
<textarea
id={field.name}
name={field.name}
rows={field.rows || 4}
required={field.required}
/>
) : (
<input
type={field.type}
id={field.name}
name={field.name}
required={field.required}
{...(field.type === 'date' && { min: new Date().toISOString().split('T')[0] })}
/>
)}
{field.type === 'email' && (
<p class="hint">We'll keep your email private and never share it.</p>
)}
</div>
))}
<button type="submit" disabled={isWaiting}>
{isWaiting ? (
<span class="spinner" aria-hidden="true"></span>
) : null}
{type === 'waitlist' ? 'Join Waitlist' : 'Send Message'}
</button>
{errorMessage && (
<p class="form-error" role="alert">
{errorMessage}
</p>
)}
</>
)}
</form>
<style>
:global(.form-success) {
text-align: center;
padding: 2rem;
background: var(--color-green);
color: white;
border-radius: 8px;
}
:global(.form-success h4) {
font-size: 1.5rem;
margin: 0 0 0.5rem;
}
:global(.form-group) {
margin-bottom: 1.25rem;
}
:global(.form-group label) {
display: block;
font-weight: 500;
margin-bottom: 0.375rem;
color: var(--color-brown-dark);
}
:global(.form-group label .required) {
color: var(--color-green);
}
:global(.form-group input),
:global(.form-group select),
:global(.form-group textarea) {
width: 100%;
padding: 0.625rem;
border: 1px solid var(--color-border);
border-radius: 4px;
font-size: 1rem;
font-family: inherit;
}
:global(.form-group input:focus),
:global(.form-group select:focus),
:global(.form-group textarea:focus) {
outline: none;
border-color: var(--color-green);
}
:global(.form-group .hint) {
font-size: 0.875rem;
color: var(--color-text-muted);
margin: 0.25rem 0 0;
}
:global(.form-group button) {
background: var(--color-amber);
color: white;
padding: 0.75rem 1.5rem;
border: none;
border-radius: 4px;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
width: 100%;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
}
:global(.form-group button:hover) {
background: var(--color-amber-light);
}
:global(.form-group button:disabled) {
opacity: 0.7;
cursor: not-allowed;
}
:global(.spinner) {
width: 16px;
height: 16px;
border: 2px solid white;
border-top-color: transparent;
border-radius: 50%;
animation: spin 0.75s linear infinite;
}
:global(.form-error) {
background: #dc2626;
color: white;
padding: 0.75rem;
border-radius: 4px;
margin-top: 1rem;
font-size: 0.9375rem;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
</style>

223
src/components/SEO.astro Normal file
View file

@ -0,0 +1,223 @@
---
import { useState } from 'astro:collection-utils';
interface SEOProps {
title: string;
description: string;
ogImage?: string;
ogType?: 'website' | 'article' | 'book' | 'profile';
url?: string;
pageType?: 'home' | 'speaking' | 'books' | 'consulting' | 'contact' | 'blog';
}
const {
title,
description,
ogImage = '/social-card.png',
ogType = 'website',
url = Astro.url.pathname,
pageType = 'home',
} = Astro.props;
const siteUrl = 'https://scott.scoble.me';
const fullTitle = title.includes('Scott Scoble') ? title : `${title} — Scott Scoble`;
const imageUrl = new URL(ogImage, siteUrl).href;
const pageUrl = new URL(url, siteUrl).href;
---
<meta name="title" content={fullTitle} />
<meta name="description" content={description} />
<meta name="author" content="Scott Scoble" />
<meta property="og:title" content={fullTitle} />
<meta property="og:description" content={description} />
<meta property="og:type" content={ogType} />
<meta property="og:url" content={pageUrl} />
<meta property="og:image" content={imageUrl} />
<meta property="og:site_name" content="Scott Scoble" />
<meta property="og:locale" content="en_US" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content={fullTitle} />
<meta name="twitter:description" content={description} />
<meta name="twitter:image" content={imageUrl} />
<meta name="twitter:site" content="@scottscoble" />
<link rel="canonical" href={pageUrl} />
<JsonLd pageType={pageType} />
<Style is:global>
:root {
--color-brown: #4a4438;
--color-brown-dark: #2d2a24;
--color-green: #3d5a3e;
--color-cream: #faf8f5;
--color-amber: #c8952e;
}
/* Base theme color */
meta[name="theme-color"][content] {
content: var(--color-green);
}
</Style>
---
function JsonLd({ pageType }: { pageType: SEOProps['pageType'] }) {
const person = {
'@type': 'Person',
'@id': `${siteUrl}#person`,
name: 'Scott Scoble',
givenName: 'Scott',
familyName: 'Scoble',
jobTitle: 'Human Cultivation Expert',
url: siteUrl,
email: 'mailto:scott@scoble.me',
sameAs: [
'https://linkedin.com/in/scottscoble',
'https://x.com/scottscoble',
'https://github.com/sscoble',
'https://amzn.com/author/scottscoble',
],
};
const website = {
'@type': 'WebSite',
'@id': `${siteUrl}#website`,
name: 'Scott Scoble',
description: 'Human Cultivation Expert for the AI Age',
url: siteUrl,
};
switch (pageType) {
case 'home':
return (
<script type="application/ld+json">
{JSON.stringify([person, website], null, 2)}
</script>
);
case 'speaking':
return (
<script type="application/ld+json">
{JSON.stringify(
{
'@type': 'Person',
'@id': `${siteUrl}#speaker`,
name: 'Scott Scoble',
jobTitle: 'Keynote Speaker',
description: 'Human Cultivation Expert speaking on AI, leadership, and team cultivation',
url: siteUrl,
sameAs: person.sameAs,
},
null,
2
)}
</script>
);
case 'books':
return (
<script type="application/ld+json">
{JSON.stringify(
[
{
'@type': 'Book',
name: 'The Soil Beneath the Code',
author: 'Scott Scoble',
datePublished: '2023',
bookFormat: 'EBook, Hardcover',
isbn: '',
},
{
'@type': 'Book',
name: 'A Walk in Mentoring',
author: 'Scott Scoble',
datePublished: '2024',
bookFormat: 'EBook, Paperback',
isbn: '',
},
{
'@type': 'Book',
name: 'Dark Provenance',
author: 'Scott Scoble',
datePublished: '2026',
bookFormat: 'EBook, Hardcover',
isbn: '',
position: 'Forthcoming',
},
],
null,
2
)}
</script>
);
case 'consulting':
return (
<script type="application/ld+json">
{JSON.stringify(
{
'@type': 'Service',
name: 'AI Team Consulting',
description: 'Helping leaders build AI systems that cultivate humans instead of strip-mining them',
provider: {
'@type': 'Person',
name: 'Scott Scoble',
},
serviceType: 'Professional Consulting',
areaServed: 'Worldwide',
offering: [
{
'@type': 'Offering',
name: 'Keynote Speaking',
description: '45-60 minute keynotes on AI, leadership, and team cultivation',
},
{
'@type': 'Offering',
name: 'Workshops',
description: 'Half-day or full-day workshops on cultivation practices',
},
],
},
null,
2
)}
</script>
);
case 'contact':
return (
<script type="application/ld+json">
{JSON.stringify(
{
'@type': 'ContactPoint',
telephone: '+1-555-000-0000',
email: 'scott@scoble.me',
contactType: 'Customer Service',
availableLanguage: 'English',
},
null,
2
)}
</script>
);
case 'blog':
default:
return (
<script type="application/ld+json">
{JSON.stringify(
{
'@type': 'Blog',
name: 'Scott Scoble Blog',
description: 'Thoughts on AI, leadership, and human cultivation',
url: `${siteUrl}/blog`,
},
null,
2
)}
</script>
);
}
}

View file

@ -3,6 +3,7 @@
import '../styles/global.css'; import '../styles/global.css';
import Nav from '../components/Nav.astro'; import Nav from '../components/Nav.astro';
import Footer from '../components/Footer.astro'; import Footer from '../components/Footer.astro';
import SEO from '../components/SEO.astro';
export interface Props { export interface Props {
title: string; title: string;
@ -10,6 +11,7 @@ export interface Props {
ogImage?: string; ogImage?: string;
ogType?: string; ogType?: string;
canonicalUrl?: string; canonicalUrl?: string;
pageType?: 'home' | 'speaking' | 'books' | 'consulting' | 'contact' | 'blog';
} }
const { const {
@ -18,10 +20,10 @@ const {
ogImage = '/social-card.png', ogImage = '/social-card.png',
ogType = 'website', ogType = 'website',
canonicalUrl, canonicalUrl,
pageType = 'home',
} = Astro.props; } = Astro.props;
const siteTitle = 'Scott Scoble'; const siteTitle = 'Scott Scoble';
const fullTitle = title.includes(siteTitle) ? title : `${title} — ${siteTitle}`;
const siteUrl = 'https://scott.scoble.me'; const siteUrl = 'https://scott.scoble.me';
const currentPath = canonicalUrl || Astro.url.pathname; const currentPath = canonicalUrl || Astro.url.pathname;
--- ---
@ -37,35 +39,18 @@ const currentPath = canonicalUrl || Astro.url.pathname;
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="generator" content={Astro.generator} /> <meta name="generator" content={Astro.generator} />
<!-- Primary Meta --> <SEO
<title>{fullTitle}</title> title={title}
<meta name="description" content={description} /> description={description}
<meta name="author" content="Scott Scoble" /> ogImage={ogImage}
ogType={ogType}
<!-- Open Graph --> canonicalUrl={canonicalUrl}
<meta property="og:title" content={fullTitle} /> pageType={pageType}
<meta property="og:description" content={description} /> />
<meta property="og:type" content={ogType} />
<meta property="og:url" content={new URL(currentPath, siteUrl).href} />
<meta property="og:image" content={new URL(ogImage, siteUrl).href} />
<meta property="og:site_name" content="Scott Scoble" />
<meta property="og:locale" content="en_US" />
<!-- Twitter Card -->
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content={fullTitle} />
<meta name="twitter:description" content={description} />
<meta name="twitter:image" content={new URL(ogImage, siteUrl).href} />
<!-- Canonical -->
<link rel="canonical" href={new URL(currentPath, siteUrl).href} />
<!-- RSS --> <!-- RSS -->
<link rel="alternate" type="application/rss+xml" title="Scott Scoble Blog" href="/rss.xml" /> <link rel="alternate" type="application/rss+xml" title="Scott Scoble Blog" href="/rss.xml" />
<!-- Umami Analytics -->
<script defer src="https://os.scoble.me/umami/script.js" data-website-id="4a23c88a-5397-4bac-a830-074c6aca7108"></script>
<!-- Google Fonts --> <!-- Google Fonts -->
<link rel="preconnect" href="https://fonts.googleapis.com" /> <link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />

View file

@ -5,6 +5,7 @@ import BaseLayout from '../layouts/BaseLayout.astro';
<BaseLayout <BaseLayout
title="Page Not Found — Scott Scoble" title="Page Not Found — Scott Scoble"
description="That page doesn't exist." description="That page doesn't exist."
pageType="home"
> >
<section class="error-page"> <section class="error-page">
@ -12,7 +13,20 @@ import BaseLayout from '../layouts/BaseLayout.astro';
<span class="error-code">404</span> <span class="error-code">404</span>
<h1>Page not found</h1> <h1>Page not found</h1>
<p>That link doesn't lead anywhere. Maybe it moved, maybe it never existed.</p> <p>That link doesn't lead anywhere. Maybe it moved, maybe it never existed.</p>
<a href="/" class="btn">Go home</a>
<div class="suggested-links">
<h3>Explore the site</h3>
<ul>
<li><a href="/speaking">Book Scott for Your Event</a></li>
<li><a href="/consulting">AI Team Consulting</a></li>
<li><a href="/books">Books &amp; Publications</a></li>
<li><a href="/about">About Scott</a></li>
</ul>
</div>
<p>Can't find what you're looking for? <a href="/contact">Get in touch</a> — I'll get back to you.</p>
<a href="/" class="btn-home">← Go home</a>
</div> </div>
</section> </section>
@ -30,7 +44,8 @@ import BaseLayout from '../layouts/BaseLayout.astro';
} }
.error-content { .error-content {
max-width: 500px; max-width: 600px;
margin: 0 auto;
} }
.error-code { .error-code {
@ -57,18 +72,60 @@ import BaseLayout from '../layouts/BaseLayout.astro';
line-height: 1.6; line-height: 1.6;
} }
.btn { .suggested-links {
text-align: left;
background: white;
padding: 2rem;
border-radius: 8px;
border: 1px solid var(--color-border);
margin: 0 0 1.5rem;
}
.suggested-links h3 {
font-size: 0.875rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--color-text-muted);
margin: 0 0 1rem;
}
.suggested-links ul {
list-style: none;
padding: 0;
margin: 0;
}
.suggested-links li {
margin-bottom: 0.75rem;
}
.suggested-links a {
color: var(--color-green);
text-decoration: none;
font-weight: 500;
}
.suggested-links a:hover {
text-decoration: underline;
}
.error-content a {
color: var(--color-green);
}
.btn-home {
display: inline-block; display: inline-block;
background: var(--color-amber); background: var(--color-amber);
color: white; color: white;
padding: 0.875rem 2rem; padding: 0.75rem 1.5rem;
border-radius: 6px; border-radius: 6px;
text-decoration: none; text-decoration: none;
font-weight: 600; font-weight: 600;
transition: all 0.2s ease; transition: all 0.2s ease;
} }
.btn:hover { .btn-home:hover {
background: var(--color-amber-light); background: var(--color-amber-light);
transform: translateY(-1px); transform: translateY(-1px);
} }

View file

@ -1,10 +1,12 @@
--- ---
import BaseLayout from '../layouts/BaseLayout.astro'; import BaseLayout from '../layouts/BaseLayout.astro';
import SharedForm from '../components/Form/SharedForm.astro';
--- ---
<BaseLayout <BaseLayout
title="Books — Scott Scoble" title="Books — Scott Scoble"
description="Published works on cultivation, mentoring, and the human side of AI." description="Published works on cultivation, mentoring, and the human side of AI."
pageType="books"
> >
<section class="hero"> <section class="hero">
@ -58,7 +60,7 @@ import BaseLayout from '../layouts/BaseLayout.astro';
<p class="book-description">Formal verification for the AI age. How to audit what your AI actually did.</p> <p class="book-description">Formal verification for the AI age. How to audit what your AI actually did.</p>
<p class="book-quote">"Speckl and the dark provenance system. A new approach to AI accountability."</p> <p class="book-quote">"Speckl and the dark provenance system. A new approach to AI accountability."</p>
<a href="#" class="waitlist-btn">Join Waitlist</a> <SharedForm type="waitlist" />
</article> </article>
</div> </div>

View file

@ -6,6 +6,7 @@ import PackageCard from '../components/PackageCard.astro';
<BaseLayout <BaseLayout
title="Consulting — Scott Scoble" title="Consulting — Scott Scoble"
description="Consulting packages for engineering leaders: Soil Assessment, Cultivation Workshop, and Fractional CTO services." description="Consulting packages for engineering leaders: Soil Assessment, Cultivation Workshop, and Fractional CTO services."
pageType="consulting"
> >
<section class="hero"> <section class="hero">

View file

@ -1,10 +1,12 @@
--- ---
import BaseLayout from '../layouts/BaseLayout.astro'; import BaseLayout from '../layouts/BaseLayout.astro';
import SharedForm from '../components/Form/SharedForm.astro';
--- ---
<BaseLayout <BaseLayout
title="Contact — Scott Scoble" title="Contact — Scott Scoble"
description="Get in touch about speaking, consulting, or just to say hello." description="Get in touch about speaking, consulting, or just to say hello."
pageType="contact"
> >
<section class="hero"> <section class="hero">
@ -22,34 +24,7 @@ import BaseLayout from '../layouts/BaseLayout.astro';
<div class="contact-form-wrapper"> <div class="contact-form-wrapper">
<h2>Send a Message</h2> <h2>Send a Message</h2>
<form action="https://formspree.io/f/PLACEHOLDER" method="POST"> <SharedForm type="contact" />
<div class="form-group">
<label for="name">Name *</label>
<input type="text" id="name" name="name" required />
</div>
<div class="form-group">
<label for="email">Email *</label>
<input type="email" id="email" name="email" required />
</div>
<div class="form-group">
<label for="topic">Topic</label>
<select id="topic" name="topic">
<option value="">Select...</option>
<option value="speaking">Speaking Inquiry</option>
<option value="consulting">Consulting Inquiry</option>
<option value="general">General</option>
</select>
</div>
<div class="form-group">
<label for="message">Message *</label>
<textarea id="message" name="message" rows="5" required></textarea>
</div>
<button type="submit" class="submit-btn">Send Message</button>
</form>
</div> </div>
<!-- Alternative Contact --> <!-- Alternative Contact -->
@ -63,14 +38,14 @@ import BaseLayout from '../layouts/BaseLayout.astro';
<div class="contact-option"> <div class="contact-option">
<h3>📅 Discovery Call</h3> <h3>📅 Discovery Call</h3>
<p><a href="#">Schedule a 20-min call</a> (Calendly link coming soon)</p> <p><a href="#" title="Calendly link coming soon">Schedule a 20-min call</a></p>
</div> </div>
<div class="contact-option"> <div class="contact-option">
<h3>🐦 Social</h3> <h3>🐦 Social</h3>
<p> <p>
<a href="https://linkedin.com/in/scottscoble" target="_blank" rel="noopener">LinkedIn</a> • <a href="https://linkedin.com/in/scottscoble" target="_blank" rel="noopener">LinkedIn</a> •
<a href="https://twitter.com/scottscoble" target="_blank" rel="noopener">X/Twitter</a> • <a href="https://x.com/scottscoble" target="_blank" rel="noopener">X/Twitter</a> •
<a href="https://github.com/sscoble" target="_blank" rel="noopener">GitHub</a> <a href="https://github.com/sscoble" target="_blank" rel="noopener">GitHub</a>
</p> </p>
</div> </div>

View file

@ -6,6 +6,7 @@ import PillarCard from '../components/PillarCard.astro';
<BaseLayout <BaseLayout
title="Scott Scoble — Human Cultivation Expert for the AI Age" title="Scott Scoble — Human Cultivation Expert for the AI Age"
description="Keynote speaker and consultant helping leaders build AI systems that cultivate humans instead of strip-mining them. Author of The Soil Beneath the Code." description="Keynote speaker and consultant helping leaders build AI systems that cultivate humans instead of strip-mining them. Author of The Soil Beneath the Code."
pageType="home"
> >
<!-- Hero Section --> <!-- Hero Section -->
<section class="hero"> <section class="hero">

View file

@ -24,10 +24,11 @@ import BaseLayout from '../layouts/BaseLayout.astro';
<p>If you submit a contact form, your email and message are sent to me directly. I don't share this information with third parties.</p> <p>If you submit a contact form, your email and message are sent to me directly. I don't share this information with third parties.</p>
<h2>Analytics</h2> <h2>Analytics</h2>
<p>This site does not use Google Analytics or other tracking scripts.</p> <p>This site uses <a href="https://umami.is" target="_blank" rel="noopener">Umami Analytics</a>, a privacy-focused, first-party analytics tool. Umami does not use cookies, does not track you across sites, and does not collect personal data.</p>
<p>You can see the privacy policy at <a href="https://umami.is/privacy" target="_blank" rel="noopener">umami.is/privacy</a>.</p>
<h2>Cookies</h2> <h2>Cookies</h2>
<p>No cookies are set by this site.</p> <p>No cookies are set by this site. Umami Analytics uses localStorage (not cookies) for anonymous session tracking.</p>
<h2>Hosting</h2> <h2>Hosting</h2>
<p>This site is hosted on Storj. Their privacy policy applies to infrastructure-level logging.</p> <p>This site is hosted on Storj. Their privacy policy applies to infrastructure-level logging.</p>

44
src/pages/sitemap.xml.ts Normal file
View file

@ -0,0 +1,44 @@
---
import { getCollection } from 'astro:content';
import { getSortedPosts } from '../utils/posts';
const posts = await getCollection('blog');
const speakingPosts = await getCollection('speaking');
const pages = [
{ url: '/', lastmod: new Date().toISOString() },
{ url: '/speaking', lastmod: new Date().toISOString() },
{ url: '/consulting', lastmod: new Date().toISOString() },
{ url: '/books', lastmod: new Date().toISOString() },
{ url: '/contact', lastmod: new Date().toISOString() },
...posts.map(post => ({
url: `/blog/${post.slug}`,
lastmod: post.data.updated || post.data.date,
})),
...speakingPosts.map(talk => ({
url: `/speaking#talk-${talk.slug}`,
lastmod: new Date().toISOString(),
})),
];
// Render the sitemap XML
const xml = `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
${pages.map(page => `
<url>
<loc>${new URL(page.url, 'https://scott.scoble.me').href}</loc>
<lastmod>${typeof page.lastmod === 'string' ? page.lastmod : new Date(page.lastmod).toISOString().split('T')[0]}</lastmod>
<changefreq>weekly</changefreq>
<priority>${page.url === '/' ? '1.0' : '0.8'}</priority>
</url>
`).join('')}
</urlset>`;
export const GET = () => {
return new Response(xml, {
headers: {
'Content-Type': 'application/xml',
'Cache-Control': 'public, max-age=3600',
},
});
};

View file

@ -1,11 +1,13 @@
--- ---
import BaseLayout from '../layouts/BaseLayout.astro'; import BaseLayout from '../layouts/BaseLayout.astro';
import TalkCard from '../components/TalkCard.astro'; import TalkCard from '../components/TalkCard.astro';
import SharedForm from '../components/Form/SharedForm.astro';
--- ---
<BaseLayout <BaseLayout
title="Speaking — Scott Scoble" title="Speaking — Scott Scoble"
description="Keynote speaker on AI, leadership, and human cultivation. Book Scott for your next conference or corporate event." description="Keynote speaker on AI, leadership, and human cultivation. Book Scott for your next conference or corporate event."
pageType="speaking"
> >
<!-- Hero --> <!-- Hero -->
@ -108,40 +110,7 @@ import TalkCard from '../components/TalkCard.astro';
<div class="book-form"> <div class="book-form">
<h3>Inquiry Form</h3> <h3>Inquiry Form</h3>
<form action="https://formspree.io/f/PLACEHOLDER" method="POST"> <SharedForm type="speaking" />
<div class="form-group">
<label for="name">Name *</label>
<input type="text" id="name" name="name" required />
</div>
<div class="form-group">
<label for="email">Email *</label>
<input type="email" id="email" name="email" required />
</div>
<div class="form-group">
<label for="event-date">Event Date</label>
<input type="date" id="event-date" name="event-date" />
</div>
<div class="form-group">
<label for="event-type">Event Type</label>
<select id="event-type" name="event-type">
<option value="">Select...</option>
<option value="conference">Conference</option>
<option value="corporate">Corporate Event</option>
<option value="workshop">Workshop</option>
<option value="other">Other</option>
</select>
</div>
<div class="form-group">
<label for="message">Message *</label>
<textarea id="message" name="message" rows="4" required></textarea>
</div>
<button type="submit" class="submit-btn">Send Inquiry</button>
</form>
</div> </div>