From b3339d9acd257d3c69e3815c7336549ef0d2f86e Mon Sep 17 00:00:00 2001 From: Jordan Winters Date: Tue, 21 Jul 2026 17:19:23 -0500 Subject: [PATCH 1/8] feat(seo): move career coaching to /services/career-coaching with redirect stub Nests the fourth service under /services/* and takes the searched slug ('career coaching'; 'guidance' is a zero-volume phrase). Safe to move now: months of 404 plus the homepage-canonical bug left the old URL with minimal equity. Retitled for 'tech career coach minneapolis', local hero sentence + Twin Cities FAQ, discovery-call CTAs to /contact, Service schema + breadcrumb, first-person voice in metadata. New reusable static-redirect pattern (GitHub Pages cannot 301): lib/redirects.ts registry + RedirectPage stub emitting an instant meta refresh with canonical pointing at the destination. The old URL keeps a stub that is never sitemapped or internally linked; the metadata-policy test now computes each page's route, requires canonical === route for real pages, and requires stubs to canonicalize to their registered destination. e2e verifies the browser lands on the new page. Co-Authored-By: Claude Fable 5 --- .../career-guidance/opengraph-image.tsx | 10 - app/(main)/career-guidance/page.test.tsx | 129 +------- app/(main)/career-guidance/page.tsx | 249 +-------------- app/(main)/page.tsx | 2 +- .../career-coaching/opengraph-image.tsx | 10 + .../services/career-coaching/page.test.tsx | 145 +++++++++ app/(main)/services/career-coaching/page.tsx | 288 ++++++++++++++++++ app/components/redirect-page.tsx | 27 ++ app/config/nav.ts | 2 +- app/metadata-policy.test.ts | 35 ++- app/sitemap.test.ts | 5 +- app/sitemap.ts | 2 +- e2e/seo.spec.ts | 13 +- lib/redirects.ts | 30 ++ .../next-image-export-optimizer-hashes.json | 6 +- 15 files changed, 570 insertions(+), 383 deletions(-) delete mode 100644 app/(main)/career-guidance/opengraph-image.tsx create mode 100644 app/(main)/services/career-coaching/opengraph-image.tsx create mode 100644 app/(main)/services/career-coaching/page.test.tsx create mode 100644 app/(main)/services/career-coaching/page.tsx create mode 100644 app/components/redirect-page.tsx create mode 100644 lib/redirects.ts diff --git a/app/(main)/career-guidance/opengraph-image.tsx b/app/(main)/career-guidance/opengraph-image.tsx deleted file mode 100644 index e35091a..0000000 --- a/app/(main)/career-guidance/opengraph-image.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import { OG_SIZE, ogCard } from '../../../lib/og-image' - -export const dynamic = 'force-static' -export const alt = 'Career Guidance' -export const size = OG_SIZE -export const contentType = 'image/png' - -export default function OpengraphImage() { - return ogCard('Career Guidance', 'Bidwell Consulting') -} diff --git a/app/(main)/career-guidance/page.test.tsx b/app/(main)/career-guidance/page.test.tsx index 315718f..71041df 100644 --- a/app/(main)/career-guidance/page.test.tsx +++ b/app/(main)/career-guidance/page.test.tsx @@ -1,124 +1,27 @@ -import { axeTest, render, screen } from 'lib/test-utils' +import { render, screen } from 'lib/test-utils' import { describe, expect, it } from 'vitest' -import CareerGuidancePage from './page' +import CareerGuidanceRedirect from './page' -const LINKEDIN_URL = 'https://www.linkedin.com/in/wintersjordan/' +describe('career-guidance redirect stub', () => { + it('meta-refreshes instantly to the new URL', () => { + render() -describe('Career Guidance Page', () => { - it('renders the hero section with main heading', () => { - render() - - expect(screen.getByRole('heading', { level: 1, name: /career guidance/i })).toBeInTheDocument() - expect(screen.getByText(/career coaching/i)).toBeInTheDocument() - }) - - it('displays the hero call-to-action', () => { - render() - - const heroCta = screen.getByRole('link', { name: /get in touch on linkedin/i }) - expect(heroCta).toHaveAttribute('href', LINKEDIN_URL) - }) - - it('displays all service offerings', () => { - render() - - expect(screen.getByRole('heading', { name: /services offered/i })).toBeInTheDocument() - expect(screen.getByRole('heading', { name: /1-on-1 coaching/i })).toBeInTheDocument() - expect(screen.getByRole('heading', { name: /resume & linkedin review/i })).toBeInTheDocument() - expect(screen.getByRole('heading', { name: /interview prep/i })).toBeInTheDocument() - expect(screen.getByRole('heading', { name: /career transitions/i })).toBeInTheDocument() - }) - - it('displays service benefits', () => { - render() - - expect(screen.getByText(/personalized advice/i)).toBeInTheDocument() - expect(screen.getByText(/ats optimization/i)).toBeInTheDocument() - expect(screen.getByText(/mock interviews/i)).toBeInTheDocument() - expect(screen.getByText(/transition planning/i)).toBeInTheDocument() - }) - - it('displays engagement options with durations', () => { - render() - - expect(screen.getByRole('heading', { name: /ways to work together/i })).toBeInTheDocument() - expect(screen.getByRole('heading', { name: /discovery call/i })).toBeInTheDocument() - expect(screen.getByRole('heading', { name: /^single session$/i })).toBeInTheDocument() - expect(screen.getByRole('heading', { name: /ongoing mentorship/i })).toBeInTheDocument() - expect(screen.getByText('30 minutes')).toBeInTheDocument() - expect(screen.getByText('60 minutes')).toBeInTheDocument() - expect(screen.getByText('3 months')).toBeInTheDocument() - }) - - it('renders a reach-out link for every engagement option', () => { - render() - - const reachOutLinks = screen.getAllByRole('link', { name: /reach out/i }) - expect(reachOutLinks).toHaveLength(3) - for (const link of reachOutLinks) { - expect(link).toHaveAttribute('href', LINKEDIN_URL) - } + const refresh = document.head.querySelector('meta[http-equiv="refresh"]') + expect(refresh?.getAttribute('content')).toBe('0;url=/services/career-coaching') }) - it('includes FAQ section with all questions', () => { - render() - - expect(screen.getByRole('heading', { name: /frequently asked questions/i })).toBeInTheDocument() - expect(screen.getByText(/how long are sessions\?/i)).toBeInTheDocument() - expect(screen.getByText(/not sure what you need\?/i)).toBeInTheDocument() - expect(screen.getByText(/packages available\?/i)).toBeInTheDocument() - expect(screen.getByText(/industry focus\?/i)).toBeInTheDocument() - expect(screen.getByText(/remote work\?/i)).toBeInTheDocument() - }) - - it('renders contact section', () => { - render() - - expect(screen.getByRole('heading', { name: /get in touch/i })).toBeInTheDocument() - expect(screen.getByRole('link', { name: /connect on linkedin/i })).toBeInTheDocument() - }) - - it('opens all external links safely in a new tab', () => { - render() - - const links = screen.getAllByRole('link') - expect(links.length).toBeGreaterThan(0) - for (const link of links) { - expect(link).toHaveAttribute('href', LINKEDIN_URL) - expect(link).toHaveAttribute('target', '_blank') - expect(link).toHaveAttribute('rel', 'noopener noreferrer') - } - }) - - it('has proper heading hierarchy', () => { - render() - - // Should have exactly one h1 - const h1Elements = screen.getAllByRole('heading', { level: 1 }) - expect(h1Elements).toHaveLength(1) - - // One h2 per main section: services, engagement, FAQ, contact - const h2Elements = screen.getAllByRole('heading', { level: 2 }) - expect(h2Elements).toHaveLength(4) - }) - - it('exposes anchor targets for deep links', () => { - const { container } = render() - - expect(container.querySelector('#services')).toBeInTheDocument() - expect(container.querySelector('#contact')).toBeInTheDocument() - }) + it('offers a visible link to the destination', () => { + render() - it('is accessible', async () => { - const { container } = render() - await axeTest(container) + expect(screen.getByRole('heading', { level: 1, name: /moved/i })).toBeInTheDocument() + expect(screen.getByRole('link', { name: /the new page/i })).toHaveAttribute( + 'href', + '/services/career-coaching' + ) }) - it('has proper metadata', async () => { + it('canonicalizes to the destination so equity consolidates there', async () => { const { metadata } = await import('./page') - expect(metadata.title).toBe('Career Guidance') - expect(metadata.description).toContain('career coaching') - expect(metadata.keywords).toContain('career coach') - expect(metadata.keywords).toContain('career guidance') + expect(metadata.alternates?.canonical).toBe('/services/career-coaching') }) }) diff --git a/app/(main)/career-guidance/page.tsx b/app/(main)/career-guidance/page.tsx index 246f79f..65fdcbc 100644 --- a/app/(main)/career-guidance/page.tsx +++ b/app/(main)/career-guidance/page.tsx @@ -1,247 +1,8 @@ -import { faqSchema } from 'lib/structured-data' -import type { Metadata } from 'next' -import type React from 'react' -import JsonLd from '../../components/structured-data' +import { redirectMetadata } from 'lib/redirects' +import RedirectPage from '../../components/redirect-page' -export const metadata: Metadata = { - title: 'Career Guidance', - description: - 'We provide career coaching and guidance for tech professionals. Help with career transitions, resume reviews, interview prep, and leadership development.', - keywords: [ - 'career coach', - 'career guidance', - 'career mentoring', - 'professional development', - 'career transition', - 'resume optimization', - 'interview preparation', - 'leadership development', - ], - alternates: { canonical: '/career-guidance' }, - openGraph: { - title: 'Career Guidance | Bidwell Consulting', - description: 'We provide career coaching and guidance for tech professionals.', - type: 'website', - }, -} - -const services = [ - { - title: '1-on-1 Coaching', - description: 'Dedicated time to work through your career questions and challenges.', - benefits: ['Personalized advice', 'Goal setting', 'Accountability check-ins'], - }, - { - title: 'Resume & LinkedIn Review', - description: 'Honest feedback on your professional materials.', - benefits: ['ATS optimization', 'Profile improvements', 'Positioning strategy'], - }, - { - title: 'Interview Prep', - description: 'Practice interviews with real feedback.', - benefits: ['Mock interviews', 'Behavioral coaching', 'Technical prep'], - }, - { - title: 'Career Transitions', - description: 'Guidance for changing roles or industries.', - benefits: ['Skills assessment', 'Transition planning', 'Network strategy'], - }, -] - -const faqs = [ - { - question: 'How long are sessions?', - answer: '60 minutes. Long enough to dig in, short enough to respect your time.', - }, - { - question: 'Not sure what you need?', - answer: "Start with a free 30-minute call. We'll figure it out together.", - }, - { - question: 'Packages available?', - answer: 'Yes. Single sessions or ongoing engagements. Reach out to discuss.', - }, - { - question: 'Industry focus?', - answer: 'I specialize in tech and software, but the principles apply broadly.', - }, - { - question: 'Remote work?', - answer: 'Definitely. I can help with remote job searches and distributed team dynamics.', - }, -] - -const engagementOptions = [ - { - name: 'Discovery Call', - duration: '30 minutes', - description: "Let's talk about what you're working on. No commitment, just a conversation.", - features: ['Initial conversation', 'Goal discussion', "See if we're a good fit"], - }, - { - name: 'Single Session', - duration: '60 minutes', - description: 'Focused time on a specific challenge or question.', - features: ['1-on-1 time', 'Targeted guidance', 'Action items'], - }, - { - name: 'Ongoing Mentorship', - duration: '3 months', - description: 'Regular check-ins for those who want sustained support.', - features: ['Weekly sessions', 'Resume review', 'Interview prep', 'Email support'], - }, -] - -export default function CareerGuidancePage(): React.JSX.Element { - return ( -
- - {/* Hero Section */} -
-

Career Guidance

-

- With nearly 20 years of experience in professional development, I offer career coaching - for those navigating transitions, preparing for interviews, or figuring out their next - move. -

- -
- - {/* Services Section */} -
-

Services Offered

-
- {services.map(service => ( -
-

{service.title}

-

{service.description}

-
    - {service.benefits.map(benefit => ( -
  • - - Checkmark - - - {benefit} -
  • - ))} -
-
- ))} -
-
- - {/* Engagement Options */} -
-

Ways to Work Together

-
- {engagementOptions.map(option => ( -
-

{option.name}

-
- {option.duration} -
-

{option.description}

-
    - {option.features.map(feature => ( -
  • - - Checkmark - - - {feature} -
  • - ))} -
- - Reach Out - -
- ))} -
-
- - {/* FAQ Section */} -
-

Frequently Asked Questions

-
- {faqs.map(faq => ( -
-

{faq.question}

-

{faq.answer}

-
- ))} -
-
+export const metadata = redirectMetadata('/career-guidance') - {/* Contact Section */} -
-

Get in Touch

-

- The best way to reach me is through LinkedIn. Send a message and we can set up a time to - talk. -

- - Connect on LinkedIn - -
-
- ) +export default function CareerGuidanceRedirect() { + return } diff --git a/app/(main)/page.tsx b/app/(main)/page.tsx index eaf6e79..db5ae85 100644 --- a/app/(main)/page.tsx +++ b/app/(main)/page.tsx @@ -42,7 +42,7 @@ export default function Page(): React.JSX.Element {
  • Full-stack problem solving and solution design
  • Career coaching and development diff --git a/app/(main)/services/career-coaching/opengraph-image.tsx b/app/(main)/services/career-coaching/opengraph-image.tsx new file mode 100644 index 0000000..94275e9 --- /dev/null +++ b/app/(main)/services/career-coaching/opengraph-image.tsx @@ -0,0 +1,10 @@ +import { OG_SIZE, ogCard } from '../../../../lib/og-image' + +export const dynamic = 'force-static' +export const alt = 'Tech Career Coaching' +export const size = OG_SIZE +export const contentType = 'image/png' + +export default function OpengraphImage() { + return ogCard('Tech Career Coaching', 'Bidwell Consulting · Minneapolis') +} diff --git a/app/(main)/services/career-coaching/page.test.tsx b/app/(main)/services/career-coaching/page.test.tsx new file mode 100644 index 0000000..f693195 --- /dev/null +++ b/app/(main)/services/career-coaching/page.test.tsx @@ -0,0 +1,145 @@ +import { axeTest, render, screen } from 'lib/test-utils' +import { describe, expect, it } from 'vitest' +import CareerCoachingPage from './page' + +const LINKEDIN_URL = 'https://www.linkedin.com/in/wintersjordan/' + +describe('Career Coaching Page', () => { + it('renders the hero section with main heading', () => { + render() + + expect( + screen.getByRole('heading', { level: 1, name: /tech career coaching/i }) + ).toBeInTheDocument() + expect(screen.getByText(/coach engineers and tech professionals/i)).toBeInTheDocument() + }) + + it('states the Minneapolis base without making location a blocker', () => { + render() + + expect(screen.getAllByText(/based in minneapolis/i).length).toBeGreaterThanOrEqual(1) + expect(screen.getByText(/location never gets in the way/i)).toBeInTheDocument() + }) + + it('displays the hero call-to-action with a discovery-call alternative', () => { + render() + + const heroCta = screen.getByRole('link', { name: /get in touch on linkedin/i }) + expect(heroCta).toHaveAttribute('href', LINKEDIN_URL) + + const discoveryLinks = screen.getAllByRole('link', { name: /book a free discovery call/i }) + expect(discoveryLinks.length).toBeGreaterThanOrEqual(1) + for (const link of discoveryLinks) { + expect(link).toHaveAttribute('href', '/contact') + } + }) + + it('displays all service offerings', () => { + render() + + expect(screen.getByRole('heading', { name: /services offered/i })).toBeInTheDocument() + expect(screen.getByRole('heading', { name: /1-on-1 coaching/i })).toBeInTheDocument() + expect(screen.getByRole('heading', { name: /resume & linkedin review/i })).toBeInTheDocument() + expect(screen.getByRole('heading', { name: /interview prep/i })).toBeInTheDocument() + expect(screen.getByRole('heading', { name: /career transitions/i })).toBeInTheDocument() + }) + + it('displays engagement options with durations', () => { + render() + + expect(screen.getByRole('heading', { name: /ways to work together/i })).toBeInTheDocument() + expect(screen.getByRole('heading', { name: /discovery call/i })).toBeInTheDocument() + expect(screen.getByRole('heading', { name: /^single session$/i })).toBeInTheDocument() + expect(screen.getByRole('heading', { name: /ongoing mentorship/i })).toBeInTheDocument() + expect(screen.getByText('30 minutes')).toBeInTheDocument() + expect(screen.getByText('60 minutes')).toBeInTheDocument() + expect(screen.getByText('3 months')).toBeInTheDocument() + }) + + it('includes FAQ section with the Twin Cities question', () => { + render() + + expect(screen.getByRole('heading', { name: /frequently asked questions/i })).toBeInTheDocument() + expect(screen.getByText(/how long are sessions\?/i)).toBeInTheDocument() + expect( + screen.getByText(/do you offer career coaching in the twin cities\?/i) + ).toBeInTheDocument() + }) + + it('renders contact section with LinkedIn and an about link', () => { + render() + + expect(screen.getByRole('heading', { name: /get in touch/i })).toBeInTheDocument() + expect(screen.getByRole('link', { name: /connect on linkedin/i })).toBeInTheDocument() + expect(screen.getByRole('link', { name: /more about my background/i })).toHaveAttribute( + 'href', + '/about' + ) + }) + + it('opens external links safely in a new tab', () => { + render() + + const externalLinks = screen + .getAllByRole('link') + .filter(link => (link.getAttribute('href') ?? '').startsWith('http')) + expect(externalLinks.length).toBeGreaterThan(0) + for (const link of externalLinks) { + expect(link).toHaveAttribute('href', LINKEDIN_URL) + expect(link).toHaveAttribute('target', '_blank') + expect(link).toHaveAttribute('rel', 'noopener noreferrer') + } + }) + + it('renders breadcrumb navigation to the services hub', () => { + render() + + const breadcrumb = screen.getByRole('navigation', { name: /breadcrumb/i }) + expect(breadcrumb).toBeInTheDocument() + expect(screen.getByRole('link', { name: 'Services' })).toHaveAttribute('href', '/services') + }) + + it('emits Service and FAQPage structured data', () => { + const { container } = render() + + const schemas = Array.from( + container.querySelectorAll('script[type="application/ld+json"]') + ).map(script => JSON.parse(script.textContent || '{}')) + const types = schemas.map(schema => schema['@type']) + expect(types).toContain('Service') + expect(types).toContain('FAQPage') + expect(types).toContain('BreadcrumbList') + }) + + it('has proper heading hierarchy', () => { + render() + + const h1Elements = screen.getAllByRole('heading', { level: 1 }) + expect(h1Elements).toHaveLength(1) + + const h2Elements = screen.getAllByRole('heading', { level: 2 }) + expect(h2Elements).toHaveLength(4) + }) + + it('exposes anchor targets for deep links', () => { + const { container } = render() + + expect(container.querySelector('#services')).toBeInTheDocument() + expect(container.querySelector('#contact')).toBeInTheDocument() + }) + + it('is accessible', async () => { + const { container } = render() + await axeTest(container) + }) + + it('has locally-targeted metadata in the solo voice', async () => { + const { metadata } = await import('./page') + expect(metadata.title).toBe('Tech Career Coaching in Minneapolis') + expect(metadata.description).toContain('Career coaching for software engineers') + expect(metadata.description).toContain('Minneapolis') + expect(metadata.description).not.toMatch(/\bwe\b/i) + expect(metadata.alternates?.canonical).toBe('/services/career-coaching') + expect(metadata.keywords).toContain('tech career coach') + }) +}) diff --git a/app/(main)/services/career-coaching/page.tsx b/app/(main)/services/career-coaching/page.tsx new file mode 100644 index 0000000..4f1c4ba --- /dev/null +++ b/app/(main)/services/career-coaching/page.tsx @@ -0,0 +1,288 @@ +import { faqSchema, serviceSchema } from 'lib/structured-data' +import type { Metadata } from 'next' +import Link from 'next/link' +import type React from 'react' +import Breadcrumb from '../../../components/breadcrumb' +import JsonLd from '../../../components/structured-data' + +export const metadata: Metadata = { + title: 'Tech Career Coaching in Minneapolis', + description: + 'Career coaching for software engineers and tech professionals — resumes, interviews, transitions. Minneapolis-based, remote-friendly. Free 30-minute call.', + keywords: [ + 'tech career coach', + 'career coaching for software engineers', + 'tech resume review', + 'interview prep coaching', + 'career transition', + 'engineering leadership coach', + ], + alternates: { canonical: '/services/career-coaching' }, + openGraph: { + title: 'Tech Career Coaching — Minneapolis', + description: + 'Career coaching for software engineers and tech professionals, from a Minneapolis-based consultant.', + type: 'website', + }, +} + +const services = [ + { + title: '1-on-1 Coaching', + description: 'Dedicated time to work through your career questions and challenges.', + benefits: ['Personalized advice', 'Goal setting', 'Accountability check-ins'], + }, + { + title: 'Resume & LinkedIn Review', + description: 'Honest feedback on your professional materials.', + benefits: ['ATS optimization', 'Profile improvements', 'Positioning strategy'], + }, + { + title: 'Interview Prep', + description: 'Practice interviews with real feedback.', + benefits: ['Mock interviews', 'Behavioral coaching', 'Technical prep'], + }, + { + title: 'Career Transitions', + description: 'Guidance for changing roles or industries.', + benefits: ['Skills assessment', 'Transition planning', 'Network strategy'], + }, +] + +const faqs = [ + { + question: 'How long are sessions?', + answer: '60 minutes. Long enough to dig in, short enough to respect your time.', + }, + { + question: 'Not sure what you need?', + answer: "Start with a free 30-minute call. We'll figure it out together.", + }, + { + question: 'Packages available?', + answer: 'Yes. Single sessions or ongoing engagements. Reach out to discuss.', + }, + { + question: 'Industry focus?', + answer: 'I specialize in tech and software, but the principles apply broadly.', + }, + { + question: 'Remote work?', + answer: 'Definitely. I can help with remote job searches and distributed team dynamics.', + }, + { + question: 'Do you offer career coaching in the Twin Cities?', + answer: + "Yes — I'm based in Minneapolis. Clients in the metro can meet in person when it helps; for everyone else, video works great.", + }, +] + +const engagementOptions = [ + { + name: 'Discovery Call', + duration: '30 minutes', + description: "Let's talk about what you're working on. No commitment, just a conversation.", + features: ['Initial conversation', 'Goal discussion', "See if we're a good fit"], + }, + { + name: 'Single Session', + duration: '60 minutes', + description: 'Focused time on a specific challenge or question.', + features: ['1-on-1 time', 'Targeted guidance', 'Action items'], + }, + { + name: 'Ongoing Mentorship', + duration: '3 months', + description: 'Regular check-ins for those who want sustained support.', + features: ['Weekly sessions', 'Resume review', 'Interview prep', 'Email support'], + }, +] + +export default function CareerCoachingPage(): React.JSX.Element { + return ( +
    + + + + {/* Hero Section */} + + + {/* Services Section */} +
    +

    Services Offered

    +
    + {services.map(service => ( +
    +

    {service.title}

    +

    {service.description}

    +
      + {service.benefits.map(benefit => ( +
    • + + Checkmark + + + {benefit} +
    • + ))} +
    +
    + ))} +
    +
    + + {/* Engagement Options */} +
    +

    Ways to Work Together

    +
    + {engagementOptions.map(option => ( +
    +

    {option.name}

    +
    + {option.duration} +
    +

    {option.description}

    +
      + {option.features.map(feature => ( +
    • + + Checkmark + + + {feature} +
    • + ))} +
    + + Reach Out + +
    + ))} +
    +
    + + {/* FAQ Section */} +
    +

    Frequently Asked Questions

    +
    + {faqs.map(faq => ( +
    +

    {faq.question}

    +

    {faq.answer}

    +
    + ))} +
    +
    + + {/* Contact Section */} +
    +

    Get in Touch

    +

    + The best way to reach me is through LinkedIn — or{' '} + + book a free discovery call + {' '} + and we can set up a time to talk. +

    + + Connect on LinkedIn + +

    + Curious who you'd be working with?{' '} + + More about my background + + . +

    +
    +
    + ) +} diff --git a/app/components/redirect-page.tsx b/app/components/redirect-page.tsx new file mode 100644 index 0000000..bdf15d9 --- /dev/null +++ b/app/components/redirect-page.tsx @@ -0,0 +1,27 @@ +import Link from 'next/link' +import type React from 'react' + +import { resolveRedirect } from '../../lib/redirects' + +/** + * Stub body for a moved URL. React hoists the meta refresh into ; + * the visible link covers crawlers and anyone with auto-refresh disabled. + */ +export default function RedirectPage({ from }: { from: string }): React.JSX.Element { + const to = resolveRedirect(from) + return ( + <> + +
    +

    This page has moved

    +

    + If you are not redirected automatically, continue to{' '} + + the new page + + . +

    +
    + + ) +} diff --git a/app/config/nav.ts b/app/config/nav.ts index 2f5efa8..28bcc3b 100644 --- a/app/config/nav.ts +++ b/app/config/nav.ts @@ -15,7 +15,7 @@ export const navItems: Record = { name: 'experiments', }, - '/career-guidance': { + '/services/career-coaching': { name: 'coaching', }, } diff --git a/app/metadata-policy.test.ts b/app/metadata-policy.test.ts index a8d6937..48e3583 100644 --- a/app/metadata-policy.test.ts +++ b/app/metadata-policy.test.ts @@ -1,4 +1,5 @@ /// +import { redirects } from 'lib/redirects' import type { Metadata } from 'next' /** @@ -9,6 +10,9 @@ import type { Metadata } from 'next' * which suppressed indexing of all sub-pages. * 2. Titles, descriptions, and canonicals are unique — 12 experiment stubs * once shared one inherited title/description. + * + * Redirect stubs (lib/redirects.ts) are the one sanctioned exception: their + * canonical deliberately points at the destination page. */ const pageModules = import.meta.glob('./**/page.tsx', { eager: true }) as Record< string, @@ -18,11 +22,24 @@ const pageModules = import.meta.glob('./**/page.tsx', { eager: true }) as Record // Modules only — importing them would execute ImageResponse; paths suffice. const ogImageFiles = Object.keys(import.meta.glob('./**/opengraph-image.tsx')) -const pages = Object.entries(pageModules).map(([path, module]) => ({ +// './(main)/services/career-coaching/page.tsx' -> '/services/career-coaching' +function routeOf(path: string): string { + const route = path + .replace(/\([^)]+\)\//g, '') + .replace(/^\.\//, '/') + .replace(/\/page\.tsx$/, '') + return route === '' ? '/' : route +} + +const allPages = Object.entries(pageModules).map(([path, module]) => ({ path, + route: routeOf(path), metadata: module.metadata, })) +const redirectStubs = allPages.filter(page => page.route in redirects) +const pages = allPages.filter(page => !(page.route in redirects)) + describe('metadata policy', () => { it('discovers the full page inventory (route groups included)', () => { expect(pages.length).toBeGreaterThanOrEqual(18) @@ -31,18 +48,22 @@ describe('metadata policy', () => { }) it.each( - pages.map(page => [page.path, page.metadata] as const) - )('%s exports complete metadata', (_path, metadata) => { + pages.map(page => [page.path, page.route, page.metadata] as const) + )('%s exports complete metadata', (_path, route, metadata) => { expect(metadata).toBeDefined() expect(typeof metadata?.title).toBe('string') expect((metadata?.title as string).length).toBeGreaterThan(0) expect(typeof metadata?.description).toBe('string') expect((metadata?.description as string).length).toBeGreaterThan(0) - const canonical = metadata?.alternates?.canonical - expect(typeof canonical).toBe('string') - expect(canonical as string).toMatch(/^\//) - expect(canonical as string).not.toMatch(/.\/$/) + expect(metadata?.alternates?.canonical).toBe(route) + }) + + it('canonicalizes every redirect stub to its registered destination', () => { + expect(redirectStubs.length).toBe(Object.keys(redirects).length) + for (const stub of redirectStubs) { + expect(stub.metadata?.alternates?.canonical).toBe(redirects[stub.route]) + } }) it('has a unique canonical per page', () => { diff --git a/app/sitemap.test.ts b/app/sitemap.test.ts index 4696cc9..b5e78b3 100644 --- a/app/sitemap.test.ts +++ b/app/sitemap.test.ts @@ -23,10 +23,11 @@ describe('sitemap', () => { } }) - it('includes the career-guidance sales page', async () => { + it('includes the career-coaching sales page but never its redirect stub', async () => { const urls = (await entries()).map(route => route.url) - expect(urls).toContain('https://bidwell.info/career-guidance') + expect(urls).toContain('https://bidwell.info/services/career-coaching') + expect(urls).not.toContain('https://bidwell.info/career-guidance') }) it('includes active experiments and excludes noindexed coming-soon stubs', async () => { diff --git a/app/sitemap.ts b/app/sitemap.ts index 56e5fc2..cb8bac2 100644 --- a/app/sitemap.ts +++ b/app/sitemap.ts @@ -17,7 +17,7 @@ export const dynamic = 'force-static' export default async function sitemap() { const routes = [ { url: baseUrl, priority: 1.0 }, - { url: `${baseUrl}/career-guidance`, priority: 0.8 }, + { url: `${baseUrl}/services/career-coaching`, priority: 0.9 }, { url: `${baseUrl}/blog`, priority: 0.7 }, ...posts.map(post => ({ url: `${baseUrl}/blog/${post.slug}`, diff --git a/e2e/seo.spec.ts b/e2e/seo.spec.ts index 4cea01f..c4682c8 100644 --- a/e2e/seo.spec.ts +++ b/e2e/seo.spec.ts @@ -60,11 +60,22 @@ test.describe('SEO invariants', () => { test('sitemap lists indexable pages only', async ({ request }) => { const xml = await (await request.get('/sitemap.xml')).text() - expect(xml).toContain('https://bidwell.info/career-guidance') + expect(xml).toContain('https://bidwell.info/services/career-coaching') + expect(xml).not.toContain('https://bidwell.info/career-guidance') expect(xml).toContain('https://bidwell.info/experiments/claude-agentic-framework') expect(xml).not.toContain('sonic-weather') }) + test('the moved career-guidance URL serves a redirect stub', async ({ page }) => { + await page.goto('/career-guidance', { waitUntil: 'commit' }) + + // The 0s meta refresh fires immediately; landing on the destination IS the pass. + await page.waitForURL('**/services/career-coaching') + await expect( + page.getByRole('heading', { level: 1, name: /tech career coaching/i }) + ).toBeVisible() + }) + test('entity graph renders ProfessionalService, Person, and WebSite JSON-LD', async ({ page, }) => { diff --git a/lib/redirects.ts b/lib/redirects.ts new file mode 100644 index 0000000..285da00 --- /dev/null +++ b/lib/redirects.ts @@ -0,0 +1,30 @@ +import type { Metadata } from 'next' + +/** + * Client-side redirects for moved URLs — GitHub Pages cannot serve 301s. + * Each entry keeps a stub page at the old path emitting an instant meta + * refresh plus a canonical pointing at the destination, which search + * engines treat as a redirect signal. Stubs are never sitemapped or + * internally linked, and entries are permanent — never delete one or + * reuse an old path. + */ +export const redirects: Record = { + '/career-guidance': '/services/career-coaching', +} + +export function resolveRedirect(from: string): string { + const to = redirects[from] + if (!to) { + throw new Error(`No redirect registered for ${from}`) + } + return to +} + +export function redirectMetadata(from: string): Metadata { + const to = resolveRedirect(from) + return { + title: `Moved to ${to}`, + description: `This page has moved to ${to}.`, + alternates: { canonical: to }, + } +} diff --git a/public/images/next-image-export-optimizer-hashes.json b/public/images/next-image-export-optimizer-hashes.json index 8b53e84..441d0ff 100644 --- a/public/images/next-image-export-optimizer-hashes.json +++ b/public/images/next-image-export-optimizer-hashes.json @@ -1,4 +1,4 @@ { - "/apple-icon.2op_3_9qhojyb.png": "PPJAMIBvTZwzudZNELxW9ijdr747DHs7IkCoXyI3lUU=", - "/icon.18nryn5uzzn91.png": "m124whsoQxa0sD-w9gCvFg-ETvv0-7nX4LSj5iimMp8=" -} + "/apple-icon.2op_3_9qhojyb.png": "PPJAMIBvTZwzudZNELxW9ijdr747DHs7IkCoXyI3lUU=", + "/icon.18nryn5uzzn91.png": "m124whsoQxa0sD-w9gCvFg-ETvv0-7nX4LSj5iimMp8=" +} \ No newline at end of file From 3ee62973e962da2e209d423f41695dda0dade401 Mon Sep 17 00:00:00 2001 From: Jordan Winters Date: Tue, 21 Jul 2026 17:23:32 -0500 Subject: [PATCH 2/8] feat(seo): services hub + three Twin Cities service pages /services hub (four offering cards, solo positioning, local intro) plus dedicated pages for the three consulting lines, each targeting one primary query: software-consulting ('software consultant minneapolis'), ai-consulting ('ai consulting minneapolis'), and engineering-practice-improvement ('engineering process improvement consultant'). Every page: distinct 600-900 word copy in first person, a deliberately different local angle (onsite architecture sessions / past- the-demo-phase AI adoption / embedded practice change), six real-search FAQs with FAQPage schema, Service schema, breadcrumb, own OG card, related-services triangle, and discovery-call CTA. The AI page cites the agent-coordination essay as proof of practice. Sitemap gains all four routes in the same PR that ships them (health-check contract). Co-Authored-By: Claude Fable 5 --- .../ai-consulting/opengraph-image.tsx | 10 + .../services/ai-consulting/page.test.tsx | 98 +++++++++ app/(main)/services/ai-consulting/page.tsx | 189 ++++++++++++++++++ .../opengraph-image.tsx | 10 + .../page.test.tsx | 94 +++++++++ .../engineering-practice-improvement/page.tsx | 178 +++++++++++++++++ app/(main)/services/opengraph-image.tsx | 10 + app/(main)/services/page.test.tsx | 65 ++++++ app/(main)/services/page.tsx | 97 +++++++++ .../software-consulting/opengraph-image.tsx | 10 + .../software-consulting/page.test.tsx | 94 +++++++++ .../services/software-consulting/page.tsx | 187 +++++++++++++++++ app/sitemap.test.ts | 6 +- app/sitemap.ts | 4 + 14 files changed, 1051 insertions(+), 1 deletion(-) create mode 100644 app/(main)/services/ai-consulting/opengraph-image.tsx create mode 100644 app/(main)/services/ai-consulting/page.test.tsx create mode 100644 app/(main)/services/ai-consulting/page.tsx create mode 100644 app/(main)/services/engineering-practice-improvement/opengraph-image.tsx create mode 100644 app/(main)/services/engineering-practice-improvement/page.test.tsx create mode 100644 app/(main)/services/engineering-practice-improvement/page.tsx create mode 100644 app/(main)/services/opengraph-image.tsx create mode 100644 app/(main)/services/page.test.tsx create mode 100644 app/(main)/services/page.tsx create mode 100644 app/(main)/services/software-consulting/opengraph-image.tsx create mode 100644 app/(main)/services/software-consulting/page.test.tsx create mode 100644 app/(main)/services/software-consulting/page.tsx diff --git a/app/(main)/services/ai-consulting/opengraph-image.tsx b/app/(main)/services/ai-consulting/opengraph-image.tsx new file mode 100644 index 0000000..78e6624 --- /dev/null +++ b/app/(main)/services/ai-consulting/opengraph-image.tsx @@ -0,0 +1,10 @@ +import { OG_SIZE, ogCard } from '../../../../lib/og-image' + +export const dynamic = 'force-static' +export const alt = 'AI Consulting & Agent Engineering' +export const size = OG_SIZE +export const contentType = 'image/png' + +export default function OpengraphImage() { + return ogCard('AI Consulting & Agent Engineering', 'Bidwell Consulting · Minneapolis') +} diff --git a/app/(main)/services/ai-consulting/page.test.tsx b/app/(main)/services/ai-consulting/page.test.tsx new file mode 100644 index 0000000..54ab9f9 --- /dev/null +++ b/app/(main)/services/ai-consulting/page.test.tsx @@ -0,0 +1,98 @@ +import { axeTest, render, screen } from 'lib/test-utils' +import { describe, expect, it } from 'vitest' +import AiConsultingPage from './page' + +describe('AI Consulting Page', () => { + it('leads with the agent-engineering h1 and practices-what-it-sells proof', () => { + render() + + expect( + screen.getByRole('heading', { level: 1, name: /ai consulting & agent engineering/i }) + ).toBeInTheDocument() + expect( + screen.getByRole('link', { name: /how i coordinate ai coding agents/i }) + ).toHaveAttribute('href', '/blog/agent-coordination') + }) + + it('covers implementation, agents, and AI-native teams', () => { + render() + + expect( + screen.getByRole('heading', { name: /ai implementation & llm integration/i }) + ).toBeInTheDocument() + expect(screen.getByRole('heading', { name: /ai agent development/i })).toBeInTheDocument() + expect( + screen.getByRole('heading', { name: /ai-native engineering teams/i }) + ).toBeInTheDocument() + expect(screen.getByRole('heading', { name: /how i approach ai projects/i })).toBeInTheDocument() + }) + + it('has a distinct Twin Cities paragraph about the local adoption moment', () => { + render() + + expect( + screen.getByRole('heading', { name: /ai work from the twin cities/i }) + ).toBeInTheDocument() + expect(screen.getByText(/past the demo phase/i)).toBeInTheDocument() + }) + + it('answers real AI-adoption questions in the FAQ', () => { + render() + + expect( + screen.getByText(/can you build custom ai agents for our workflows\?/i) + ).toBeInTheDocument() + expect(screen.getByText(/is our data safe when we use ai tools\?/i)).toBeInTheDocument() + expect( + screen.getByText(/do we need custom ai or can we use off-the-shelf tools\?/i) + ).toBeInTheDocument() + }) + + it('links the related-services triangle and the contact CTA', () => { + render() + + expect(screen.getByRole('link', { name: /engineering practice improvement/i })).toHaveAttribute( + 'href', + '/services/engineering-practice-improvement' + ) + expect(screen.getByRole('link', { name: /software consulting/i })).toHaveAttribute( + 'href', + '/services/software-consulting' + ) + expect(screen.getByRole('link', { name: /book a free discovery call/i })).toHaveAttribute( + 'href', + '/contact' + ) + }) + + it('emits Service, FAQPage, and breadcrumb structured data', () => { + const { container } = render() + + const types = Array.from(container.querySelectorAll('script[type="application/ld+json"]')).map( + script => JSON.parse(script.textContent || '{}')['@type'] + ) + expect(types).toContain('Service') + expect(types).toContain('FAQPage') + expect(types).toContain('BreadcrumbList') + }) + + it('has exactly one h1 and a coherent h2 set', () => { + render() + + expect(screen.getAllByRole('heading', { level: 1 })).toHaveLength(1) + expect(screen.getAllByRole('heading', { level: 2 })).toHaveLength(8) + }) + + it('is accessible', async () => { + const { container } = render() + await axeTest(container) + }) + + it('targets "ai consulting minneapolis" in metadata', async () => { + const { metadata } = await import('./page') + expect(metadata.title).toBe('AI Consulting in Minneapolis') + expect(metadata.description).toContain('LLM integration') + expect(metadata.description).toContain('Minneapolis') + expect(metadata.alternates?.canonical).toBe('/services/ai-consulting') + }) +}) diff --git a/app/(main)/services/ai-consulting/page.tsx b/app/(main)/services/ai-consulting/page.tsx new file mode 100644 index 0000000..5017456 --- /dev/null +++ b/app/(main)/services/ai-consulting/page.tsx @@ -0,0 +1,189 @@ +import { faqSchema, serviceSchema } from 'lib/structured-data' +import type { Metadata } from 'next' +import Link from 'next/link' +import type React from 'react' +import Breadcrumb from '../../../components/breadcrumb' +import JsonLd from '../../../components/structured-data' + +export const metadata: Metadata = { + title: 'AI Consulting in Minneapolis', + description: + 'AI consulting in Minneapolis — LLM integration, AI agent development, and coding-agent workflows for Twin Cities teams. Book a free discovery call.', + keywords: [ + 'ai consulting minneapolis', + 'ai implementation consultant', + 'ai agent development', + 'llm integration consultant', + 'ai consulting twin cities', + ], + alternates: { canonical: '/services/ai-consulting' }, + openGraph: { + title: 'AI Consulting & Agent Engineering — Minneapolis', + description: + 'LLM integration, AI agent development, and coding-agent workflows — from a consultant who works this way daily.', + type: 'website', + }, +} + +const faqs = [ + { + question: 'What does an AI implementation consultant do?', + answer: + 'Turns "we should be doing something with AI" into a working system: picking the use cases worth automating, integrating LLMs into your product or workflow, building the evaluation and guardrails around them, and getting your team comfortable operating it all.', + }, + { + question: 'How do we start using AI in our business?', + answer: + "Start with one workflow that's high-volume, low-risk, and measurable — not a moonshot. We scope it in a discovery call, ship a working pilot, measure honestly, and only then widen. Most failed AI projects skipped the 'measure honestly' step.", + }, + { + question: 'Do we need custom AI or can we use off-the-shelf tools?', + answer: + 'Usually off-the-shelf models with custom integration — the value is rarely in training your own model, and almost always in how well the model is wired into your data, your process, and your guardrails. I'll tell you plainly when a SaaS tool already solves your problem.', + }, + { + question: 'Can you build custom AI agents for our workflows?', + answer: + 'Yes — agents that draft, triage, reconcile, or coordinate real business processes, with human checkpoints where the cost of a mistake is real. I publish how I coordinate my own coding agents, so you can inspect the approach before you hire it.', + }, + { + question: 'Is our data safe when we use AI tools?', + answer: + 'It can be, if you choose deliberately: which providers see what data, what gets retained, what stays local, and what never leaves your systems. Data-boundary design is part of every AI engagement I take — not an afterthought.', + }, + { + question: 'Can you train our engineering team on AI coding tools?', + answer: + 'Yes. I help teams adopt Copilot- and Claude-class tools with the practices that make them safe: review discipline, quality gates, and workflows that amplify judgment instead of replacing it.', + }, +] + +export default function AiConsultingPage(): React.JSX.Element { + return ( +
    + + + + +

    + AI Consulting & Agent Engineering +

    +

    + Plenty of consultants advise on AI. I build with it daily — including the multi-agent + workflows that produce my own work — and I publish{' '} + + how I coordinate AI coding agents + {' '} + so you can judge the approach before you pay for it. The goal is always the same: AI that + ships, with guardrails, not demos that die in a slide deck. +

    + +
    +

    + AI Implementation & LLM Integration +

    +

    + Wiring large language models into products and workflows that already exist: document + processing, support triage, search and retrieval over your own data, drafting and + summarization inside the tools your team lives in. Scoped around evaluation from day one, + so you know whether it works — not just whether it demos. +

    +
    + +
    +

    AI Agent Development

    +

    + Agents earn their keep on multi-step work: triaging queues, reconciling records, + coordinating handoffs between systems. I design them with explicit boundaries and human + checkpoints where mistakes are expensive — autonomy where it's safe, oversight where + it isn't. +

    +
    + +
    +

    AI-Native Engineering Teams

    +

    + Coding agents are the biggest change to software delivery in a decade, and most teams are + getting mixed results because they adopted the tool without the practice. I help + engineering teams put the structure around Copilot- and Claude-class tools — review + discipline, quality gates, coordination patterns — that turns them from chaos amplifiers + into force multipliers. +

    +
    + +
    +

    How I Approach AI Projects

    +

    + Pragmatically. AI is spectacular at some jobs and confidently wrong at others, and the + expensive failures come from not knowing which is which. I start small, measure real + outcomes, design the data boundaries deliberately, and tell you when the right answer is + “don't use AI for this.” +

    +
    + +
    +

    AI Work From the Twin Cities

    +

    + Twin Cities companies are past the demo phase — the question now is what AI actually + changes for their business. I meet Minneapolis–St. Paul clients in person for working + sessions and leadership briefings; the engineering itself runs remote, same as any modern + delivery. +

    +
    + +
    +

    Frequently Asked Questions

    +
    + {faqs.map(faq => ( +
    +

    {faq.question}

    +

    {faq.answer}

    +
    + ))} +
    +
    + +
    +

    Related Services

    +

    + Making AI adoption stick is a practice problem — see{' '} + + engineering practice improvement + + . Need the system around the model built too? See{' '} + + software consulting + + . +

    +
    + +
    +

    Start a Conversation

    +

    + Have a workflow you suspect AI could carry?{' '} + + Book a free discovery call + {' '} + — 30 minutes, no commitment. +

    +
    +
    + ) +} diff --git a/app/(main)/services/engineering-practice-improvement/opengraph-image.tsx b/app/(main)/services/engineering-practice-improvement/opengraph-image.tsx new file mode 100644 index 0000000..571c074 --- /dev/null +++ b/app/(main)/services/engineering-practice-improvement/opengraph-image.tsx @@ -0,0 +1,10 @@ +import { OG_SIZE, ogCard } from '../../../../lib/og-image' + +export const dynamic = 'force-static' +export const alt = 'Engineering Practice Improvement' +export const size = OG_SIZE +export const contentType = 'image/png' + +export default function OpengraphImage() { + return ogCard('Engineering Practice Improvement', 'Bidwell Consulting · Minneapolis') +} diff --git a/app/(main)/services/engineering-practice-improvement/page.test.tsx b/app/(main)/services/engineering-practice-improvement/page.test.tsx new file mode 100644 index 0000000..0d4dfce --- /dev/null +++ b/app/(main)/services/engineering-practice-improvement/page.test.tsx @@ -0,0 +1,94 @@ +import { axeTest, render, screen } from 'lib/test-utils' +import { describe, expect, it } from 'vitest' +import EngineeringPracticeImprovementPage from './page' + +describe('Engineering Practice Improvement Page', () => { + it('leads with the consulting h1 and the day-job credential', () => { + render() + + expect( + screen.getByRole('heading', { + level: 1, + name: /engineering practice improvement consulting/i, + }) + ).toBeInTheDocument() + expect( + screen.getByText(/lead engineering practice\s+improvement for a living/i) + ).toBeInTheDocument() + }) + + it('covers scope, engagement shape, and measurement', () => { + render() + + expect( + screen.getByRole('heading', { name: /what practice improvement covers/i }) + ).toBeInTheDocument() + expect(screen.getByRole('heading', { name: /how an engagement runs/i })).toBeInTheDocument() + expect(screen.getByRole('heading', { name: /measuring what matters/i })).toBeInTheDocument() + }) + + it('has a distinct Twin Cities paragraph about embedded on-site time', () => { + render() + + expect( + screen.getByRole('heading', { name: /working with twin cities teams/i }) + ).toBeInTheDocument() + expect(screen.getByText(/embed on-site/i)).toBeInTheDocument() + }) + + it('answers agile-vs-practice and measurement questions in the FAQ', () => { + render() + + expect(screen.getByText(/is this the same as agile coaching\?/i)).toBeInTheDocument() + expect(screen.getByText(/how do you measure developer productivity\?/i)).toBeInTheDocument() + expect(screen.getByText(/can you help us adopt ai coding tools safely\?/i)).toBeInTheDocument() + }) + + it('links the related services and the contact CTA', () => { + render() + + expect(screen.getByRole('link', { name: /ai & agent engineering/i })).toHaveAttribute( + 'href', + '/services/ai-consulting' + ) + expect(screen.getByRole('link', { name: /career coaching/i })).toHaveAttribute( + 'href', + '/services/career-coaching' + ) + expect(screen.getByRole('link', { name: /book a free discovery call/i })).toHaveAttribute( + 'href', + '/contact' + ) + }) + + it('emits Service, FAQPage, and breadcrumb structured data', () => { + const { container } = render() + + const types = Array.from(container.querySelectorAll('script[type="application/ld+json"]')).map( + script => JSON.parse(script.textContent || '{}')['@type'] + ) + expect(types).toContain('Service') + expect(types).toContain('FAQPage') + expect(types).toContain('BreadcrumbList') + }) + + it('has exactly one h1 and a coherent h2 set', () => { + render() + + expect(screen.getAllByRole('heading', { level: 1 })).toHaveLength(1) + expect(screen.getAllByRole('heading', { level: 2 })).toHaveLength(7) + }) + + it('is accessible', async () => { + const { container } = render() + await axeTest(container) + }) + + it('targets "engineering process improvement consultant" in metadata', async () => { + const { metadata } = await import('./page') + expect(metadata.title).toBe('Engineering Practice Improvement') + expect(metadata.description).toContain('process improvement') + expect(metadata.description).toContain('Twin Cities') + expect(metadata.alternates?.canonical).toBe('/services/engineering-practice-improvement') + }) +}) diff --git a/app/(main)/services/engineering-practice-improvement/page.tsx b/app/(main)/services/engineering-practice-improvement/page.tsx new file mode 100644 index 0000000..1fd1afc --- /dev/null +++ b/app/(main)/services/engineering-practice-improvement/page.tsx @@ -0,0 +1,178 @@ +import { faqSchema, serviceSchema } from 'lib/structured-data' +import type { Metadata } from 'next' +import Link from 'next/link' +import type React from 'react' +import Breadcrumb from '../../../components/breadcrumb' +import JsonLd from '../../../components/structured-data' + +export const metadata: Metadata = { + title: 'Engineering Practice Improvement', + description: + 'Engineering process improvement consulting in Minneapolis — DevOps, agile delivery, and developer productivity for Twin Cities teams. Free discovery call.', + keywords: [ + 'engineering process improvement consultant', + 'devops consulting twin cities', + 'agile consulting minneapolis', + 'developer productivity consultant', + 'engineering effectiveness', + ], + alternates: { canonical: '/services/engineering-practice-improvement' }, + openGraph: { + title: 'Engineering Practice Improvement — Minneapolis', + description: + 'DevOps, agile delivery, and developer productivity consulting from a practitioner who leads this work for a living.', + type: 'website', + }, +} + +const faqs = [ + { + question: 'What is engineering practice improvement?', + answer: + "Making the way your team builds software measurably better: how work flows from idea to production, how quality is enforced, how ownership is shared, and how fast you can safely ship. It's the discipline of removing the friction your engineers complain about in private.", + }, + { + question: 'Is this the same as agile coaching?', + answer: + "It overlaps, but no. I'm an engineer first — the recommendations come from shipping software, not from a certification deck. Ceremony that doesn't serve delivery gets cut, not added. If a standup isn't earning its fifteen minutes, we kill the standup.", + }, + { + question: 'How do you measure developer productivity?', + answer: + 'Flow and outcome metrics — lead time, deploy frequency, change-failure rate, recovery time — plus the qualitative signal your engineers already have. What I refuse to do is rank humans by lines of code or ticket counts; vanity metrics rot trust and change nothing.', + }, + { + question: 'How long does a process improvement engagement take?', + answer: + 'An assessment runs a couple of weeks. Real practice change sticks over one to three months of embedded work — long enough to change habits, short enough to stay honest. You should see the first measurable improvement inside the first month.', + }, + { + question: 'Can you help us adopt AI coding tools safely?', + answer: + "Yes — it's become the most common reason teams call. Coding agents amplify whatever discipline you already have, so the practices come first: review standards, quality gates, and coordination patterns. I publish how I run my own agent workflows.", + }, + { + question: 'Do you work with distributed or remote teams?', + answer: + 'Constantly. Practice improvement is mostly about how work and decisions flow, and that flows through the same tools whether your team sits in one room or five time zones.', + }, +] + +export default function EngineeringPracticeImprovementPage(): React.JSX.Element { + return ( +
    + + + + +

    + Engineering Practice Improvement Consulting +

    +

    + Helping engineering teams ship faster is my day job — I lead Engineering Practice + Improvement for a living, and I consult on the same craft. If your team is busy but delivery + feels slow, the problem is rarely effort. It's friction, and friction can be found and + removed. +

    + +
    +

    + What Practice Improvement Covers +

    +

    + The whole path from idea to production: DevOps practices and pipeline health, agile + delivery that serves outcomes instead of ceremony, code ownership and review culture, and + the product operating model that connects engineering work to things customers notice. + Teams should run experiments, own what they ship, and see their work matter. +

    +
    + +
    +

    How an Engagement Runs

    +

    + Assess: a few weeks watching how work actually flows — not how the wiki + says it flows. Recommend: a short, ranked list of changes with the + reasoning attached, never a hundred-page deck. Embed: I work alongside + the team while the changes take hold, because practice change that arrives by memo + doesn't survive the quarter. +

    +
    + +
    +

    Measuring What Matters

    +

    + Lead time, deploy frequency, change-failure rate, time to recover — measured before and + after, so improvement is a fact rather than a feeling. And the AI-era addition: teams that + get their practices right can adopt coding agents safely and go genuinely faster; teams + that don't just produce defects at higher velocity. +

    +
    + +
    +

    Working With Twin Cities Teams

    +

    + Practice change sticks when someone is in the room. For Minneapolis–St. Paul teams I embed + on-site for the moments that matter — working sessions, retros, delivery reviews — and run + the rest remotely. Fully distributed org? The whole engagement runs remote just as well. +

    +
    + +
    +

    Frequently Asked Questions

    +
    + {faqs.map(faq => ( +
    +

    {faq.question}

    +

    {faq.answer}

    +
    + ))} +
    +
    + +
    +

    Related Services

    +

    + Adopting AI as part of the change? See{' '} + + AI & agent engineering + + . Want growth support for the engineers themselves? See{' '} + + career coaching + + . +

    +
    + +
    +

    Start a Conversation

    +

    + Curious where your delivery friction actually is?{' '} + + Book a free discovery call + {' '} + — 30 minutes, no commitment. +

    +
    +
    + ) +} diff --git a/app/(main)/services/opengraph-image.tsx b/app/(main)/services/opengraph-image.tsx new file mode 100644 index 0000000..88724b0 --- /dev/null +++ b/app/(main)/services/opengraph-image.tsx @@ -0,0 +1,10 @@ +import { OG_SIZE, ogCard } from '../../../lib/og-image' + +export const dynamic = 'force-static' +export const alt = 'Consulting Services' +export const size = OG_SIZE +export const contentType = 'image/png' + +export default function OpengraphImage() { + return ogCard('Consulting Services', 'Bidwell Consulting · Minneapolis') +} diff --git a/app/(main)/services/page.test.tsx b/app/(main)/services/page.test.tsx new file mode 100644 index 0000000..7d76fd9 --- /dev/null +++ b/app/(main)/services/page.test.tsx @@ -0,0 +1,65 @@ +import { axeTest, render, screen } from 'lib/test-utils' +import { describe, expect, it } from 'vitest' +import ServicesPage from './page' + +describe('Services Hub Page', () => { + it('renders the hub heading and the solo local intro', () => { + render() + + expect( + screen.getByRole('heading', { level: 1, name: /consulting services/i }) + ).toBeInTheDocument() + expect(screen.getByText(/based in minneapolis/i)).toBeInTheDocument() + expect(screen.getByText(/twin cities metro/i)).toBeInTheDocument() + }) + + it('links all four offerings as cards', () => { + render() + + expect(screen.getByRole('link', { name: /software consulting/i })).toHaveAttribute( + 'href', + '/services/software-consulting' + ) + expect(screen.getByRole('link', { name: /ai & agent engineering/i })).toHaveAttribute( + 'href', + '/services/ai-consulting' + ) + expect(screen.getByRole('link', { name: /engineering practice improvement/i })).toHaveAttribute( + 'href', + '/services/engineering-practice-improvement' + ) + expect(screen.getByRole('link', { name: /career coaching/i })).toHaveAttribute( + 'href', + '/services/career-coaching' + ) + }) + + it('describes who the practice serves and offers the discovery call', () => { + render() + + expect(screen.getByRole('heading', { name: /who i work with/i })).toBeInTheDocument() + expect(screen.getByRole('link', { name: /free 30-minute discovery call/i })).toHaveAttribute( + 'href', + '/contact' + ) + }) + + it('has exactly one h1 and a coherent h2 set', () => { + render() + + expect(screen.getAllByRole('heading', { level: 1 })).toHaveLength(1) + expect(screen.getAllByRole('heading', { level: 2 })).toHaveLength(3) + }) + + it('is accessible', async () => { + const { container } = render() + await axeTest(container) + }) + + it('has hub metadata with a self-canonical', async () => { + const { metadata } = await import('./page') + expect(metadata.title).toBe('Software & AI Consulting Services') + expect(metadata.description).toContain('Minneapolis') + expect(metadata.alternates?.canonical).toBe('/services') + }) +}) diff --git a/app/(main)/services/page.tsx b/app/(main)/services/page.tsx new file mode 100644 index 0000000..0df4b70 --- /dev/null +++ b/app/(main)/services/page.tsx @@ -0,0 +1,97 @@ +import type { Metadata } from 'next' +import Link from 'next/link' +import type React from 'react' +import Breadcrumb from '../../components/breadcrumb' + +export const metadata: Metadata = { + title: 'Software & AI Consulting Services', + description: + 'Software consulting, AI & agent engineering, engineering practice improvement, and tech career coaching from a Minneapolis consultant. Free discovery call.', + alternates: { canonical: '/services' }, +} + +const services = [ + { + title: 'Software Consulting', + href: '/services/software-consulting', + description: 'Architecture, custom software development, and rescue work — hands-on.', + }, + { + title: 'AI & Agent Engineering', + href: '/services/ai-consulting', + description: 'Pragmatic AI adoption: LLM integration, custom agents, coding-agent workflows.', + }, + { + title: 'Engineering Practice Improvement', + href: '/services/engineering-practice-improvement', + description: 'Ship faster with healthier DevOps, agile delivery, and review culture.', + }, + { + title: 'Career Coaching', + href: '/services/career-coaching', + description: '1-on-1 coaching for engineers and tech leaders — resumes, interviews, moves.', + }, +] + +export default function ServicesPage(): React.JSX.Element { + return ( +
    + + +

    Consulting Services

    + +
    +

    How I Can Help

    +

    + I'm Jordan Winters — an independent consultant with nearly 20 years in software. + Every engagement is with me directly: the person you talk to on the discovery call is the + person who does the work. I'm based in Minneapolis and work with companies across the + Twin Cities metro, and remotely everywhere else. +

    +
    + {services.map(service => ( + +

    {service.title}

    +

    + {service.description} +

    + + Learn more + + + ))} +
    +
    + +
    +

    Who I Work With

    +

    + Founders and CTOs of small-to-mid companies who need senior software judgment without an + agency's overhead. Engineering leaders at larger organizations who want delivery to + feel less stuck. And individual engineers working on their own careers. I'm one + person by design — which means honest scope, direct communication, and no handoffs. +

    +
    + +
    +

    Start With a Conversation

    +

    + Not sure which of these fits? That's what the{' '} + + free 30-minute discovery call + {' '} + is for. +

    +
    +
    + ) +} diff --git a/app/(main)/services/software-consulting/opengraph-image.tsx b/app/(main)/services/software-consulting/opengraph-image.tsx new file mode 100644 index 0000000..d5eaa36 --- /dev/null +++ b/app/(main)/services/software-consulting/opengraph-image.tsx @@ -0,0 +1,10 @@ +import { OG_SIZE, ogCard } from '../../../../lib/og-image' + +export const dynamic = 'force-static' +export const alt = 'Software Consulting in Minneapolis' +export const size = OG_SIZE +export const contentType = 'image/png' + +export default function OpengraphImage() { + return ogCard('Software Consulting', 'Bidwell Consulting · Minneapolis') +} diff --git a/app/(main)/services/software-consulting/page.test.tsx b/app/(main)/services/software-consulting/page.test.tsx new file mode 100644 index 0000000..5eaa22b --- /dev/null +++ b/app/(main)/services/software-consulting/page.test.tsx @@ -0,0 +1,94 @@ +import { axeTest, render, screen } from 'lib/test-utils' +import { describe, expect, it } from 'vitest' +import SoftwareConsultingPage from './page' + +describe('Software Consulting Page', () => { + it('leads with the Minneapolis-targeted h1 and hands-on positioning', () => { + render() + + expect( + screen.getByRole('heading', { level: 1, name: /software consulting in minneapolis/i }) + ).toBeInTheDocument() + expect(screen.getByText(/nearly 20 years/i)).toBeInTheDocument() + }) + + it('covers development, architecture, and rescue offerings', () => { + render() + + expect( + screen.getByRole('heading', { name: /custom software development/i }) + ).toBeInTheDocument() + expect( + screen.getByRole('heading', { name: /architecture & technical strategy/i }) + ).toBeInTheDocument() + expect(screen.getByRole('heading', { name: /rescue & modernization/i })).toBeInTheDocument() + expect(screen.getByRole('heading', { name: /how engagements work/i })).toBeInTheDocument() + }) + + it('has a distinct Twin Cities paragraph about onsite collaboration', () => { + render() + + expect( + screen.getByRole('heading', { name: /working together in the twin cities/i }) + ).toBeInTheDocument() + expect(screen.getByText(/anywhere in the minneapolis–st\. paul metro/i)).toBeInTheDocument() + }) + + it('answers the cost question honestly in the FAQ', () => { + render() + + expect( + screen.getByText(/how much does a software consultant cost in minneapolis\?/i) + ).toBeInTheDocument() + expect(screen.getByText(/what does a software consultant actually do\?/i)).toBeInTheDocument() + expect(screen.getByText(/do you work onsite in the twin cities\?/i)).toBeInTheDocument() + }) + + it('links the related-services triangle and the contact CTA', () => { + render() + + expect(screen.getByRole('link', { name: /ai & agent engineering/i })).toHaveAttribute( + 'href', + '/services/ai-consulting' + ) + expect(screen.getByRole('link', { name: /engineering practice improvement/i })).toHaveAttribute( + 'href', + '/services/engineering-practice-improvement' + ) + expect(screen.getByRole('link', { name: /book a free discovery call/i })).toHaveAttribute( + 'href', + '/contact' + ) + }) + + it('emits Service, FAQPage, and breadcrumb structured data', () => { + const { container } = render() + + const types = Array.from(container.querySelectorAll('script[type="application/ld+json"]')).map( + script => JSON.parse(script.textContent || '{}')['@type'] + ) + expect(types).toContain('Service') + expect(types).toContain('FAQPage') + expect(types).toContain('BreadcrumbList') + }) + + it('has exactly one h1 and a coherent h2 set', () => { + render() + + expect(screen.getAllByRole('heading', { level: 1 })).toHaveLength(1) + expect(screen.getAllByRole('heading', { level: 2 })).toHaveLength(8) + }) + + it('is accessible', async () => { + const { container } = render() + await axeTest(container) + }) + + it('targets "software consultant minneapolis" in metadata', async () => { + const { metadata } = await import('./page') + expect(metadata.title).toBe('Software Consulting in Minneapolis') + expect(metadata.description).toContain('Minneapolis') + expect(metadata.description).toContain('Twin Cities') + expect(metadata.alternates?.canonical).toBe('/services/software-consulting') + }) +}) diff --git a/app/(main)/services/software-consulting/page.tsx b/app/(main)/services/software-consulting/page.tsx new file mode 100644 index 0000000..561f362 --- /dev/null +++ b/app/(main)/services/software-consulting/page.tsx @@ -0,0 +1,187 @@ +import { faqSchema, serviceSchema } from 'lib/structured-data' +import type { Metadata } from 'next' +import Link from 'next/link' +import type React from 'react' +import Breadcrumb from '../../../components/breadcrumb' +import JsonLd from '../../../components/structured-data' + +export const metadata: Metadata = { + title: 'Software Consulting in Minneapolis', + description: + 'Hands-on software consulting in Minneapolis — architecture, custom software development, and legacy rescue for Twin Cities teams. Free discovery call.', + keywords: [ + 'software consultant minneapolis', + 'software consulting twin cities', + 'custom software development', + 'software architecture consultant', + 'legacy software modernization', + ], + alternates: { canonical: '/services/software-consulting' }, + openGraph: { + title: 'Software Consulting in Minneapolis — Bidwell Consulting', + description: + 'Hands-on software consulting — architecture, custom development, and legacy rescue for Twin Cities teams.', + type: 'website', + }, +} + +const faqs = [ + { + question: 'What does a software consultant actually do?', + answer: + 'Whatever moves your software forward: designing systems, writing code, reviewing architecture, untangling legacy projects, or advising your team on hard technical decisions. Unlike an agency, you work directly with me — the person doing the thinking is the person doing the work.', + }, + { + question: 'How much does a software consultant cost in Minneapolis?', + answer: + 'Independent consultants in the Twin Cities typically run from the low hundreds per hour, with fixed-scope projects priced by outcome rather than time. The honest answer depends on scope and urgency — the free discovery call exists so I can give you a real number instead of a range.', + }, + { + question: 'Can you take over an existing or legacy codebase?', + answer: + "Yes — rescue work is a specialty. I start with an assessment of what's actually there, stabilize the riskiest parts, and lay out a modernization path you can execute incrementally instead of betting on a rewrite.", + }, + { + question: 'Do you write the code yourself or just advise?', + answer: + 'Both, and you choose the mix. Some clients want working software delivered; others want architecture reviews and a sounding board for their team. Either way I stay hands-on enough that my advice survives contact with a compiler.', + }, + { + question: 'Do you work with startups and small businesses?', + answer: + 'Yes. Small teams are where a single experienced consultant beats an agency: no account managers, no handoffs, and scope shaped to what you can actually maintain after I leave.', + }, + { + question: 'Do you work onsite in the Twin Cities?', + answer: + 'For architecture sessions, whiteboarding, and workshops — gladly, anywhere in the metro. Day-to-day build work usually runs remote, which keeps it efficient for both of us.', + }, +] + +export default function SoftwareConsultingPage(): React.JSX.Element { + return ( +
    + + + + +

    + Software Consulting in Minneapolis +

    +

    + I'm Jordan Winters, an independent software consultant with nearly 20 years of building + and leading. I work hands-on: I design systems, write code, and leave your team with + software — and decisions — that hold up. No account managers, no junior handoffs. +

    + +
    +

    Custom Software Development

    +

    + Prototypes that prove an idea, internal tools that remove a bottleneck, integrations that + make two systems finally talk, MVPs scoped to learn something real. I build custom + software sized for a solo expert to deliver well — which is exactly the size where an + agency's overhead stops making sense. +

    +
    + +
    +

    + Architecture & Technical Strategy +

    +

    + Architecture reviews before you commit a year of budget. Build-versus-buy calls with the + trade-offs stated plainly. Technical due diligence when you're acquiring or + investing. System design that starts from your constraints — team size, budget, and what + you can operate at 2 a.m. — instead of whatever is trending. +

    +
    + +
    +

    Rescue & Modernization

    +

    + Inherited a codebase nobody understands? Watched a project stall past its second deadline? + I take over troubled and legacy systems, stabilize what's risky, and chart an + incremental modernization path — because “rewrite it” is usually the most + expensive sentence in software. +

    +
    + +
    +

    How Engagements Work

    +

    + Three shapes: a fixed-scope assessment when you need clarity before + committing, a project when you need something built, or an ongoing{' '} + advisory arrangement when your team needs a senior technical voice on + call. Every engagement starts with a free 30-minute discovery call and a written scope — + you'll always know what you're paying for and what done looks like. +

    +
    + +
    +

    + Working Together in the Twin Cities +

    +

    + I'm based in Minneapolis. For the parts of software work that go better in a room — + architecture sessions, whiteboarding, team workshops — I'll come to you anywhere in + the Minneapolis–St. Paul metro. The day-to-day build work runs remote, so distance never + slows a project down. +

    +
    + +
    +

    Frequently Asked Questions

    +
    + {faqs.map(faq => ( +
    +

    {faq.question}

    +

    {faq.answer}

    +
    + ))} +
    +
    + +
    +

    Related Services

    +

    + Building with LLMs or coding agents? See{' '} + + AI & agent engineering + + . Team shipping slower than it should? See{' '} + + engineering practice improvement + + . +

    +
    + +
    +

    Start a Conversation

    +

    + Tell me what you're building or what's stuck.{' '} + + Book a free discovery call + {' '} + — 30 minutes, no commitment. +

    +
    +
    + ) +} diff --git a/app/sitemap.test.ts b/app/sitemap.test.ts index b5e78b3..7e6165f 100644 --- a/app/sitemap.test.ts +++ b/app/sitemap.test.ts @@ -23,9 +23,13 @@ describe('sitemap', () => { } }) - it('includes the career-coaching sales page but never its redirect stub', async () => { + it('includes the services hub and all four service pages, never the redirect stub', async () => { const urls = (await entries()).map(route => route.url) + expect(urls).toContain('https://bidwell.info/services') + expect(urls).toContain('https://bidwell.info/services/software-consulting') + expect(urls).toContain('https://bidwell.info/services/ai-consulting') + expect(urls).toContain('https://bidwell.info/services/engineering-practice-improvement') expect(urls).toContain('https://bidwell.info/services/career-coaching') expect(urls).not.toContain('https://bidwell.info/career-guidance') }) diff --git a/app/sitemap.ts b/app/sitemap.ts index cb8bac2..f88cc5d 100644 --- a/app/sitemap.ts +++ b/app/sitemap.ts @@ -17,6 +17,10 @@ export const dynamic = 'force-static' export default async function sitemap() { const routes = [ { url: baseUrl, priority: 1.0 }, + { url: `${baseUrl}/services`, priority: 0.8 }, + { url: `${baseUrl}/services/software-consulting`, priority: 0.9 }, + { url: `${baseUrl}/services/ai-consulting`, priority: 0.9 }, + { url: `${baseUrl}/services/engineering-practice-improvement`, priority: 0.9 }, { url: `${baseUrl}/services/career-coaching`, priority: 0.9 }, { url: `${baseUrl}/blog`, priority: 0.7 }, ...posts.map(post => ({ From 5066c1fe22f5def6c2567e8ebce9aa8c6752b9de Mon Sep 17 00:00:00 2001 From: Jordan Winters Date: Tue, 21 Jul 2026 17:26:29 -0500 Subject: [PATCH 3/8] feat(seo): homepage rewrite + about + contact pages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Home becomes the metro category page: 'Twin Cities Software Consulting' title (keyword-first for a weak-brand domain), 'Software Consulting in the Twin Cities' h1, founder hero linking /about, discovery-call CTA, four service cards from a shared config (also consumed by the hub), an AI-native differentiator section citing the agent-coordination essay, and exactly one local trust line. /about is the E-E-A-T anchor — Jordan Winters, Minneapolis, beliefs, solo working model, proof links — and the Person schema url now points there. /contact ships LinkedIn-primary (email lands in the gated commit) with the free-discovery-call framing and the NAP line. Sitemap gains /about and /contact. Co-Authored-By: Claude Fable 5 --- app/(main)/about/opengraph-image.tsx | 10 ++ app/(main)/about/page.test.tsx | 73 ++++++++++++++ app/(main)/about/page.tsx | 124 ++++++++++++++++++++++++ app/(main)/contact/opengraph-image.tsx | 10 ++ app/(main)/contact/page.test.tsx | 56 +++++++++++ app/(main)/contact/page.tsx | 60 ++++++++++++ app/(main)/opengraph-image.tsx | 2 +- app/(main)/page.test.tsx | 96 ++++++++++++++---- app/(main)/page.tsx | 120 +++++++++++++++++------ app/(main)/services/page.tsx | 26 +---- app/components/structured-data.test.tsx | 1 + app/config/services.ts | 29 ++++++ app/sitemap.test.ts | 7 ++ app/sitemap.ts | 2 + lib/structured-data.ts | 2 +- 15 files changed, 545 insertions(+), 73 deletions(-) create mode 100644 app/(main)/about/opengraph-image.tsx create mode 100644 app/(main)/about/page.test.tsx create mode 100644 app/(main)/about/page.tsx create mode 100644 app/(main)/contact/opengraph-image.tsx create mode 100644 app/(main)/contact/page.test.tsx create mode 100644 app/(main)/contact/page.tsx create mode 100644 app/config/services.ts diff --git a/app/(main)/about/opengraph-image.tsx b/app/(main)/about/opengraph-image.tsx new file mode 100644 index 0000000..6ff7a6d --- /dev/null +++ b/app/(main)/about/opengraph-image.tsx @@ -0,0 +1,10 @@ +import { OG_SIZE, ogCard } from '../../../lib/og-image' + +export const dynamic = 'force-static' +export const alt = 'Jordan Winters — Software Consultant, Minneapolis' +export const size = OG_SIZE +export const contentType = 'image/png' + +export default function OpengraphImage() { + return ogCard('Jordan Winters', 'Software Consultant · Minneapolis') +} diff --git a/app/(main)/about/page.test.tsx b/app/(main)/about/page.test.tsx new file mode 100644 index 0000000..6b09968 --- /dev/null +++ b/app/(main)/about/page.test.tsx @@ -0,0 +1,73 @@ +import { axeTest, render, screen } from 'lib/test-utils' +import { describe, expect, it } from 'vitest' +import AboutPage from './page' + +describe('About Page', () => { + it('names Jordan Winters with the Minneapolis base and experience claim', () => { + render() + + expect( + screen.getByRole('heading', { level: 1, name: /about jordan winters/i }) + ).toBeInTheDocument() + expect(screen.getByText(/live and work in minneapolis/i)).toBeInTheDocument() + expect(screen.getByText(/nearly 20 years/i)).toBeInTheDocument() + }) + + it('links the public proof surfaces safely', () => { + render() + + const linkedin = screen.getByRole('link', { name: /linkedin/i }) + expect(linkedin).toHaveAttribute('href', 'https://www.linkedin.com/in/wintersjordan/') + const github = screen.getByRole('link', { name: /github/i }) + expect(github).toHaveAttribute('href', 'https://github.com/dralgorhythm') + for (const link of [linkedin, github]) { + expect(link).toHaveAttribute('target', '_blank') + expect(link).toHaveAttribute('rel', 'noopener noreferrer') + } + expect(screen.getByRole('link', { name: /coordinating ai coding agents/i })).toHaveAttribute( + 'href', + '/blog/agent-coordination' + ) + }) + + it('states beliefs and the solo working model', () => { + render() + + expect(screen.getByRole('heading', { name: /what i believe/i })).toBeInTheDocument() + expect(screen.getByRole('heading', { name: /how i work/i })).toBeInTheDocument() + expect(screen.getByText(/solo, by design/i)).toBeInTheDocument() + }) + + it('links all four services and the contact page from Work With Me', () => { + render() + + expect(screen.getByRole('link', { name: /software consulting/i })).toHaveAttribute( + 'href', + '/services/software-consulting' + ) + expect(screen.getByRole('link', { name: /career coaching/i })).toHaveAttribute( + 'href', + '/services/career-coaching' + ) + expect(screen.getByRole('link', { name: /get in touch/i })).toHaveAttribute('href', '/contact') + }) + + it('has exactly one h1 and a coherent h2 set', () => { + render() + + expect(screen.getAllByRole('heading', { level: 1 })).toHaveLength(1) + expect(screen.getAllByRole('heading', { level: 2 })).toHaveLength(5) + }) + + it('is accessible', async () => { + const { container } = render() + await axeTest(container) + }) + + it('has entity-page metadata', async () => { + const { metadata } = await import('./page') + expect(metadata.title).toBe('About Jordan Winters') + expect(metadata.description).toContain('Minneapolis-based software consultant') + expect(metadata.alternates?.canonical).toBe('/about') + }) +}) diff --git a/app/(main)/about/page.tsx b/app/(main)/about/page.tsx new file mode 100644 index 0000000..f828c41 --- /dev/null +++ b/app/(main)/about/page.tsx @@ -0,0 +1,124 @@ +import type { Metadata } from 'next' +import Link from 'next/link' +import type React from 'react' +import { serviceEntries } from '../../config/services' + +export const metadata: Metadata = { + title: 'About Jordan Winters', + description: + 'Jordan Winters is a Minneapolis-based software consultant with nearly 20 years in engineering, architecture, and practice improvement. Serving the Twin Cities.', + alternates: { canonical: '/about' }, + openGraph: { + title: 'Jordan Winters — Software Consultant, Minneapolis', + description: + 'Nearly 20 years in engineering, architecture, and practice improvement. Based in Minneapolis, serving the Twin Cities.', + type: 'profile', + }, +} + +export default function AboutPage(): React.JSX.Element { + return ( +
    +

    About Jordan Winters

    + +
    +

    Hi, I'm Jordan

    +

    + I live and work in Minneapolis and I've spent nearly 20 years building software and + building the teams that build software — as an engineer, an architect, and a leader of + Engineering Practice Improvement. Bidwell Consulting is where I do that work directly for + companies across the Twin Cities metro, and remotely everywhere else. +

    +

    + You can verify most of this the same way I would:{' '} + + LinkedIn + {' '} + for the career,{' '} + + GitHub + {' '} + for the code, and my essay on{' '} + + coordinating AI coding agents + {' '} + for how I think. +

    +
    + +
    +

    What I Believe

    +
      +
    • + Engineering exists to serve outcomes — teams do their best work when they can run real + experiments and see the results land with customers. +
    • +
    • + Speed and stability reinforce each other. Quality gates aren't a tax on velocity; + they're what makes sustained velocity possible. +
    • +
    • + You build it, you own it. Ownership — not process theater — is what keeps software + healthy after the launch party. +
    • +
    +
    + +
    +

    How I Work

    +

    + Solo, by design. The person you meet on the discovery call is the person who designs the + system, writes the code, and answers the hard questions — there are no juniors to hand you + off to. That keeps scope honest, communication direct, and quality personal. +

    +
    + +
    +

    Beyond Work

    +

    + When I'm not shipping software I'm usually making music — you can hear what that + sounds like on{' '} + + SoundCloud + + . Minneapolis is home, and the coffee-meeting offer is real. +

    +
    + +
    +

    Work With Me

    +
      + {serviceEntries.map(service => ( +
    • + + {service.title} + +
    • + ))} +
    +

    + Or just{' '} + + get in touch + {' '} + — the discovery call is free. +

    +
    +
    + ) +} diff --git a/app/(main)/contact/opengraph-image.tsx b/app/(main)/contact/opengraph-image.tsx new file mode 100644 index 0000000..3f19fcf --- /dev/null +++ b/app/(main)/contact/opengraph-image.tsx @@ -0,0 +1,10 @@ +import { OG_SIZE, ogCard } from '../../../lib/og-image' + +export const dynamic = 'force-static' +export const alt = 'Contact Bidwell Consulting' +export const size = OG_SIZE +export const contentType = 'image/png' + +export default function OpengraphImage() { + return ogCard('Contact', 'Bidwell Consulting · Minneapolis, MN') +} diff --git a/app/(main)/contact/page.test.tsx b/app/(main)/contact/page.test.tsx new file mode 100644 index 0000000..a20da9a --- /dev/null +++ b/app/(main)/contact/page.test.tsx @@ -0,0 +1,56 @@ +import { axeTest, render, screen } from 'lib/test-utils' +import { describe, expect, it } from 'vitest' +import ContactPage from './page' + +describe('Contact Page', () => { + it('renders the heading with the LinkedIn CTA', () => { + render() + + expect(screen.getByRole('heading', { level: 1, name: /contact/i })).toBeInTheDocument() + const cta = screen.getByRole('link', { name: /message me on linkedin/i }) + expect(cta).toHaveAttribute('href', 'https://www.linkedin.com/in/wintersjordan/') + expect(cta).toHaveAttribute('target', '_blank') + expect(cta).toHaveAttribute('rel', 'noopener noreferrer') + }) + + it('explains the free discovery call honestly', () => { + render() + + expect(screen.getByRole('heading', { name: /free discovery call/i })).toBeInTheDocument() + expect(screen.getByText(/no commitment, no pitch/i)).toBeInTheDocument() + }) + + it('tells prospects what to include', () => { + render() + + expect(screen.getByRole('heading', { name: /what to include/i })).toBeInTheDocument() + expect(screen.getByText(/rough timeline/i)).toBeInTheDocument() + }) + + it('shows the NAP line exactly once', () => { + render() + + expect( + screen.getAllByText(/bidwell consulting · minneapolis, mn · serving the twin cities metro/i) + ).toHaveLength(1) + }) + + it('has exactly one h1 and a coherent h2 set', () => { + render() + + expect(screen.getAllByRole('heading', { level: 1 })).toHaveLength(1) + expect(screen.getAllByRole('heading', { level: 2 })).toHaveLength(3) + }) + + it('is accessible', async () => { + const { container } = render() + await axeTest(container) + }) + + it('has navigational metadata with the local line', async () => { + const { metadata } = await import('./page') + expect(metadata.title).toBe('Contact') + expect(metadata.description).toContain('Minneapolis') + expect(metadata.alternates?.canonical).toBe('/contact') + }) +}) diff --git a/app/(main)/contact/page.tsx b/app/(main)/contact/page.tsx new file mode 100644 index 0000000..a3b3408 --- /dev/null +++ b/app/(main)/contact/page.tsx @@ -0,0 +1,60 @@ +import type { Metadata } from 'next' +import type React from 'react' + +export const metadata: Metadata = { + title: 'Contact', + description: + 'Reach Bidwell Consulting — message me on LinkedIn or book a free 30-minute discovery call. Based in Minneapolis, serving the Twin Cities metro and remote clients.', + alternates: { canonical: '/contact' }, + openGraph: { + title: 'Contact Bidwell Consulting — Minneapolis, MN', + description: + 'Message me on LinkedIn or book a free 30-minute discovery call. Minneapolis-based, remote-friendly.', + type: 'website', + }, +} + +export default function ContactPage(): React.JSX.Element { + return ( +
    +

    Contact

    + +
    +

    Get in Touch

    +

    + The fastest way to reach me is a message on LinkedIn — I read everything. +

    + + Message me on LinkedIn + +
    + +
    +

    Free Discovery Call

    +

    + Every engagement starts with a free 30-minute conversation — no commitment, no pitch. + We'll talk through what you're working on and whether I'm the right person + to help. If I'm not, I'll say so and point you somewhere better. +

    +
    + +
    +

    What to Include

    +
      +
    • What you're building or what's stuck — a few sentences is plenty
    • +
    • Rough timeline: exploring, this quarter, or on fire
    • +
    • How you'd like to work: project, assessment, or ongoing advice
    • +
    +
    + +

    + Bidwell Consulting · Minneapolis, MN · Serving the Twin Cities metro · Remote-friendly +

    +
    + ) +} diff --git a/app/(main)/opengraph-image.tsx b/app/(main)/opengraph-image.tsx index ea6a715..77b24e3 100644 --- a/app/(main)/opengraph-image.tsx +++ b/app/(main)/opengraph-image.tsx @@ -12,5 +12,5 @@ export const contentType = 'image/png' * so the image file must be colocated in this segment. */ export default function OpengraphImage() { - return ogCard('Bidwell Consulting', 'Software Engineering & Organizational Consulting') + return ogCard('Bidwell Consulting', 'Software Consulting in the Twin Cities') } diff --git a/app/(main)/page.test.tsx b/app/(main)/page.test.tsx index e0f5319..d506599 100644 --- a/app/(main)/page.test.tsx +++ b/app/(main)/page.test.tsx @@ -1,29 +1,90 @@ import { axeTest, render, screen } from 'lib/test-utils' +import { describe, expect, it } from 'vitest' import HomePage from './page' -describe('Home Page Integration', () => { - it('renders the main value proposition', () => { +describe('Home Page', () => { + it('leads with the Twin Cities h1 and the founder hero', () => { render() expect( - screen.getByRole('heading', { level: 1, name: /bidwell consulting/i }) + screen.getByRole('heading', { level: 1, name: /software consulting in the twin cities/i }) ).toBeInTheDocument() - expect(screen.getByText(/welcome to bidwell consulting/i)).toBeInTheDocument() - expect(screen.getByText(/thoughts on problem solving/i)).toBeInTheDocument() + expect(screen.getByRole('link', { name: /jordan winters/i })).toHaveAttribute('href', '/about') + expect(screen.getByText(/nearly 20 years/i)).toBeInTheDocument() }) - it('displays key business services', () => { + it('shows the primary discovery-call CTA', () => { render() - expect(screen.getByText(/Software engineering/i)).toBeInTheDocument() - expect(screen.getByText(/Organizational consulting/i)).toBeInTheDocument() - expect(screen.getByText(/Full-stack problem solving/i)).toBeInTheDocument() - expect(screen.getByText(/Career coaching/i)).toBeInTheDocument() + const ctas = screen.getAllByRole('link', { name: /book a free discovery call/i }) + expect(ctas.length).toBeGreaterThanOrEqual(1) + for (const cta of ctas) { + expect(cta).toHaveAttribute('href', '/contact') + } }) - it('includes the expertise section', () => { + it('links all four service cards plus the hub', () => { render() - expect(screen.getByRole('heading', { name: /expertise & services/i })).toBeInTheDocument() + + expect(screen.getByRole('link', { name: /software consulting/i })).toHaveAttribute( + 'href', + '/services/software-consulting' + ) + expect(screen.getByRole('link', { name: /ai & agent engineering/i })).toHaveAttribute( + 'href', + '/services/ai-consulting' + ) + expect(screen.getByRole('link', { name: /engineering practice improvement/i })).toHaveAttribute( + 'href', + '/services/engineering-practice-improvement' + ) + expect(screen.getByRole('link', { name: /career coaching/i })).toHaveAttribute( + 'href', + '/services/career-coaching' + ) + expect(screen.getByRole('link', { name: /all services/i })).toHaveAttribute('href', '/services') + }) + + it('differentiates with AI-native proof linking the essay', () => { + render() + + expect(screen.getByRole('heading', { name: /ai-native consulting/i })).toBeInTheDocument() + expect( + screen.getByRole('link', { name: /how i coordinate ai coding agents/i }) + ).toHaveAttribute('href', '/blog/agent-coordination') + }) + + it('carries exactly one local trust line', () => { + render() + + expect( + screen.getByText(/based in minneapolis and serving the twin cities metro/i) + ).toBeInTheDocument() + }) + + it('keeps the solo first-person voice (no "our team")', () => { + const { container } = render() + + expect(container.textContent).not.toMatch(/our team/i) + }) + + it('opens external links safely', () => { + render() + + const externalLinks = screen + .getAllByRole('link') + .filter(link => (link.getAttribute('href') ?? '').startsWith('http')) + for (const link of externalLinks) { + expect(link).toHaveAttribute('target', '_blank') + expect(link).toHaveAttribute('rel', 'noopener noreferrer') + } + }) + + it('has exactly one h1 and a coherent h2 set', () => { + render() + + expect(screen.getAllByRole('heading', { level: 1 })).toHaveLength(1) + expect(screen.getAllByRole('heading', { level: 2 })).toHaveLength(3) }) it('is accessible', async () => { @@ -31,10 +92,11 @@ describe('Home Page Integration', () => { await axeTest(container) }) - it('has a valid heading hierarchy', () => { - render() - // Should have exactly one h1 - const h1Elements = screen.getAllByRole('heading', { level: 1 }) - expect(h1Elements).toHaveLength(1) + it('targets "software consulting twin cities" keyword-first', async () => { + const { metadata } = await import('./page') + expect(metadata.title).toBe('Twin Cities Software Consulting') + expect(metadata.description).toContain('Minneapolis') + expect(metadata.description).toContain('Twin Cities') + expect(metadata.alternates?.canonical).toBe('/') }) }) diff --git a/app/(main)/page.tsx b/app/(main)/page.tsx index db5ae85..54fdcae 100644 --- a/app/(main)/page.tsx +++ b/app/(main)/page.tsx @@ -1,25 +1,24 @@ import type { Metadata } from 'next' +import Link from 'next/link' import type React from 'react' +import { serviceEntries } from '../config/services' export const metadata: Metadata = { - title: 'Bidwell Consulting', + title: 'Twin Cities Software Consulting', description: - 'Expert software engineering and organizational consulting firm - solving problems, designing systems, and optimizing processes.', + 'Independent software consultant in Minneapolis — architecture, AI engineering, and practice improvement for Twin Cities teams. Book a free discovery call.', keywords: [ + 'software consulting twin cities', + 'software consultant minneapolis', + 'technology consulting minneapolis', + 'independent software consultant', 'bidwell consulting', - 'software engineering services', - 'organizational consulting', - 'portfolio', - 'technical consulting', - 'system architecture', - 'business optimization', - 'full-stack development', ], alternates: { canonical: '/' }, openGraph: { - title: 'Bidwell Consulting', + title: 'Bidwell Consulting — Software Consulting in the Twin Cities', description: - 'Expert software engineer and organizational consultant, specializing in problem solving, system design, and process optimization. This portfolio site showcases innovative technical solutions and demonstrates our approach to complex problem solving.', + 'Independent software consultant in Minneapolis — architecture, AI engineering, and practice improvement for Twin Cities teams.', type: 'website', }, } @@ -27,28 +26,89 @@ export const metadata: Metadata = { export default function Page(): React.JSX.Element { return (
    -

    Bidwell Consulting

    +

    + Software Consulting in the Twin Cities +

    -
    -

    - Welcome to Bidwell Consulting, I look forward to working with you! Here, you can find my - thoughts on problem solving. -

    +

    + I'm{' '} + + Jordan Winters + + , an independent software consultant in Minneapolis. I help companies design systems, ship + software, and build engineering practices that hold up — drawing on nearly 20 years of + building and leading. +

    + +
    + + Book a free discovery call + +
    -

    Expertise & Services

    - +

    {service.title}

    +

    + {service.description} +

    + + ))} +
    +

    + Or browse{' '} + + all services + + . +

    + + +
    +

    AI-Native Consulting

    +

    + I don't just advise on AI — I work this way every day, coordinating coding agents to + deliver real software, and I publish{' '} + + how I coordinate AI coding agents + + . If you want AI in your engineering practice, you want someone who has already made it + work in their own. +

    +

    + Based in Minneapolis and serving the Twin Cities metro — in person when it helps, remote + when it doesn't. +

    +
    + +
    +

    Start a Conversation

    +

    + Tell me what you're building, what's stuck, or where you want to be.{' '} + + Book a free discovery call + {' '} + or reach out on{' '} + + LinkedIn + + . +

    ) diff --git a/app/(main)/services/page.tsx b/app/(main)/services/page.tsx index 0df4b70..de26def 100644 --- a/app/(main)/services/page.tsx +++ b/app/(main)/services/page.tsx @@ -2,6 +2,7 @@ import type { Metadata } from 'next' import Link from 'next/link' import type React from 'react' import Breadcrumb from '../../components/breadcrumb' +import { serviceEntries } from '../../config/services' export const metadata: Metadata = { title: 'Software & AI Consulting Services', @@ -10,29 +11,6 @@ export const metadata: Metadata = { alternates: { canonical: '/services' }, } -const services = [ - { - title: 'Software Consulting', - href: '/services/software-consulting', - description: 'Architecture, custom software development, and rescue work — hands-on.', - }, - { - title: 'AI & Agent Engineering', - href: '/services/ai-consulting', - description: 'Pragmatic AI adoption: LLM integration, custom agents, coding-agent workflows.', - }, - { - title: 'Engineering Practice Improvement', - href: '/services/engineering-practice-improvement', - description: 'Ship faster with healthier DevOps, agile delivery, and review culture.', - }, - { - title: 'Career Coaching', - href: '/services/career-coaching', - description: '1-on-1 coaching for engineers and tech leaders — resumes, interviews, moves.', - }, -] - export default function ServicesPage(): React.JSX.Element { return (
    @@ -54,7 +32,7 @@ export default function ServicesPage(): React.JSX.Element { Twin Cities metro, and remotely everywhere else.

    - {services.map(service => ( + {serviceEntries.map(service => ( { expect(schema['@type']).toBe('Person') expect(schema['@id']).toBe('https://bidwell.info/#person') expect(schema.name).toBe('Jordan Winters') + expect(schema.url).toBe('https://bidwell.info/about') expect(schema.worksFor).toEqual({ '@id': 'https://bidwell.info/#organization' }) expect(schema.sameAs).toContain('https://www.linkedin.com/in/wintersjordan/') }) diff --git a/app/config/services.ts b/app/config/services.ts new file mode 100644 index 0000000..17b4f0d --- /dev/null +++ b/app/config/services.ts @@ -0,0 +1,29 @@ +/** The four consulting offerings — single source for home cards, the services hub, and the footer. */ +export interface ServiceEntry { + title: string + href: string + description: string +} + +export const serviceEntries: ServiceEntry[] = [ + { + title: 'Software Consulting', + href: '/services/software-consulting', + description: 'Architecture, custom software development, and rescue work — hands-on.', + }, + { + title: 'AI & Agent Engineering', + href: '/services/ai-consulting', + description: 'Pragmatic AI adoption: LLM integration, custom agents, coding-agent workflows.', + }, + { + title: 'Engineering Practice Improvement', + href: '/services/engineering-practice-improvement', + description: 'Ship faster with healthier DevOps, agile delivery, and review culture.', + }, + { + title: 'Career Coaching', + href: '/services/career-coaching', + description: '1-on-1 coaching for engineers and tech leaders — resumes, interviews, moves.', + }, +] diff --git a/app/sitemap.test.ts b/app/sitemap.test.ts index 7e6165f..1d11ba0 100644 --- a/app/sitemap.test.ts +++ b/app/sitemap.test.ts @@ -34,6 +34,13 @@ describe('sitemap', () => { expect(urls).not.toContain('https://bidwell.info/career-guidance') }) + it('includes the about and contact pages', async () => { + const urls = (await entries()).map(route => route.url) + + expect(urls).toContain('https://bidwell.info/about') + expect(urls).toContain('https://bidwell.info/contact') + }) + it('includes active experiments and excludes noindexed coming-soon stubs', async () => { const urls = (await entries()).map(route => route.url) diff --git a/app/sitemap.ts b/app/sitemap.ts index f88cc5d..a1305b3 100644 --- a/app/sitemap.ts +++ b/app/sitemap.ts @@ -22,6 +22,8 @@ export default async function sitemap() { { url: `${baseUrl}/services/ai-consulting`, priority: 0.9 }, { url: `${baseUrl}/services/engineering-practice-improvement`, priority: 0.9 }, { url: `${baseUrl}/services/career-coaching`, priority: 0.9 }, + { url: `${baseUrl}/about`, priority: 0.6 }, + { url: `${baseUrl}/contact`, priority: 0.7 }, { url: `${baseUrl}/blog`, priority: 0.7 }, ...posts.map(post => ({ url: `${baseUrl}/blog/${post.slug}`, diff --git a/lib/structured-data.ts b/lib/structured-data.ts index b7a48c3..61f4064 100644 --- a/lib/structured-data.ts +++ b/lib/structured-data.ts @@ -77,7 +77,7 @@ export function personSchema(): WithContext { '@id': PERSON_ID, name: siteConfig.founder.name, jobTitle: siteConfig.founder.jobTitle, - url: baseUrl, + url: absoluteUrl('/about'), worksFor: { '@id': ORGANIZATION_ID }, sameAs: [...siteConfig.founder.sameAs], knowsAbout: [ From ba66221a3492f4cb0e697d86e4ce898aa50b9482 Mon Sep 17 00:00:00 2001 From: Jordan Winters Date: Tue, 21 Jul 2026 17:30:15 -0500 Subject: [PATCH 4/8] feat(seo): nav/footer architecture, sitewide positioning, llms.txt + RSS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nav becomes home/services/coaching/blog/about/contact (experiments moves to the footer — 12 of 13 are noindexed stubs). The footer grows three link columns (Services x4 / Site / Connect) plus one plain NAP line, fixing the sitewide orphaning of a footer that previously held only social links. Root default title/description flip to the Twin Cities positioning (deliberately held out of PR1), authors credit Jordan Winters, and the WebSite/Organization schema description follows via siteConfig. Blog: index description drops 'the team' voice; the post's title tag targets 'Coordinating AI Coding Agents' via a new seoTitle field (h1 unchanged), gains a byline linking /about, repoints its intro Engineering-Practice-Improvement link from LinkedIn to the service page, and closes with an ai-consulting pointer. New AI/feed discovery surfaces: public/llms.txt and a hand-rolled static /rss.xml (linked from the layout head and blog index). Co-Authored-By: Claude Fable 5 --- app/(main)/blog/agent-coordination/page.tsx | 23 +++-- app/(main)/blog/page.tsx | 10 +- app/(main)/blog/posts.ts | 6 +- app/components/footer.test.tsx | 68 ++++++++---- app/components/footer.tsx | 109 +++++++++++++------- app/components/nav.test.tsx | 13 +++ app/config/nav.ts | 16 ++- app/layout.tsx | 29 +++--- app/rss.xml/route.ts | 43 ++++++++ e2e/seo.spec.ts | 12 +++ lib/site-config.ts | 3 +- public/llms.txt | 20 ++++ 12 files changed, 269 insertions(+), 83 deletions(-) create mode 100644 app/rss.xml/route.ts create mode 100644 public/llms.txt diff --git a/app/(main)/blog/agent-coordination/page.tsx b/app/(main)/blog/agent-coordination/page.tsx index c8989ff..213fe44 100644 --- a/app/(main)/blog/agent-coordination/page.tsx +++ b/app/(main)/blog/agent-coordination/page.tsx @@ -1,12 +1,13 @@ import { siteConfig } from 'lib/site-config' import { blogPostingSchema } from 'lib/structured-data' import type { Metadata } from 'next' +import Link from 'next/link' import Breadcrumb from '../../../components/breadcrumb' import JsonLd from '../../../components/structured-data' import { formatPostDate, agentCoordinationPost as post } from '../posts' export const metadata: Metadata = { - title: post.title, + title: post.seoTitle, description: post.description, alternates: { canonical: `/blog/${post.slug}` }, openGraph: { @@ -33,19 +34,18 @@ export default function AgentCoordinationPost() {

    {post.title}

    - {formatPostDate(post.publishedAt)} + + Jordan Winters + {' '} + · Engineering Practice Improvement · {formatPostDate(post.publishedAt)}

    +

    + I help teams put this into practice — see{' '} + + AI & agent engineering consulting + + . +

    ) } diff --git a/app/(main)/blog/page.tsx b/app/(main)/blog/page.tsx index 46f530f..49509d5 100644 --- a/app/(main)/blog/page.tsx +++ b/app/(main)/blog/page.tsx @@ -5,7 +5,8 @@ import { formatPostDate, posts } from './posts' export const metadata: Metadata = { title: 'Blog', - description: 'Updates and insights from the Bidwell Consulting team.', + description: + 'Notes on software engineering, AI coding agents, and engineering practice from Jordan Winters, a software consultant in Minneapolis.', alternates: { canonical: '/blog' }, } @@ -29,6 +30,13 @@ export default function BlogPage() { ))} +

    + Subscribe via{' '} + + RSS + + . +

    ) } diff --git a/app/(main)/blog/posts.ts b/app/(main)/blog/posts.ts index 1dae616..9281e97 100644 --- a/app/(main)/blog/posts.ts +++ b/app/(main)/blog/posts.ts @@ -6,6 +6,8 @@ export interface Post { slug: string title: string + /** Search-targeted when it should differ from the on-page h1. */ + seoTitle: string description: string /** ISO date (YYYY-MM-DD) the post was first published. Never derived from build time. */ publishedAt: string @@ -14,7 +16,9 @@ export interface Post { export const agentCoordinationPost: Post = { slug: 'agent-coordination', title: 'Agent Coordination Structure', - description: 'A framework for managing AI agents within the Bidwell ecosystem.', + seoTitle: 'Coordinating AI Coding Agents', + description: + 'A practical structure for coordinating AI coding agents — critical directives, artifact handoffs, and personas.', publishedAt: '2025-11-27', } diff --git a/app/components/footer.test.tsx b/app/components/footer.test.tsx index a3a3174..5521924 100644 --- a/app/components/footer.test.tsx +++ b/app/components/footer.test.tsx @@ -1,43 +1,71 @@ -import { render, screen } from '@testing-library/react' -import { axeTest } from 'lib/test-utils' +import { axeTest, render, screen } from 'lib/test-utils' +import { describe, expect, it } from 'vitest' import Footer from './footer' describe('Footer Component', () => { - it('renders navigation links', () => { + it('links all four services', () => { render(<Footer />) - expect(screen.getByRole('link', { name: /linkedin/i })).toBeInTheDocument() - expect(screen.getByRole('link', { name: /github/i })).toBeInTheDocument() - expect(screen.getByRole('link', { name: /soundcloud/i })).toBeInTheDocument() - }) - - it('has correct href attributes', () => { - render(<Footer />) - - expect(screen.getByRole('link', { name: /linkedin/i })).toHaveAttribute( + expect(screen.getByRole('link', { name: /software consulting/i })).toHaveAttribute( + 'href', + '/services/software-consulting' + ) + expect(screen.getByRole('link', { name: /ai & agent engineering/i })).toHaveAttribute( + 'href', + '/services/ai-consulting' + ) + expect(screen.getByRole('link', { name: /engineering practice improvement/i })).toHaveAttribute( 'href', - 'https://www.linkedin.com/in/wintersjordan/' + '/services/engineering-practice-improvement' ) - expect(screen.getByRole('link', { name: /github/i })).toHaveAttribute( + expect(screen.getByRole('link', { name: /career coaching/i })).toHaveAttribute( 'href', - 'https://github.com/dralgorhythm' + '/services/career-coaching' ) - expect(screen.getByRole('link', { name: /soundcloud/i })).toHaveAttribute( + }) + + it('links the site pages including experiments', () => { + render(<Footer />) + + expect(screen.getByRole('link', { name: /about/i })).toHaveAttribute('href', '/about') + expect(screen.getByRole('link', { name: /blog/i })).toHaveAttribute('href', '/blog') + expect(screen.getByRole('link', { name: /experiments/i })).toHaveAttribute( 'href', - 'https://soundcloud.com/dralgorhythm' + '/experiments' ) + expect(screen.getByRole('link', { name: /contact/i })).toHaveAttribute('href', '/contact') }) - it('opens external links in new tab with security attributes', () => { + it('opens social links safely in a new tab', () => { render(<Footer />) - const links = screen.getAllByRole('link') - for (const link of links) { + const socials: [string, string][] = [ + ['linkedin', 'https://www.linkedin.com/in/wintersjordan/'], + ['github', 'https://github.com/dralgorhythm'], + ['soundcloud', 'https://soundcloud.com/dralgorhythm'], + ] + for (const [name, href] of socials) { + const link = screen.getByRole('link', { name: new RegExp(name, 'i') }) + expect(link).toHaveAttribute('href', href) expect(link).toHaveAttribute('target', '_blank') expect(link).toHaveAttribute('rel', 'noopener noreferrer') } }) + it('shows the NAP line exactly once, plain', () => { + render(<Footer />) + + expect( + screen.getAllByText(/bidwell consulting · minneapolis, mn · serving the twin cities metro/i) + ).toHaveLength(1) + }) + + it('is a labeled footer navigation landmark', () => { + render(<Footer />) + + expect(screen.getByRole('navigation', { name: /footer/i })).toBeInTheDocument() + }) + it('is accessible', async () => { const { container } = render(<Footer />) await axeTest(container) diff --git a/app/components/footer.tsx b/app/components/footer.tsx index 59912ea..2870d6a 100644 --- a/app/components/footer.tsx +++ b/app/components/footer.tsx @@ -1,5 +1,21 @@ +import Link from 'next/link' import type React from 'react' +import { serviceEntries } from '../config/services' + +const siteLinks = [ + { name: 'About', href: '/about' }, + { name: 'Blog', href: '/blog' }, + { name: 'Experiments', href: '/experiments' }, + { name: 'Contact', href: '/contact' }, +] + +const socialLinks = [ + { name: 'linkedin', href: 'https://www.linkedin.com/in/wintersjordan/' }, + { name: 'github', href: 'https://github.com/dralgorhythm' }, + { name: 'soundcloud', href: 'https://soundcloud.com/dralgorhythm' }, +] + function ArrowIcon(): React.JSX.Element { return ( <svg width='12' height='12' viewBox='0 0 12 12' fill='none' xmlns='http://www.w3.org/2000/svg'> @@ -14,42 +30,63 @@ function ArrowIcon(): React.JSX.Element { export default function Footer(): React.JSX.Element { return ( - <footer className='mb-16'> - <ul className='font-sm mt-8 flex flex-col space-x-0 space-y-2 text-neutral-600 md:flex-row md:space-x-4 md:space-y-0 dark:text-neutral-300'> - <li> - <a - className='flex items-center transition-all hover:text-neutral-800 dark:hover:text-neutral-100' - rel='noopener noreferrer' - target='_blank' - href='https://www.linkedin.com/in/wintersjordan/' - > - <ArrowIcon /> - <p className='ml-2 h-7'>linkedin</p> - </a> - </li> - <li> - <a - className='flex items-center transition-all hover:text-neutral-800 dark:hover:text-neutral-100' - rel='noopener noreferrer' - target='_blank' - href='https://github.com/dralgorhythm' - > - <ArrowIcon /> - <p className='ml-2 h-7'>github</p> - </a> - </li> - <li> - <a - className='flex items-center transition-all hover:text-neutral-800 dark:hover:text-neutral-100' - rel='noopener noreferrer' - target='_blank' - href='https://soundcloud.com/dralgorhythm' - > - <ArrowIcon /> - <p className='ml-2 h-7'>soundcloud</p> - </a> - </li> - </ul> + <footer className='mb-16 mt-8'> + <nav + aria-label='Footer' + className='grid gap-8 sm:grid-cols-3 border-t border-neutral-200 dark:border-neutral-800 pt-8 text-sm' + > + <div> + <p className='mb-3 font-medium text-neutral-900 dark:text-neutral-100'>Services</p> + <ul className='space-y-2 text-neutral-600 dark:text-neutral-300'> + {serviceEntries.map(service => ( + <li key={service.href}> + <Link + href={service.href} + className='transition-all hover:text-neutral-800 dark:hover:text-neutral-100' + > + {service.title} + </Link> + </li> + ))} + </ul> + </div> + <div> + <p className='mb-3 font-medium text-neutral-900 dark:text-neutral-100'>Site</p> + <ul className='space-y-2 text-neutral-600 dark:text-neutral-300'> + {siteLinks.map(link => ( + <li key={link.href}> + <Link + href={link.href} + className='transition-all hover:text-neutral-800 dark:hover:text-neutral-100' + > + {link.name} + </Link> + </li> + ))} + </ul> + </div> + <div> + <p className='mb-3 font-medium text-neutral-900 dark:text-neutral-100'>Connect</p> + <ul className='space-y-2 text-neutral-600 dark:text-neutral-300'> + {socialLinks.map(link => ( + <li key={link.href}> + <a + className='flex items-center transition-all hover:text-neutral-800 dark:hover:text-neutral-100' + rel='noopener noreferrer' + target='_blank' + href={link.href} + > + <ArrowIcon /> + <p className='ml-2 h-7'>{link.name}</p> + </a> + </li> + ))} + </ul> + </div> + </nav> + <p className='mt-8 text-sm text-neutral-600 dark:text-neutral-400'> + Bidwell Consulting · Minneapolis, MN · Serving the Twin Cities metro + </p> </footer> ) } diff --git a/app/components/nav.test.tsx b/app/components/nav.test.tsx index 1cdadbf..f0c9fc6 100644 --- a/app/components/nav.test.tsx +++ b/app/components/nav.test.tsx @@ -28,7 +28,20 @@ describe('Navbar', () => { render(<Navbar />) expect(screen.getByRole('link', { name: /home/i })).toHaveAttribute('href', '/') + expect(screen.getByRole('link', { name: /^services$/i })).toHaveAttribute('href', '/services') + expect(screen.getByRole('link', { name: /coaching/i })).toHaveAttribute( + 'href', + '/services/career-coaching' + ) expect(screen.getByRole('link', { name: /blog/i })).toHaveAttribute('href', '/blog') + expect(screen.getByRole('link', { name: /about/i })).toHaveAttribute('href', '/about') + expect(screen.getByRole('link', { name: /contact/i })).toHaveAttribute('href', '/contact') + }) + + it('does not link experiments from the primary nav (footer only)', () => { + render(<Navbar />) + + expect(screen.queryByRole('link', { name: /experiments/i })).not.toBeInTheDocument() }) it('has proper navigation structure', () => { diff --git a/app/config/nav.ts b/app/config/nav.ts index 28bcc3b..bdfc8f0 100644 --- a/app/config/nav.ts +++ b/app/config/nav.ts @@ -7,15 +7,23 @@ export const navItems: Record<string, NavItem> = { name: 'home', }, + '/services': { + name: 'services', + }, + + '/services/career-coaching': { + name: 'coaching', + }, + '/blog': { name: 'blog', }, - '/experiments': { - name: 'experiments', + '/about': { + name: 'about', }, - '/services/career-coaching': { - name: 'coaching', + '/contact': { + name: 'contact', }, } diff --git a/app/layout.tsx b/app/layout.tsx index 8343946..2002ce0 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -12,21 +12,20 @@ import JsonLd from './components/structured-data' export const metadata: Metadata = { metadataBase: new URL(baseUrl), title: { - default: 'Bidwell Consulting - Software Engineering & Organizational Consulting', + default: 'Bidwell Consulting | Twin Cities Software Consulting', template: '%s | Bidwell Consulting', }, description: - 'Expert software engineering and organizational consulting services. Specialized in technical problem solving, system architecture, and business optimization. Portfolio showcasing innovative solutions and development expertise.', + 'Independent software consulting from Minneapolis — architecture, AI & agent engineering, engineering practice improvement, and tech career coaching for the Twin Cities and beyond.', keywords: [ - 'software engineering', - 'organizational consulting', - 'technical consulting', - 'system architecture', - 'software development', - 'business optimization', - 'portfolio', + 'software consulting', + 'twin cities', + 'minneapolis', + 'ai consulting', + 'engineering practice improvement', + 'career coaching', ], - authors: [{ name: 'Bidwell Consulting' }], + authors: [{ name: 'Jordan Winters' }], creator: 'Bidwell Consulting', publisher: 'Bidwell Consulting', formatDetection: { @@ -35,9 +34,9 @@ export const metadata: Metadata = { telephone: false, }, openGraph: { - title: 'Bidwell Consulting - Software Engineering & Organizational Consulting', + title: 'Bidwell Consulting | Twin Cities Software Consulting', description: - 'Expert software engineering and organizational consulting services. Specialized in technical problem solving, system architecture, and business optimization.', + 'Independent software consulting from Minneapolis — architecture, AI & agent engineering, and practice improvement for the Twin Cities and beyond.', url: baseUrl, siteName: 'Bidwell Consulting', locale: 'en_US', @@ -86,6 +85,12 @@ export default function RootLayout({ children }: { children: React.ReactNode }): <JsonLd data={websiteSchema()} /> <JsonLd data={organizationSchema()} /> <JsonLd data={personSchema()} /> + <link + rel='alternate' + type='application/rss+xml' + title='Bidwell Consulting Blog' + href='/rss.xml' + /> {/* Security headers */} <meta httpEquiv='X-Content-Type-Options' content='nosniff' /> <meta httpEquiv='X-Frame-Options' content='DENY' /> diff --git a/app/rss.xml/route.ts b/app/rss.xml/route.ts new file mode 100644 index 0000000..68d4a33 --- /dev/null +++ b/app/rss.xml/route.ts @@ -0,0 +1,43 @@ +import { baseUrl, siteConfig } from '../../lib/site-config' +import { posts } from '../(main)/blog/posts' + +export const dynamic = 'force-static' + +function escapeXml(value: string): string { + return value + .replace(/&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + .replace(/'/g, ''') +} + +export function GET(): Response { + const items = posts + .map( + post => ` <item> + <title>${escapeXml(post.title)} + ${baseUrl}/blog/${post.slug} + ${baseUrl}/blog/${post.slug} + ${escapeXml(post.description)} + ${new Date(`${post.publishedAt}T12:00:00Z`).toUTCString()} + ` + ) + .join('\n') + + const rss = ` + + + ${escapeXml(siteConfig.name)} Blog + ${baseUrl}/blog + ${escapeXml(siteConfig.description)} + en-us +${items} + + +` + + return new Response(rss, { + headers: { 'Content-Type': 'application/rss+xml; charset=utf-8' }, + }) +} diff --git a/e2e/seo.spec.ts b/e2e/seo.spec.ts index c4682c8..6d7360a 100644 --- a/e2e/seo.spec.ts +++ b/e2e/seo.spec.ts @@ -66,6 +66,18 @@ test.describe('SEO invariants', () => { expect(xml).not.toContain('sonic-weather') }) + test('rss and llms.txt discovery surfaces resolve', async ({ request }) => { + const rss = await request.get('/rss.xml') + expect(rss.status()).toBe(200) + const xml = await rss.text() + expect(xml).toContain('') + expect(xml).toContain('https://bidwell.info/blog/agent-coordination') + + const llms = await request.get('/llms.txt') + expect(llms.status()).toBe(200) + expect(await llms.text()).toContain('# Bidwell Consulting') + }) + test('the moved career-guidance URL serves a redirect stub', async ({ page }) => { await page.goto('/career-guidance', { waitUntil: 'commit' }) diff --git a/lib/site-config.ts b/lib/site-config.ts index 89f625c..54cd9d4 100644 --- a/lib/site-config.ts +++ b/lib/site-config.ts @@ -28,7 +28,8 @@ export interface SiteConfig { export const siteConfig: SiteConfig = { name: 'Bidwell Consulting', baseUrl: 'https://bidwell.info', - description: 'Expert software engineering and organizational consulting services', + description: + 'Independent software consulting from Minneapolis — architecture, AI engineering, and practice improvement for the Twin Cities and beyond.', email: 'jordan@bidwell.info', founder: { name: 'Jordan Winters', diff --git a/public/llms.txt b/public/llms.txt new file mode 100644 index 0000000..dc091f7 --- /dev/null +++ b/public/llms.txt @@ -0,0 +1,20 @@ +# Bidwell Consulting + +> Independent software consulting practice of Jordan Winters, based in Minneapolis, MN, serving the Minneapolis–St. Paul (Twin Cities) metro in person and clients everywhere remotely. Nearly 20 years of engineering, architecture, and engineering-practice leadership. Solo by design: the person on the discovery call is the person who does the work. + +## Services + +- [Software Consulting](https://bidwell.info/services/software-consulting): Architecture, custom software development, and legacy rescue — hands-on, code included. +- [AI Consulting & Agent Engineering](https://bidwell.info/services/ai-consulting): LLM integration, custom AI agents, and coding-agent workflows, from a consultant who works this way daily. +- [Engineering Practice Improvement](https://bidwell.info/services/engineering-practice-improvement): DevOps, agile delivery, and developer productivity — measured by lead time and change-failure rate, not ceremony. +- [Tech Career Coaching](https://bidwell.info/services/career-coaching): 1-on-1 coaching for software engineers and tech professionals — resumes, interviews, transitions. + +## About + +- [About Jordan Winters](https://bidwell.info/about): Background, beliefs, and how the practice works. +- [Contact](https://bidwell.info/contact): Free 30-minute discovery call; LinkedIn is the fastest channel. + +## Writing + +- [Agent Coordination Structure](https://bidwell.info/blog/agent-coordination): A practical structure for coordinating AI coding agents — critical directives, artifact handoffs, and personas. +- [Blog](https://bidwell.info/blog) · [RSS](https://bidwell.info/rss.xml) From c026cb61c0033fcd4c5e28959ea5cbc2d887eda7 Mon Sep 17 00:00:00 2001 From: Jordan Winters Date: Tue, 21 Jul 2026 17:31:41 -0500 Subject: [PATCH 5/8] feat(seo): publish jordan@bidwell.info contact email MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MERGE GATE: requires the Namecheap Email Forwarding alias 'jordan' to exist and receive a test message first (~5 min in the Namecheap dashboard; MX/SPF already live). Drop this commit if merging before the alias is confirmed — everything else in the PR stands alone. Adds the email (single source: siteConfig.email) to the footer Connect column, the contact page as the primary CTA, the home closing CTA, the career-coaching contact section, and the ProfessionalService schema's email property. Co-Authored-By: Claude Fable 5 --- app/(main)/contact/page.test.tsx | 6 +++- app/(main)/contact/page.tsx | 35 ++++++++++++++------ app/(main)/page.tsx | 9 +++-- app/(main)/services/career-coaching/page.tsx | 7 +++- app/components/footer.test.tsx | 9 +++++ app/components/footer.tsx | 9 +++++ app/components/structured-data.test.tsx | 4 +-- lib/structured-data.ts | 1 + 8 files changed, 63 insertions(+), 17 deletions(-) diff --git a/app/(main)/contact/page.test.tsx b/app/(main)/contact/page.test.tsx index a20da9a..c2cd8b2 100644 --- a/app/(main)/contact/page.test.tsx +++ b/app/(main)/contact/page.test.tsx @@ -3,10 +3,14 @@ import { describe, expect, it } from 'vitest' import ContactPage from './page' describe('Contact Page', () => { - it('renders the heading with the LinkedIn CTA', () => { + it('renders the heading with email-primary and LinkedIn CTAs', () => { render() expect(screen.getByRole('heading', { level: 1, name: /contact/i })).toBeInTheDocument() + expect(screen.getByRole('link', { name: /^email me$/i })).toHaveAttribute( + 'href', + 'mailto:jordan@bidwell.info' + ) const cta = screen.getByRole('link', { name: /message me on linkedin/i }) expect(cta).toHaveAttribute('href', 'https://www.linkedin.com/in/wintersjordan/') expect(cta).toHaveAttribute('target', '_blank') diff --git a/app/(main)/contact/page.tsx b/app/(main)/contact/page.tsx index a3b3408..a0063b4 100644 --- a/app/(main)/contact/page.tsx +++ b/app/(main)/contact/page.tsx @@ -1,15 +1,16 @@ +import { siteConfig } from 'lib/site-config' import type { Metadata } from 'next' import type React from 'react' export const metadata: Metadata = { title: 'Contact', description: - 'Reach Bidwell Consulting — message me on LinkedIn or book a free 30-minute discovery call. Based in Minneapolis, serving the Twin Cities metro and remote clients.', + 'Reach Bidwell Consulting — email jordan@bidwell.info or message me on LinkedIn. Based in Minneapolis, serving the Twin Cities metro and remote clients.', alternates: { canonical: '/contact' }, openGraph: { title: 'Contact Bidwell Consulting — Minneapolis, MN', description: - 'Message me on LinkedIn or book a free 30-minute discovery call. Minneapolis-based, remote-friendly.', + 'Email jordan@bidwell.info, message me on LinkedIn, or book a free 30-minute discovery call. Minneapolis-based, remote-friendly.', type: 'website', }, } @@ -22,16 +23,28 @@ export default function ContactPage(): React.JSX.Element {

    Get in Touch

    - The fastest way to reach me is a message on LinkedIn — I read everything. + Email is best:{' '} + + {siteConfig.email} + + . LinkedIn works too — I read everything.

    - - Message me on LinkedIn - +
    diff --git a/app/(main)/page.tsx b/app/(main)/page.tsx index 54fdcae..3a2f6ef 100644 --- a/app/(main)/page.tsx +++ b/app/(main)/page.tsx @@ -1,3 +1,4 @@ +import { siteConfig } from 'lib/site-config' import type { Metadata } from 'next' import Link from 'next/link' import type React from 'react' @@ -97,8 +98,12 @@ export default function Page(): React.JSX.Element { Tell me what you're building, what's stuck, or where you want to be.{' '} Book a free discovery call - {' '} - or reach out on{' '} + + , email{' '} + + {siteConfig.email} + + , or reach out on{' '}

    Get in Touch

    - The best way to reach me is through LinkedIn — or{' '} + Email{' '} + + {siteConfig.email} + + , reach me through LinkedIn, or{' '} book a free discovery call {' '} diff --git a/app/components/footer.test.tsx b/app/components/footer.test.tsx index 5521924..dd2260f 100644 --- a/app/components/footer.test.tsx +++ b/app/components/footer.test.tsx @@ -36,6 +36,15 @@ describe('Footer Component', () => { expect(screen.getByRole('link', { name: /contact/i })).toHaveAttribute('href', '/contact') }) + it('links the contact email as mailto', () => { + render(