feat: implement forms, SEO, sitemap, and other infrastructure improvements
Some checks failed
Build and Deploy to Storj / build-and-deploy (push) Failing after 0s
Some checks failed
Build and Deploy to Storj / build-and-deploy (push) Failing after 0s
This commit is contained in:
parent
e9af3a9c27
commit
e68db9c26c
14 changed files with 791 additions and 102 deletions
24
public/manifest.json
Normal file
24
public/manifest.json
Normal 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
19
public/robots.txt
Normal 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
|
||||
|
|
@ -9,11 +9,13 @@ const currentYear = new Date().getFullYear();
|
|||
<div class="footer-brand">
|
||||
<span class="footer-logo">Scott Scoble</span>
|
||||
<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">
|
||||
<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>
|
||||
</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>
|
||||
</a>
|
||||
<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 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-col">
|
||||
<h4>Pages</h4>
|
||||
|
|
@ -61,8 +73,8 @@ const currentYear = new Date().getFullYear();
|
|||
|
||||
.footer-main {
|
||||
display: grid;
|
||||
grid-template-columns: 2fr 1fr;
|
||||
gap: 4rem;
|
||||
grid-template-columns: 1.5fr 1.5fr 1fr;
|
||||
gap: 3rem;
|
||||
padding-bottom: 3rem;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
margin-bottom: 2rem;
|
||||
|
|
@ -83,6 +95,21 @@ const currentYear = new Date().getFullYear();
|
|||
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 {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
|
|
@ -97,9 +124,71 @@ const currentYear = new Date().getFullYear();
|
|||
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 {
|
||||
display: flex;
|
||||
gap: 3rem;
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
.footer-col {
|
||||
|
|
@ -136,6 +225,12 @@ const currentYear = new Date().getFullYear();
|
|||
color: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.footer-main {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.footer-main {
|
||||
grid-template-columns: 1fr;
|
||||
|
|
|
|||
293
src/components/Form/SharedForm.astro
Normal file
293
src/components/Form/SharedForm.astro
Normal 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
223
src/components/SEO.astro
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -3,6 +3,7 @@
|
|||
import '../styles/global.css';
|
||||
import Nav from '../components/Nav.astro';
|
||||
import Footer from '../components/Footer.astro';
|
||||
import SEO from '../components/SEO.astro';
|
||||
|
||||
export interface Props {
|
||||
title: string;
|
||||
|
|
@ -10,6 +11,7 @@ export interface Props {
|
|||
ogImage?: string;
|
||||
ogType?: string;
|
||||
canonicalUrl?: string;
|
||||
pageType?: 'home' | 'speaking' | 'books' | 'consulting' | 'contact' | 'blog';
|
||||
}
|
||||
|
||||
const {
|
||||
|
|
@ -18,10 +20,10 @@ const {
|
|||
ogImage = '/social-card.png',
|
||||
ogType = 'website',
|
||||
canonicalUrl,
|
||||
pageType = 'home',
|
||||
} = Astro.props;
|
||||
|
||||
const siteTitle = 'Scott Scoble';
|
||||
const fullTitle = title.includes(siteTitle) ? title : `${title} — ${siteTitle}`;
|
||||
const siteUrl = 'https://scott.scoble.me';
|
||||
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="generator" content={Astro.generator} />
|
||||
|
||||
<!-- Primary Meta -->
|
||||
<title>{fullTitle}</title>
|
||||
<meta name="description" content={description} />
|
||||
<meta name="author" content="Scott Scoble" />
|
||||
|
||||
<!-- Open Graph -->
|
||||
<meta property="og:title" content={fullTitle} />
|
||||
<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} />
|
||||
<SEO
|
||||
title={title}
|
||||
description={description}
|
||||
ogImage={ogImage}
|
||||
ogType={ogType}
|
||||
canonicalUrl={canonicalUrl}
|
||||
pageType={pageType}
|
||||
/>
|
||||
|
||||
<!-- RSS -->
|
||||
<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 -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import BaseLayout from '../layouts/BaseLayout.astro';
|
|||
<BaseLayout
|
||||
title="Page Not Found — Scott Scoble"
|
||||
description="That page doesn't exist."
|
||||
pageType="home"
|
||||
>
|
||||
|
||||
<section class="error-page">
|
||||
|
|
@ -12,7 +13,20 @@ import BaseLayout from '../layouts/BaseLayout.astro';
|
|||
<span class="error-code">404</span>
|
||||
<h1>Page not found</h1>
|
||||
<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 & 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>
|
||||
</section>
|
||||
|
||||
|
|
@ -30,7 +44,8 @@ import BaseLayout from '../layouts/BaseLayout.astro';
|
|||
}
|
||||
|
||||
.error-content {
|
||||
max-width: 500px;
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.error-code {
|
||||
|
|
@ -57,18 +72,60 @@ import BaseLayout from '../layouts/BaseLayout.astro';
|
|||
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;
|
||||
background: var(--color-amber);
|
||||
color: white;
|
||||
padding: 0.875rem 2rem;
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-radius: 6px;
|
||||
text-decoration: none;
|
||||
font-weight: 600;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
.btn-home:hover {
|
||||
background: var(--color-amber-light);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,12 @@
|
|||
---
|
||||
import BaseLayout from '../layouts/BaseLayout.astro';
|
||||
import SharedForm from '../components/Form/SharedForm.astro';
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title="Books — Scott Scoble"
|
||||
description="Published works on cultivation, mentoring, and the human side of AI."
|
||||
pageType="books"
|
||||
>
|
||||
|
||||
<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-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>
|
||||
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import PackageCard from '../components/PackageCard.astro';
|
|||
<BaseLayout
|
||||
title="Consulting — Scott Scoble"
|
||||
description="Consulting packages for engineering leaders: Soil Assessment, Cultivation Workshop, and Fractional CTO services."
|
||||
pageType="consulting"
|
||||
>
|
||||
|
||||
<section class="hero">
|
||||
|
|
|
|||
|
|
@ -1,10 +1,12 @@
|
|||
---
|
||||
import BaseLayout from '../layouts/BaseLayout.astro';
|
||||
import SharedForm from '../components/Form/SharedForm.astro';
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title="Contact — Scott Scoble"
|
||||
description="Get in touch about speaking, consulting, or just to say hello."
|
||||
pageType="contact"
|
||||
>
|
||||
|
||||
<section class="hero">
|
||||
|
|
@ -22,34 +24,7 @@ import BaseLayout from '../layouts/BaseLayout.astro';
|
|||
<div class="contact-form-wrapper">
|
||||
<h2>Send a Message</h2>
|
||||
|
||||
<form action="https://formspree.io/f/PLACEHOLDER" method="POST">
|
||||
<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>
|
||||
<SharedForm type="contact" />
|
||||
</div>
|
||||
|
||||
<!-- Alternative Contact -->
|
||||
|
|
@ -63,14 +38,14 @@ import BaseLayout from '../layouts/BaseLayout.astro';
|
|||
|
||||
<div class="contact-option">
|
||||
<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 class="contact-option">
|
||||
<h3>🐦 Social</h3>
|
||||
<p>
|
||||
<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>
|
||||
</p>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import PillarCard from '../components/PillarCard.astro';
|
|||
<BaseLayout
|
||||
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."
|
||||
pageType="home"
|
||||
>
|
||||
<!-- Hero Section -->
|
||||
<section class="hero">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
<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>
|
||||
<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>
|
||||
<p>This site is hosted on Storj. Their privacy policy applies to infrastructure-level logging.</p>
|
||||
|
|
|
|||
44
src/pages/sitemap.xml.ts
Normal file
44
src/pages/sitemap.xml.ts
Normal 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',
|
||||
},
|
||||
});
|
||||
};
|
||||
|
|
@ -1,11 +1,13 @@
|
|||
---
|
||||
import BaseLayout from '../layouts/BaseLayout.astro';
|
||||
import TalkCard from '../components/TalkCard.astro';
|
||||
import SharedForm from '../components/Form/SharedForm.astro';
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title="Speaking — Scott Scoble"
|
||||
description="Keynote speaker on AI, leadership, and human cultivation. Book Scott for your next conference or corporate event."
|
||||
pageType="speaking"
|
||||
>
|
||||
|
||||
<!-- Hero -->
|
||||
|
|
@ -108,40 +110,7 @@ import TalkCard from '../components/TalkCard.astro';
|
|||
<div class="book-form">
|
||||
<h3>Inquiry Form</h3>
|
||||
|
||||
<form action="https://formspree.io/f/PLACEHOLDER" method="POST">
|
||||
<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>
|
||||
<SharedForm type="speaking" />
|
||||
</div>
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue