add new-site/ — next.js marketing site for five devs

Replaces the hugo site with a custom Next.js 16 app under new-site/.
Old hugo site is untouched until cutover.

What's in here:
- Next.js 16 (App Router, Turbopack) + React 19 + Tailwind v4 + MDX
- Pages: home, services, about, work (+ 2 case studies), contact, blog
  (3 seed posts), thank-you, /sitemap.xml, /robots.txt
- Stripe Payment Link checkout for retainer + 5/10/20-hour blocks,
  with website-redesign-launch sale (50% off) toggleable in
  src/lib/pricing.ts. Falls back to /contact when env vars unset.
- Guarantees component on home + services (4 promises in writing)
- HowIWork principles on about
- Client logo strip with the 8 named clients (text wordmarks for now)
- JSON-LD on every important page (Organization / Person / Service /
  Article / BreadcrumbList) for SEO + LLM discoverability
- /llms.txt at site root
- STRATEGY.md with positioning, 30-day content plan, Stripe setup
  walkthrough, SEO checklist, and a list of what Chris needs to wire
  before launch (Cal.com link, mailbox, photo, client sign-off, env
  vars)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-30 12:22:37 +02:00
parent e45746cb9b
commit f96adbc99a
49 changed files with 8748 additions and 0 deletions

View File

@@ -0,0 +1,16 @@
# Stripe Payment Link URLs.
# Create each Payment Link in your Stripe Dashboard:
# Dashboard -> Payment Links -> + New
# Then paste the resulting URL (looks like https://buy.stripe.com/abc123)
# into the matching variable below.
#
# When the website-redesign-launch sale is active (see
# src/lib/pricing.ts), each Payment Link should be configured at the
# sale price (50% of the regular price shown on the site). When you
# end the sale, swap each URL to a Payment Link priced at the regular
# amount.
NEXT_PUBLIC_STRIPE_RETAINER=
NEXT_PUBLIC_STRIPE_BLOCK_5=
NEXT_PUBLIC_STRIPE_BLOCK_10=
NEXT_PUBLIC_STRIPE_BLOCK_20=

41
new-site/.gitignore vendored Normal file
View File

@@ -0,0 +1,41 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts

5
new-site/AGENTS.md Normal file
View File

@@ -0,0 +1,5 @@
<!-- BEGIN:nextjs-agent-rules -->
# This is NOT the Next.js you know
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices.
<!-- END:nextjs-agent-rules -->

1
new-site/CLAUDE.md Normal file
View File

@@ -0,0 +1 @@
@AGENTS.md

36
new-site/README.md Normal file
View File

@@ -0,0 +1,36 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
## Getting Started
First, run the development server:
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.

340
new-site/STRATEGY.md Normal file
View File

@@ -0,0 +1,340 @@
# Five Devs — Positioning & 30-Day Content Strategy
This document is the strategy behind the v1 site. Treat it as a living
document — update it as the work changes shape.
---
## Positioning
**One-line:** Senior PHP help for the unglamorous, mission-critical glue
between your store, your warehouse, and your books.
**Why this and not "PHP generalist":**
- The real testimonials are *all* in this lane (Pritikin, Americold,
Fortune Fulfillment).
- Premium hourly rates ($150/hr+) come from domain expertise, not from
raw PHP skill. Buyers will pay a senior generalist $80110/hr; they
pay a domain specialist $150250/hr because the alternative is
hiring an in-house team.
- Aaron & Joel at nocompromises.io own the "Laravel maintenance
subscription" lane already. Differentiate by going *deeper* on a
vertical they don't claim (e-commerce/3PL ops) rather than competing
on the same playbook.
- Generalist work will still come in via referrals. Sharp positioning
doesn't shrink your top-of-funnel as much as people think — it
changes who shows up at the bottom of it.
**Three engagement shapes** (live on `/services`):
1. **Project sprint** — fixed scope, fixed price after paid discovery
2. **Monthly retainer** — reserved senior hours, the long tail
3. **Block of time** — 40/80/160-hour blocks usable over 6 months
Pricing strategy: don't put numbers on the site yet. Quote on the call.
Once the rate has crept comfortably to $150/hr, *then* publish a "from
$X" anchor on the services page — that's the move that filters out
unqualified leads at the top of the funnel.
---
## What's on the site (v1)
- `/` — Home: hero, services teaser, real client logo strip, 4
testimonials, CTA
- `/services` — three engagement tiers, plain-language pricing note
- `/work` — index + two case studies (Pritikin Foods, Americold)
- `/about` — voice, tools, story, mailing address
- `/contact` — Cal.com booking link, contact form, email, address
- `/blog` — index + 3 seed posts
---
## Things YOU need to do before launch
These are placeholders / wiring tasks I couldn't do without you:
1. **Cal.com link**`src/app/contact/page.tsx` currently points to
`https://cal.com/cgsmith/intro`. Set up your Cal.com account, create
a 20-minute "Intro call" event type, and update that constant.
2. **Email address** — the site uses `chris@fivedevs.com` throughout.
Either set up that mailbox (Google Workspace / Fastmail) or
global-replace to whatever you actually want public.
3. **Case study sign-off** — re-read the Pritikin and Americold case
studies. They're written from public testimonials only, no
confidential details. But you should still confirm with each client
before the site goes live — a quick "hey, mind if I tell this story
on my site?" email goes a long way and often becomes its own
testimonial moment.
4. **Photo / avatar** — drop a real headshot into `/public/chris.jpg`
and add it to the About page. Even a phone selfie outdoors is fine
— the absence of a face is the single thing that makes a freelancer
site feel cold.
5. **Domain + DNS** — when ready to flip, point `fivedevs.com` at
Vercel (or stay on AWS Amplify and add the Next.js Amplify config).
6. **Optional: contact form upgrade** — the form currently opens the
visitor's email client via `mailto:`. Works on day 1, looks
slightly amateur. Upgrade to a real server action when convenient
(10 lines of Resend code).
7. **Stripe Payment Links** — the Services page now has self-serve
checkout for the retainer and the three block sizes. The site
reads four URLs from environment variables; until they're set,
the buttons fall back to "Talk first" and route to /contact.
**Soft-launch sale: 50% off** is currently active and shows the
crossed-out regular price next to the discounted price. Each
Stripe Payment Link should be configured at the **sale price**
while the soft launch runs.
**Setup (~15 min in the Stripe Dashboard):**
1. **Stripe Dashboard → Products → + Add product** — create four
products. Use these names and prices (all USD, the *sale*
amounts that are charged today):
| Product | Type | Price (sale) | Regular |
|------------------------|-----------|--------------|---------|
| Five Devs Retainer | Recurring | **$2,500/mo** | $5,000/mo |
| 40-Hour Block | One-time | **$2,750** | $5,500 |
| 80-Hour Block | One-time | **$5,250** | $10,500 |
| 160-Hour Block | One-time | **$10,000** | $20,000 |
2. **Stripe Dashboard → Payment Links → + New** — create one
Payment Link per product. For each link:
- **After payment:** "Don't show confirmation page. Instead,
send customers to" → `https://fivedevs.com/thank-you`
- **Collect customer info:** name, email, billing address
- **Allow promotion codes:** off (the price already includes
the discount)
- **Tax behavior:** if you've enabled Stripe Tax, leave on;
otherwise inclusive vs. exclusive doesn't change anything
for B2B customers in most US states.
3. Copy each Payment Link URL (looks like
`https://buy.stripe.com/abc123`) into your local
`.env.local` (use `.env.local.example` as a template):
```
NEXT_PUBLIC_STRIPE_RETAINER=https://buy.stripe.com/...
NEXT_PUBLIC_STRIPE_BLOCK_40=https://buy.stripe.com/...
NEXT_PUBLIC_STRIPE_BLOCK_80=https://buy.stripe.com/...
NEXT_PUBLIC_STRIPE_BLOCK_160=https://buy.stripe.com/...
```
And paste the same four into your Vercel project's
Environment Variables (Project Settings → Environment
Variables → add for **Production** and **Preview**).
4. **Test once.** Use Stripe's test mode link first, then flip
to live. Stripe's test card is `4242 4242 4242 4242`, any
future expiry, any CVC.
**Ending the soft-launch sale (later):**
1. Edit `src/lib/pricing.ts` and set `sale.active = false`.
The crossed-out prices and badge disappear.
2. Create new Payment Links in Stripe at the *regular* prices
and update the four env vars to point at them.
3. Redeploy.
**What sales tax do you owe?** Nevada doesn't tax most
professional services to other businesses, so for most
B2B customers there's nothing to collect. If you take on
significant work for clients in tax-aggressive states (CA, WA,
TX) or sell internationally, enable Stripe Tax — it figures
it out per-transaction for $0.50/transaction. Talk to a CPA
before you cross $250K/year.
8. **Client logos** — the home page strip currently uses styled text
wordmarks for the eight named clients (Americold, Butcher Box,
Perfect Bar, Pritikin Foods, Potawatomi Business Development,
Fortune Fulfillment, Vanderose Farms, PERC Engage). Two things to
do before publishing real logo marks:
- **Get permission.** Email each company a one-liner: "I'd like
to list you as a past client on my site — happy to send a
mockup; let me know if you'd prefer me not to." Most will say
yes; a couple of CPG brands (Butcher Box, Perfect Bar) may
require formal sign-off via PR/legal. Fortune 500 and tribal
entities (Americold, Potawatomi) often have specific usage
requirements — ask first.
- **Source the assets.** Download proper SVGs from each brand's
press kit / "About Us" media page, drop into
`new-site/public/logos/<slug>.svg`, then update
`src/components/client-logos.tsx` to render `<img>` for entries
with a `logo` field. Style: grayscale by default, brighten on
hover, max-height ~32px.
- Note: don't use the Clearbit Logo API — HubSpot shut it down
in late 2024. Brandfetch and Logo.dev are paid alternatives if
you want a managed solution; otherwise self-host the SVGs.
---
## 30-day content plan
The goal of the next month is **two things**, in this order:
1. Get the site live with a real face on it.
2. Publish enough writing that when someone types "[your name] PHP
developer" or "PHP developer for 3PL integration" into Google or
asks an LLM, *something of yours* shows up.
You only need 46 posts to start moving the needle. Quality over
volume. Each post should answer a specific question a buyer would
actually type.
### Week 1 — Launch + one post
- [ ] Day 12: review and ship the v1 site (above checklist)
- [ ] Day 3: announce on LinkedIn/Twitter — short post, one sentence
about positioning, link to home
- [ ] Day 45: Write Post #4 (see below). One per week is the cadence.
### Week 2 — Post #5
- "What 'integrating with a 3PL' actually means in practice"
- Long-form, technical-but-readable. Walks through the actual data
flows (orders out, ASNs in, inventory snapshots, rate quotes).
- Target reader: ops director at a $5M$50M DTC brand who's about to
switch fulfillment partners.
### Week 3 — Post #6
- "Five questions to ask before signing a Magento maintenance
contract" (or Shopify Plus, or BigCommerce — pick the platform you
see most in your pipeline)
- Format: short, punchy, pitch-adjacent. The kind of thing that gets
shared in operator Slack groups.
### Week 4 — Post #7 + outreach
- "How I structure block-of-time engagements (and why)" — meta-post
about the work, modeled on Aaron & Joel's transparent posts about
their model.
- Outreach: send one personalized note per day (5 total) to someone
in your network who runs a small e-commerce or logistics business.
Not "looking for work" — "I'm publishing again, here's a piece you
might find useful."
### Already-shipped seed posts
These are live in `src/content/blog/`:
1. **"Why I take 'glue work' seriously (and you should too)"** —
manifesto / positioning post. The thing to link from your bio.
2. **"How to hire a senior PHP developer (without getting burned)"** —
the buyer's guide. Long-tail SEO + great share fodder for
founders considering hiring.
3. **"A pre-launch checklist for connecting your store to a 3PL"** —
demonstrates expertise; works as a bookmark/leadmagnet.
---
## Lead-capture levers (cheap & high-leverage)
In rough order of effort vs payoff:
1. **Update LinkedIn headline** to match the home page positioning,
*exactly*. ("PHP for e-commerce + 3PL operations" — not "Senior
PHP Developer".)
2. **Add the Cal.com link to your LinkedIn About section.** Make
booking a call a one-click action from anywhere you exist online.
3. **Update GitHub profile README** with the same one-liner + a link.
4. **Pin the "How to hire a PHP dev" post** to your Twitter/X profile.
5. **Submit the site to relevant directories**:
- Laravel Artisan (if doing Laravel work)
- PHP-FIG members directory (long shot)
- The Codeable / Toptal alternative directories
6. **Newsletter (later, not now).** Once you have 6+ posts, set up a
simple email signup. Don't bother before that — there's nothing to
subscribe *to*.
7. **Speak at one Laravel/PHP meetup or conference in the next 6
months.** Even a 10-minute lightning talk on a real war story from
the Pritikin or Americold work. Conference speaking is the single
highest-leverage move for moving from $75 → $150/hr.
---
## How to talk about the rate
When the call gets to "what do you charge":
- **Don't lead with the number.** Lead with the engagement shape.
("For projects like what you're describing, I usually structure it
as a fixed-price sprint after a paid discovery week. The discovery
is $X; the sprint is quoted from there.")
- **Anchor on outcomes.** ("The work usually pays for itself within Y
months because [specific operational savings].")
- **State the hourly with a context, not a defense.** ("My hourly is
$150 — you're paying for fifteen years of e-commerce/3PL work and
the fact that I'm reading your codebase, not directing a junior.")
- **Have a "no" ready.** ("If your budget is closer to $X, I can
recommend two people who do excellent work in that range — happy
to introduce you.")
The faster you can say "no" cleanly, the easier "yes" gets.
---
## What I won't put on the site
- **Stock photos of "developers."** Nothing erodes a freelance brand
faster.
- **Lorem ipsum.** None of it. Real copy or no copy.
- **A "process" page with seven generic steps.** Every consultancy
has one. They convert nobody. The case studies do this job better.
- **Pricing tiers in dollars** — yet. Not until you're consistently
closing at $150/hr.
---
---
## What's wired for SEO / LLM discoverability
- **JSON-LD structured data** on every important page:
- Home → `Organization`
- About → `Person` (with `knowsLanguage: [English, German]` and
`knowsAbout` listing your tech stack)
- Services → multiple `Service` objects, one per engagement option,
with the current sale prices
- Each blog post → `Article` + `BreadcrumbList`
- Each case study → `Article` + `BreadcrumbList`
- **`/sitemap.xml`** — auto-generated from the route list and the
blog/work registries (`src/app/sitemap.ts`)
- **`/robots.txt`** — allows everything except `/thank-you`; points at
the sitemap (`src/app/robots.ts`)
- **`/llms.txt`** at site root (`new-site/public/llms.txt`) — the
emerging convention for telling LLM crawlers what to index. Update
this when you add or rename pages.
- **OG metadata** — title/description templates set in root layout.
Add a `public/og-image.png` (1200×630) to make it visually richer.
What you should still do once live:
1. Submit sitemap to Google Search Console + Bing Webmaster Tools.
2. Run any new page through Google's Rich Results Test
(`https://search.google.com/test/rich-results`) to confirm the
JSON-LD parses cleanly.
3. Re-check `/llms.txt` quarterly as you publish — it doesn't
auto-update.
## Guarantees
Four are wired and visible on Home (full section) and Services
(compact strip):
1. 30-day money back on first month
2. 30-day post-handoff bug-fix
3. One-business-day response
4. You own everything (full IP transfer, no subcontracting)
Edit them in `new-site/src/components/guarantees.tsx`.
## "How I work" principles
Five principles render on the About page (`HowIWork` component).
Edit in `new-site/src/components/how-i-work.tsx`.
---
*Last updated: 2026-04-30.*

View File

@@ -0,0 +1,18 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
]);
export default eslintConfig;

View File

@@ -0,0 +1,5 @@
import type { MDXComponents } from "mdx/types";
export function useMDXComponents(components: MDXComponents): MDXComponents {
return { ...components };
}

18
new-site/next.config.ts Normal file
View File

@@ -0,0 +1,18 @@
import type { NextConfig } from "next";
import createMDX from "@next/mdx";
import path from "node:path";
const nextConfig: NextConfig = {
pageExtensions: ["ts", "tsx", "md", "mdx"],
turbopack: {
root: path.resolve(__dirname),
},
};
const withMDX = createMDX({
options: {
remarkPlugins: ["remark-gfm"],
},
});
export default withMDX(nextConfig);

33
new-site/package.json Normal file
View File

@@ -0,0 +1,33 @@
{
"name": "new-site",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint"
},
"dependencies": {
"@mdx-js/loader": "^3.1.1",
"@mdx-js/react": "^3.1.1",
"@next/mdx": "^16.2.4",
"@types/mdx": "^2.0.13",
"gray-matter": "^4.0.3",
"next": "16.2.4",
"react": "19.2.4",
"react-dom": "19.2.4",
"remark-gfm": "^4.0.1"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@tailwindcss/typography": "^0.5.19",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "16.2.4",
"tailwindcss": "^4",
"typescript": "^5"
}
}

5377
new-site/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,3 @@
ignoredBuiltDependencies:
- sharp
- unrs-resolver

View File

@@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;

View File

@@ -0,0 +1,96 @@
import { Button } from "@/components/button";
import { Container } from "@/components/container";
import { HowIWork } from "@/components/how-i-work";
import { JsonLd } from "@/components/jsonld";
import { Eyebrow } from "@/components/section-heading";
import { personSchema } from "@/lib/jsonld";
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "About",
description:
"Chris Smith — solo PHP developer running Five Devs, LLC out of Henderson, NV. Fifteen years of shipping the unglamorous parts of e-commerce and logistics.",
};
export default function AboutPage() {
return (
<article className="py-20 sm:py-28">
<JsonLd data={personSchema()} />
<Container width="narrow">
<Eyebrow>About</Eyebrow>
<h1 className="mt-6 font-serif text-4xl font-semibold leading-tight tracking-tight text-ink sm:text-6xl">
Hi, I&rsquo;m Chris.
</h1>
<div className="prose prose-lg mt-10 max-w-none text-ink-soft">
<p className="text-xl leading-relaxed">
I&rsquo;ve been writing PHP since 2010 and running{" "}
<strong className="text-ink">Five Devs, LLC</strong> for years
out of Henderson, Nevada. Before Five Devs I ran CGSmith LLC,
doing more or less the same work under a different name.
</p>
<p>
Most of my work lives in the same place: the messy seam between
an online store, a warehouse, and a back office. Order parsing.
EDI translations. Shipping rates and labels. Inventory sync.
Carrier integrations. The kind of code that, when it works, you
forget exists &mdash; and when it doesn&rsquo;t, you
can&rsquo;t ship a single box.
</p>
<p>
I&rsquo;ve worked with companies like{" "}
<strong className="text-ink">Pritikin Foods</strong>,{" "}
<strong className="text-ink">Americold</strong>,{" "}
<strong className="text-ink">Fortune Fulfillment</strong>, and{" "}
<strong className="text-ink">HD Financial</strong>. Some of
those engagements have run for years. That&rsquo;s the part of
this work I take real pride in &mdash; not the one-off fires,
but being someone a small team can rely on for the long haul.
</p>
<h2 className="mt-12 font-serif text-2xl font-semibold text-ink">
How I work
</h2>
<HowIWork />
<h2 className="mt-12 font-serif text-2xl font-semibold text-ink">
Tools I reach for
</h2>
<p>
PHP (Laravel, Symfony, plain ol&rsquo; PHP), MySQL/Postgres,
Redis, queue workers (Horizon, Supervisor), Filament for
internal admin, and whatever JS makes the page render. On the
ops side: Linux, Docker, GitHub Actions, AWS, Cloudflare. I
care more about the shape of the system than the brand on the
box.
</p>
<h2 className="mt-12 font-serif text-2xl font-semibold text-ink">
Sending a letter?
</h2>
<p>Sure. No fax.</p>
<pre className="overflow-x-auto rounded-xl border border-line/80 bg-cream-soft p-5 font-mono text-sm text-ink-soft">{`Five Devs, LLC
1887 Whitney Mesa Dr. PMB 7325
Henderson, NV 89014
USA`}</pre>
<p className="mt-10 text-sm italic text-muted">
Auch auf Deutsch verfügbar &mdash; schreiben Sie mir gern auf
Deutsch.
</p>
</div>
<div className="mt-14 flex flex-wrap items-center gap-4">
<Button href="/contact" variant="primary">
Book a call
</Button>
<Button href="/work" variant="secondary">
See some work
</Button>
</div>
</Container>
</article>
);
}

View File

@@ -0,0 +1,112 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import { Button } from "@/components/button";
import { Container } from "@/components/container";
import { JsonLd } from "@/components/jsonld";
import { Eyebrow } from "@/components/section-heading";
import { articleSchema, breadcrumbSchema } from "@/lib/jsonld";
import { formatDate, getAllSlugs, getPost } from "@/lib/posts";
import type { Metadata } from "next";
type Params = Promise<{ slug: string }>;
export async function generateStaticParams() {
return getAllSlugs().map((slug) => ({ slug }));
}
export const dynamicParams = false;
export async function generateMetadata({
params,
}: {
params: Params;
}): Promise<Metadata> {
const { slug } = await params;
const post = getPost(slug);
if (!post) return {};
return {
title: post.metadata.title,
description: post.metadata.description,
openGraph: {
title: post.metadata.title,
description: post.metadata.description,
type: "article",
publishedTime: post.metadata.date,
},
};
}
export default async function BlogPostPage({
params,
}: {
params: Params;
}) {
const { slug } = await params;
const post = getPost(slug);
if (!post) notFound();
const { Content, metadata } = post;
return (
<article className="py-20 sm:py-28">
<JsonLd
data={articleSchema({
title: metadata.title,
description: metadata.description,
slug,
date: metadata.date,
basePath: "blog",
})}
/>
<JsonLd
data={breadcrumbSchema([
{ name: "Home", path: "/" },
{ name: "Writing", path: "/blog" },
{ name: metadata.title, path: `/blog/${slug}` },
])}
/>
<Container width="narrow">
<Link
href="/blog"
className="text-sm text-muted hover:text-ink"
>
&larr; All writing
</Link>
<Eyebrow className="mt-8">Writing</Eyebrow>
<h1 className="mt-4 font-serif text-4xl font-semibold leading-tight tracking-tight text-ink sm:text-5xl">
{metadata.title}
</h1>
<div className="mt-5 flex flex-wrap items-baseline gap-x-4 gap-y-1 text-sm text-muted">
<time>{formatDate(metadata.date)}</time>
<span>&middot;</span>
<span>{metadata.readingMinutes} min read</span>
{metadata.tags?.length ? (
<>
<span>&middot;</span>
<span>{metadata.tags.join(", ")}</span>
</>
) : null}
</div>
<div className="prose prose-lg mt-12 max-w-none text-ink-soft prose-headings:font-serif prose-headings:text-ink prose-headings:font-semibold prose-h2:mt-12 prose-h2:text-2xl prose-h3:text-xl prose-strong:text-ink prose-blockquote:border-accent prose-blockquote:text-ink prose-a:text-accent prose-a:no-underline hover:prose-a:underline">
<Content />
</div>
<hr className="my-16 border-line/70" />
<div className="flex flex-col items-start gap-6 rounded-2xl border border-line/80 bg-cream-soft p-8 sm:flex-row sm:items-center sm:justify-between">
<div className="max-w-md">
<h3 className="font-serif text-xl font-semibold text-ink">
Need a senior PHP developer in your corner?
</h3>
<p className="mt-2 text-ink-soft">
Five Devs is me, Chris. Twenty minutes, no pitch deck.
</p>
</div>
<Button href="/contact" variant="primary">
Book a call
</Button>
</div>
</Container>
</article>
);
}

View File

@@ -0,0 +1,52 @@
import Link from "next/link";
import { Container } from "@/components/container";
import { Eyebrow } from "@/components/section-heading";
import { posts, formatDate } from "@/lib/posts";
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Writing",
description:
"Notes on PHP, e-commerce integrations, freelancing, and the unglamorous parts of running a small software business.",
};
export default function BlogIndex() {
return (
<section className="py-20 sm:py-28">
<Container width="narrow">
<Eyebrow>Writing</Eyebrow>
<h1 className="mt-6 font-serif text-4xl font-semibold leading-tight tracking-tight text-ink sm:text-6xl">
Field notes from the glue layer.
</h1>
<p className="mt-7 max-w-xl text-lg leading-relaxed text-ink-soft">
Mostly about PHP, e-commerce, 3PL integrations, and the
decisions that keep small software businesses from getting
stuck.
</p>
<ul className="mt-16 divide-y divide-line/70 border-y border-line/70">
{posts.map((p) => (
<li key={p.slug}>
<Link
href={`/blog/${p.slug}`}
className="group block py-9 transition-colors hover:bg-cream-soft"
>
<div className="flex flex-wrap items-baseline gap-x-4 gap-y-1 text-sm text-muted">
<time>{formatDate(p.metadata.date)}</time>
<span>&middot;</span>
<span>{p.metadata.readingMinutes} min read</span>
</div>
<h2 className="mt-2 font-serif text-2xl font-semibold leading-tight text-ink group-hover:text-accent">
{p.metadata.title}
</h2>
<p className="mt-3 text-ink-soft">
{p.metadata.description}
</p>
</Link>
</li>
))}
</ul>
</Container>
</section>
);
}

View File

@@ -0,0 +1,95 @@
import { Button } from "@/components/button";
import { Container } from "@/components/container";
import { ContactForm } from "@/components/contact-form";
import { Eyebrow } from "@/components/section-heading";
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Contact",
description:
"Three ways to reach Five Devs: book a 20-minute intro call, send a quick note, or email Chris directly.",
};
const calLink = "https://cal.com/cgsmith/intro";
export default function ContactPage() {
return (
<section className="py-20 sm:py-28">
<Container>
<div className="grid gap-16 lg:grid-cols-[1.1fr_1fr]">
{/* Left: pitch */}
<div>
<Eyebrow>Get in touch</Eyebrow>
<h1 className="mt-6 font-serif text-4xl font-semibold leading-tight tracking-tight text-ink sm:text-5xl">
Tell me what&rsquo;s on the back burner.
</h1>
<p className="mt-7 max-w-md text-lg leading-relaxed text-ink-soft">
The fastest way to find out if I can help is a 20-minute
call. No pitch deck, no SDR email sequence &mdash; just a
real conversation about what you&rsquo;re trying to do.
</p>
<div className="mt-10 space-y-6">
<div className="rounded-2xl border border-ink bg-ink p-7 text-cream">
<p className="font-mono text-xs uppercase tracking-[0.18em] text-cream/60">
Recommended
</p>
<h2 className="mt-2 font-serif text-2xl font-semibold">
Book a 20-min intro call
</h2>
<p className="mt-2 text-cream/80">
Pick a time that works for you. I&rsquo;ll come ready
with questions.
</p>
<Button
href={calLink}
variant="secondary"
className="mt-5 border-cream/30 text-cream hover:border-cream hover:bg-cream/10"
>
Open my calendar &rarr;
</Button>
</div>
<div className="rounded-2xl border border-line/80 bg-cream-soft p-7">
<h2 className="font-serif text-xl font-semibold text-ink">
Prefer email?
</h2>
<p className="mt-2 text-ink-soft">
No problem.{" "}
<a
href="mailto:chris@fivedevs.com"
className="font-medium text-ink underline underline-offset-4"
>
chris@fivedevs.com
</a>{" "}
goes straight to me.
</p>
</div>
<div className="text-sm text-muted">
<p>
<span className="font-medium text-ink">Mailing address:</span>{" "}
Five Devs, LLC &middot; 1887 Whitney Mesa Dr. PMB 7325
&middot; Henderson, NV 89014 &middot; USA
</p>
</div>
</div>
</div>
{/* Right: form */}
<div className="rounded-2xl border border-line/80 bg-cream-soft p-8 sm:p-10">
<h2 className="font-serif text-2xl font-semibold text-ink">
Or send a quick note
</h2>
<p className="mt-2 text-sm text-muted">
I read every message and reply within one business day.
</p>
<div className="mt-8">
<ContactForm />
</div>
</div>
</div>
</Container>
</section>
);
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

View File

@@ -0,0 +1,141 @@
@import "tailwindcss";
@plugin "@tailwindcss/typography";
@theme {
--color-cream: #f6f1e8;
--color-cream-soft: #faf6ee;
--color-ink: #1a1815;
--color-ink-soft: #3a352e;
--color-muted: #76706a;
--color-line: #e7e2d8;
--color-accent: #b45309;
--color-accent-soft: #fef3c7;
--font-serif: "Fraunces", ui-serif, Georgia, "Times New Roman", serif;
--font-sans: "Inter", ui-sans-serif, system-ui, -apple-system, "Segoe UI",
Helvetica, Arial, sans-serif;
--font-mono: ui-monospace, "JetBrains Mono", "SF Mono", Menlo, Consolas,
monospace;
}
html {
background: var(--color-cream);
color: var(--color-ink);
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
}
body {
font-family: var(--font-sans);
font-feature-settings: "ss01", "cv11";
}
::selection {
background: var(--color-accent);
color: var(--color-cream-soft);
}
/* Hero ambient glow — a soft warm radial that gently drifts. */
.hero-glow {
position: absolute;
pointer-events: none;
inset: 0;
overflow: hidden;
z-index: 0;
}
.hero-glow::before,
.hero-glow::after {
content: "";
position: absolute;
border-radius: 9999px;
filter: blur(90px);
opacity: 0.55;
will-change: transform;
}
.hero-glow::before {
width: 520px;
height: 520px;
top: -120px;
right: -80px;
background: radial-gradient(
closest-side,
color-mix(in oklab, var(--color-accent) 45%, transparent),
transparent 70%
);
animation: hero-drift-a 18s ease-in-out infinite alternate;
}
.hero-glow::after {
width: 420px;
height: 420px;
top: 60px;
left: -120px;
background: radial-gradient(
closest-side,
color-mix(in oklab, #d97706 35%, transparent),
transparent 70%
);
opacity: 0.35;
animation: hero-drift-b 22s ease-in-out infinite alternate;
}
@keyframes hero-drift-a {
0% {
transform: translate3d(0, 0, 0) scale(1);
}
100% {
transform: translate3d(-40px, 30px, 0) scale(1.05);
}
}
@keyframes hero-drift-b {
0% {
transform: translate3d(0, 0, 0) scale(1);
}
100% {
transform: translate3d(30px, -20px, 0) scale(1.08);
}
}
/* Drawn-in underline beneath accent words. */
.accent-highlight {
position: relative;
white-space: nowrap;
}
.accent-highlight::after {
content: "";
position: absolute;
left: 0;
right: 0;
bottom: 0.05em;
height: 0.16em;
background: color-mix(in oklab, var(--color-accent) 35%, transparent);
border-radius: 2px;
transform-origin: left center;
transform: scaleX(0);
animation: highlight-grow 0.9s cubic-bezier(0.65, 0, 0.35, 1) 0.35s forwards;
}
.accent-highlight:nth-of-type(2)::after {
animation-delay: 0.6s;
}
@keyframes highlight-grow {
to {
transform: scaleX(1);
}
}
@media (prefers-reduced-motion: reduce) {
.hero-glow::before,
.hero-glow::after {
animation: none;
}
.accent-highlight::after {
animation: none;
transform: scaleX(1);
}
}

View File

@@ -0,0 +1,60 @@
import type { Metadata } from "next";
import { Fraunces, Inter } from "next/font/google";
import "./globals.css";
import { SiteHeader } from "@/components/site-header";
import { SiteFooter } from "@/components/site-footer";
const inter = Inter({
variable: "--font-inter",
subsets: ["latin"],
display: "swap",
});
const fraunces = Fraunces({
variable: "--font-fraunces",
subsets: ["latin"],
display: "swap",
axes: ["opsz", "SOFT"],
});
export const metadata: Metadata = {
title: {
default: "Five Devs — PHP for E-commerce & 3PL Operations",
template: "%s · Five Devs",
},
description:
"Five Devs is Chris Smith — a senior PHP developer for the unglamorous, mission-critical glue between your store, your warehouse, and your books. 15 years of shipping it.",
metadataBase: new URL("https://fivedevs.com"),
openGraph: {
title: "Five Devs — PHP for E-commerce & 3PL Operations",
description:
"Senior PHP help for the unglamorous, mission-critical glue between your store, your warehouse, and your books.",
url: "https://fivedevs.com",
siteName: "Five Devs",
type: "website",
},
};
export default function RootLayout({
children,
}: Readonly<{ children: React.ReactNode }>) {
return (
<html
lang="en"
className={`${inter.variable} ${fraunces.variable} h-full antialiased`}
style={
{
"--font-sans": "var(--font-inter), ui-sans-serif, system-ui",
"--font-serif":
"var(--font-fraunces), ui-serif, Georgia, serif",
} as React.CSSProperties
}
>
<body className="min-h-full flex flex-col bg-cream text-ink">
<SiteHeader />
<main className="flex-1">{children}</main>
<SiteFooter />
</body>
</html>
);
}

140
new-site/src/app/page.tsx Normal file
View File

@@ -0,0 +1,140 @@
import Link from "next/link";
import { Button } from "@/components/button";
import { ClientLogos } from "@/components/client-logos";
import { Container } from "@/components/container";
import { Guarantees } from "@/components/guarantees";
import { JsonLd } from "@/components/jsonld";
import { PricingTeaser } from "@/components/pricing-teaser";
import { Eyebrow, SectionHeading } from "@/components/section-heading";
import { Testimonials } from "@/components/testimonials";
import { organizationSchema } from "@/lib/jsonld";
const services = [
{
title: "E-commerce + 3PL integrations",
body:
"Order import, inventory sync, label generation, EDI bridges, the parts of your stack nobody wants to touch. Built right, kept boring.",
},
{
title: "Legacy PHP rescue",
body:
"Old Magento, custom CodeIgniter, Symfony 4 stuck on EOL — I read the code, write tests, and ship the upgrade without a six-month rewrite project.",
},
{
title: "On-call ops + tech support",
body:
"Quiet, fast help when production breaks at 4pm on a Tuesday. Returned phone calls, root-caused bugs, post-mortems your team can use.",
},
{
title: "Greenfield internal apps",
body:
"From idea to running tool in weeks: Laravel, Filament, Postgres, deployed where your team already operates.",
},
];
export default function HomePage() {
return (
<>
<JsonLd data={organizationSchema()} />
{/* Hero */}
<section className="relative overflow-hidden pt-20 pb-24 sm:pt-28 sm:pb-32">
<div className="hero-glow" aria-hidden="true" />
<Container className="relative z-10">
<div className="max-w-3xl">
<Eyebrow>Five Devs &middot; PHP since 2010</Eyebrow>
<h1 className="mt-6 font-serif text-4xl font-semibold leading-[1.05] tracking-tight text-ink sm:text-6xl">
The PHP help your{" "}
<span className="accent-highlight text-accent">e-commerce</span>{" "}
and{" "}
<span className="accent-highlight text-accent">3PL</span> stack
actually needs.
</h1>
<p className="mt-7 max-w-xl text-lg leading-relaxed text-ink-soft">
I&rsquo;m Chris Smith. I&rsquo;ve spent fifteen years building
the unglamorous, mission-critical glue between online stores,
warehouses, and back offices &mdash; and quietly keeping it
running for companies that depend on it.
</p>
<div className="mt-9 flex flex-wrap items-center gap-4">
<Button href="/contact" variant="primary">
Book an intro call &rarr;
</Button>
<Button href="/work" variant="secondary">
See the work
</Button>
</div>
</div>
</Container>
{/* Logos strip */}
<Container className="mt-20 sm:mt-28">
<ClientLogos />
</Container>
</section>
{/* Services */}
<section className="py-24 sm:py-32">
<Container>
<SectionHeading
eyebrow="What I do"
title="Four things, done well, on a senior level."
subtitle="No agency overhead. No bait-and-switch to junior devs. You hire me, you get me &mdash; and the years that come with it."
/>
<ul className="mt-14 grid gap-x-10 gap-y-12 sm:grid-cols-2">
{services.map((s, i) => (
<li key={s.title} className="space-y-3">
<p className="font-mono text-sm text-accent">
0{i + 1}
</p>
<h3 className="font-serif text-xl font-semibold text-ink">
{s.title}
</h3>
<p className="leading-relaxed text-ink-soft">{s.body}</p>
</li>
))}
</ul>
<div className="mt-12">
<Link
href="/services"
className="text-sm font-medium text-ink underline-offset-4 hover:underline"
>
See engagement options &rarr;
</Link>
</div>
</Container>
</section>
<PricingTeaser />
<Testimonials />
<Guarantees />
{/* CTA */}
<section className="py-24 sm:py-32">
<Container width="narrow" className="text-center">
<Eyebrow>Let&rsquo;s talk</Eyebrow>
<h2 className="mt-6 font-serif text-3xl font-semibold tracking-tight text-ink sm:text-5xl">
Got a PHP project that&rsquo;s been on the back burner?
</h2>
<p className="mx-auto mt-6 max-w-xl text-lg leading-relaxed text-ink-soft">
Twenty minutes, no pitch deck. Tell me what&rsquo;s broken or
stuck and I&rsquo;ll tell you whether I can help &mdash; and what
it would take.
</p>
<div className="mt-9 flex flex-wrap items-center justify-center gap-4">
<Button href="/contact" variant="primary">
Book a call
</Button>
<Button
href="mailto:chris@fivedevs.com"
variant="ghost"
>
chris@fivedevs.com
</Button>
</div>
</Container>
</section>
</>
);
}

View File

@@ -0,0 +1,16 @@
import type { MetadataRoute } from "next";
import { SITE_URL } from "@/lib/site";
export default function robots(): MetadataRoute.Robots {
return {
rules: [
{
userAgent: "*",
allow: "/",
disallow: ["/thank-you"],
},
],
sitemap: `${SITE_URL}/sitemap.xml`,
host: SITE_URL,
};
}

View File

@@ -0,0 +1,373 @@
import { Button } from "@/components/button";
import { Container } from "@/components/container";
import { GuaranteesMini } from "@/components/guarantees";
import { JsonLd } from "@/components/jsonld";
import { Eyebrow, SectionHeading } from "@/components/section-heading";
import { serviceSchema } from "@/lib/jsonld";
import {
blocks,
formatPrice,
retainer,
saleActive,
saleLabel,
type CheckoutOption,
} from "@/lib/pricing";
import { SITE_URL } from "@/lib/site";
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Services & Engagements",
description:
"Three ways to work with Five Devs: a one-shot project, a monthly retainer for ongoing help, or a block of senior engineering time you can spend as you go.",
};
function PriceLine({
option,
perSuffix,
invert,
size = "lg",
}: {
option: CheckoutOption;
perSuffix?: string;
invert?: boolean;
size?: "lg" | "md";
}) {
const muted = invert ? "text-cream/60" : "text-muted";
const strong = invert ? "text-cream" : "text-ink";
const showSale = option.salePrice !== undefined;
const priceClass =
size === "lg"
? "text-3xl font-semibold tracking-tight"
: "text-2xl font-semibold tracking-tight";
const strikeClass = size === "lg" ? "text-lg line-through" : "text-base line-through";
return (
<div className="space-y-1">
<div className="flex items-baseline gap-3">
{showSale ? (
<>
<span className={`${priceClass} ${strong}`}>
{formatPrice(option.salePrice!)}
{perSuffix ? (
<span className={`text-base font-normal ${muted}`}>
{perSuffix}
</span>
) : null}
</span>
<span className={`${strikeClass} ${muted}`}>
{formatPrice(option.regularPrice)}
</span>
</>
) : (
<span className={`${priceClass} ${strong}`}>
{formatPrice(option.regularPrice)}
{perSuffix ? (
<span className={`text-base font-normal ${muted}`}>
{perSuffix}
</span>
) : null}
</span>
)}
</div>
<p className={`text-sm ${muted}`}>
{showSale && option.saleEffectiveHourly !== undefined
? `${formatPrice(option.saleEffectiveHourly)}/hr effective`
: `${formatPrice(option.effectiveHourly)}/hr effective`}
</p>
</div>
);
}
function BlockRow({ option }: { option: CheckoutOption }) {
const wired = !!option.buyHref;
const inner = (
<div className="flex flex-col gap-2 py-5 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-baseline gap-4">
<p className="font-medium text-ink">{option.label}</p>
{wired ? (
<span
aria-hidden="true"
className="font-mono text-base text-muted opacity-0 transition-all group-hover:translate-x-1 group-hover:text-accent group-hover:opacity-100"
>
</span>
) : null}
</div>
<PriceLine option={option} size="md" />
</div>
);
if (!wired) return <li className="px-1">{inner}</li>;
return (
<li>
<a
href={option.buyHref}
className="group block -mx-3 rounded-lg px-3 transition-colors hover:bg-cream"
aria-label={`Buy ${option.label}`}
>
{inner}
</a>
</li>
);
}
function SaleBadge({ invert }: { invert?: boolean }) {
if (!saleActive) return null;
return (
<span
className={`inline-flex items-center rounded-full px-3 py-1 text-xs font-medium uppercase tracking-wider ${
invert
? "bg-cream/10 text-cream ring-1 ring-cream/30"
: "bg-accent/10 text-accent ring-1 ring-accent/30"
}`}
>
{saleLabel}
</span>
);
}
const servicesUrl = `${SITE_URL}/services`;
const serviceSchemas = [
serviceSchema({
name: "Monthly PHP development retainer",
description:
"Reserved senior PHP development hours each month. Ideal for teams without a senior PHP developer in-house.",
url: servicesUrl,
price: retainer.salePrice ?? retainer.regularPrice,
priceType: "Subscription",
}),
...blocks.map((b) =>
serviceSchema({
name: `${b.label} of senior PHP development time`,
description: `Block of ${b.label.replace("-hour block", " hours")} of senior PHP development time, usable within 6 months.`,
url: servicesUrl,
price: b.salePrice ?? b.regularPrice,
priceType: "OneTime",
}),
),
serviceSchema({
name: "Project sprint",
description:
"Fixed-scope, fixed-price PHP development engagement quoted after a paid discovery sprint.",
url: servicesUrl,
}),
];
export default function ServicesPage() {
const retainerWired = !!retainer.buyHref;
return (
<>
{serviceSchemas.map((schema, i) => (
<JsonLd key={i} data={schema} />
))}
<section className="py-20 sm:py-28">
<Container>
<div className="max-w-2xl">
<Eyebrow>Services</Eyebrow>
<h1 className="mt-6 font-serif text-4xl font-semibold leading-tight tracking-tight text-ink sm:text-6xl">
Three ways to work together.
</h1>
<p className="mt-7 text-lg leading-relaxed text-ink-soft">
Most freelance engagements fall into one of three shapes.
Pick the one that matches how your work actually arrives.
Block and retainer engagements are{" "}
<span className="font-medium text-ink">self-serve</span>{" "}
&mdash; check out below, or book a call first if
you&rsquo;d rather talk.
</p>
{saleActive ? (
<div className="mt-8 flex flex-wrap items-center gap-3">
<SaleBadge />
<p className="text-sm text-ink-soft">
Half off retainer and block engagements while the
redesign is fresh. First clients lock it in.
</p>
</div>
) : null}
</div>
</Container>
</section>
<section className="pb-24 sm:pb-32">
<Container>
<ul className="grid gap-6 lg:grid-cols-3">
{/* Project sprint — no checkout */}
<li className="flex flex-col rounded-2xl border border-line/80 bg-cream-soft p-8">
<h2 className="font-serif text-2xl font-semibold text-ink">
Project sprint
</h2>
<p className="mt-3 text-lg leading-snug text-ink-soft">
A defined piece of work, scoped, quoted, shipped.
</p>
<div className="mt-7 space-y-1">
<p className="text-3xl font-semibold tracking-tight text-ink">
Custom quote
</p>
<p className="text-sm text-muted">
Fixed-price after a paid discovery
</p>
</div>
<ul className="mt-7 space-y-3 text-sm leading-relaxed text-ink-soft">
{[
"Two weeks to two months",
"Daily progress in shared Slack or GitHub",
"Documented handoff at the end",
].map((b) => (
<li key={b} className="flex gap-3">
<span className="mt-2 inline-block h-1 w-1 flex-none rounded-full bg-accent" />
<span>{b}</span>
</li>
))}
</ul>
<p className="mt-7 text-sm italic text-muted">
Best when you know exactly what you need built or fixed.
</p>
<div className="mt-auto pt-7">
<Button href="/contact" variant="primary">
Discuss a project &rarr;
</Button>
</div>
</li>
{/* Monthly retainer — featured, with checkout */}
<li className="flex flex-col rounded-2xl border border-ink bg-ink p-8 text-cream">
<div className="flex flex-wrap items-center gap-3">
<h2 className="font-serif text-2xl font-semibold text-cream">
Monthly retainer
</h2>
<SaleBadge invert />
</div>
<p className="mt-3 text-lg leading-snug text-cream/80">
Quiet, predictable senior help on the long tail.
</p>
<div className="mt-7">
<PriceLine option={retainer} perSuffix="/mo" invert />
<p className="mt-2 text-sm text-cream/70">
{retainer.hoursLabel}
</p>
</div>
<ul className="mt-7 space-y-3 text-sm leading-relaxed text-cream/85">
{[
"Reserved hours each month",
"Priority on incidents and requests",
"Quarterly roadmap & tech-health check-ins",
"Pause or cancel with 30 days notice",
].map((b) => (
<li key={b} className="flex gap-3">
<span className="mt-2 inline-block h-1 w-1 flex-none rounded-full bg-cream/70" />
<span>{b}</span>
</li>
))}
</ul>
<p className="mt-7 text-sm italic text-cream/70">
Best for teams without a senior PHP developer in-house.
</p>
<div className="mt-auto space-y-3 pt-7">
<Button
href={retainer.buyHref ?? "/contact"}
variant="secondary"
className="w-full justify-center border-cream/30 text-cream hover:border-cream hover:bg-cream/10"
>
{retainerWired
? "Subscribe — $2,500/mo →"
: "Talk first →"}
</Button>
<Button
href="/contact"
variant="ghost"
className="w-full justify-center text-cream/70 hover:text-cream"
>
Or book a call first
</Button>
</div>
</li>
{/* Block of time — selectable rows */}
<li className="flex flex-col rounded-2xl border border-line/80 bg-cream-soft p-8">
<div className="flex flex-wrap items-center gap-3">
<h2 className="font-serif text-2xl font-semibold text-ink">
Block of time
</h2>
<SaleBadge />
</div>
<p className="mt-3 text-lg leading-snug text-ink-soft">
Buy a block, spend it as needs come up.
</p>
<ul className="mt-7 divide-y divide-line/80 border-y border-line/80">
{blocks.map((b) => (
<BlockRow key={b.id} option={b} />
))}
</ul>
<p className="mt-7 text-sm italic text-muted">
Best for work that comes in bursts &mdash; integrations,
migrations, audits.
</p>
<div className="mt-auto pt-7">
<Button
href="/contact"
variant="secondary"
className="w-full justify-center"
>
Or book a call first
</Button>
</div>
</li>
</ul>
<p className="mt-10 max-w-2xl text-sm text-muted">
All purchases trigger a confirmation email and a link to
book your kickoff call. You&rsquo;ll hear from me within
one business day.
</p>
<div className="mt-12">
<GuaranteesMini />
</div>
</Container>
</section>
<section className="border-t border-line/70 bg-cream-soft py-24 sm:py-32">
<Container width="narrow">
<SectionHeading
eyebrow="A note on rates"
title="Senior, transparent, and worth it."
subtitle="Rates anchor in the senior end of the market. Retainer and block rates are lower per hour because I can plan around them."
/>
<div className="mt-12 space-y-6 text-ink-soft">
<p>
Project sprints are quoted as a fixed price after a short
paid discovery so we both know what we&rsquo;re
committing to. Retainer and block prices are listed above
and you can check out directly &mdash; no email
ping-pong.
</p>
<p>
I do not subcontract or hand work off to junior
developers without telling you. If a project genuinely
needs a second pair of hands, I&rsquo;ll tell you who and
why before they touch your code.
</p>
</div>
<div className="mt-12 flex flex-wrap items-center gap-4">
<Button href="/contact" variant="primary">
Book a call
</Button>
<Button href="mailto:chris@fivedevs.com" variant="ghost">
chris@fivedevs.com
</Button>
</div>
</Container>
</section>
</>
);
}

View File

@@ -0,0 +1,33 @@
import type { MetadataRoute } from "next";
import { SITE_URL } from "@/lib/site";
import { posts } from "@/lib/posts";
import { caseStudies } from "@/lib/case-studies";
export default function sitemap(): MetadataRoute.Sitemap {
const now = new Date();
const staticRoutes: MetadataRoute.Sitemap = [
{ url: `${SITE_URL}/`, lastModified: now, priority: 1.0, changeFrequency: "monthly" },
{ url: `${SITE_URL}/services`, lastModified: now, priority: 0.9, changeFrequency: "monthly" },
{ url: `${SITE_URL}/work`, lastModified: now, priority: 0.8, changeFrequency: "monthly" },
{ url: `${SITE_URL}/about`, lastModified: now, priority: 0.7, changeFrequency: "yearly" },
{ url: `${SITE_URL}/contact`, lastModified: now, priority: 0.7, changeFrequency: "yearly" },
{ url: `${SITE_URL}/blog`, lastModified: now, priority: 0.7, changeFrequency: "weekly" },
];
const blogRoutes: MetadataRoute.Sitemap = posts.map((p) => ({
url: `${SITE_URL}/blog/${p.slug}`,
lastModified: new Date(p.metadata.date),
priority: 0.6,
changeFrequency: "yearly",
}));
const workRoutes: MetadataRoute.Sitemap = caseStudies.map((c) => ({
url: `${SITE_URL}/work/${c.slug}`,
lastModified: now,
priority: 0.6,
changeFrequency: "yearly",
}));
return [...staticRoutes, ...blogRoutes, ...workRoutes];
}

View File

@@ -0,0 +1,107 @@
import { Button } from "@/components/button";
import { Container } from "@/components/container";
import { Eyebrow } from "@/components/section-heading";
import type { Metadata } from "next";
const calLink = "https://cal.com/cgsmith/kickoff";
export const metadata: Metadata = {
title: "Thanks — let's get started",
description:
"Your purchase is confirmed. Book your kickoff call and Chris will be in touch within one business day.",
robots: { index: false, follow: false },
};
export default function ThankYouPage() {
return (
<section className="py-24 sm:py-32">
<Container width="narrow">
<Eyebrow>Confirmed</Eyebrow>
<h1 className="mt-6 font-serif text-4xl font-semibold leading-tight tracking-tight text-ink sm:text-5xl">
You&rsquo;re in. Thank you.
</h1>
<p className="mt-7 text-lg leading-relaxed text-ink-soft">
Stripe has the receipt on the way. Here&rsquo;s what happens
next &mdash; in this order.
</p>
<ol className="mt-12 space-y-8">
<li className="flex gap-5">
<span className="flex h-9 w-9 flex-none items-center justify-center rounded-full bg-accent/10 font-mono text-sm font-medium text-accent">
1
</span>
<div>
<h2 className="font-serif text-xl font-semibold text-ink">
Book your kickoff call
</h2>
<p className="mt-2 text-ink-soft">
Twenty minutes. Bring access details, repos, and the
short list of what you want to tackle first. Pick any
slot that works.
</p>
<Button
href={calLink}
variant="primary"
className="mt-5"
>
Open my calendar &rarr;
</Button>
</div>
</li>
<li className="flex gap-5">
<span className="flex h-9 w-9 flex-none items-center justify-center rounded-full bg-accent/10 font-mono text-sm font-medium text-accent">
2
</span>
<div>
<h2 className="font-serif text-xl font-semibold text-ink">
Watch your inbox
</h2>
<p className="mt-2 text-ink-soft">
I&rsquo;ll send a short onboarding note within one
business day &mdash; what to expect, how I track time,
and a one-page mutual NDA if you&rsquo;d like one. If
you don&rsquo;t see anything by tomorrow afternoon,
check spam, then{" "}
<a
href="mailto:chris@fivedevs.com"
className="text-accent underline-offset-4 hover:underline"
>
chris@fivedevs.com
</a>
.
</p>
</div>
</li>
<li className="flex gap-5">
<span className="flex h-9 w-9 flex-none items-center justify-center rounded-full bg-accent/10 font-mono text-sm font-medium text-accent">
3
</span>
<div>
<h2 className="font-serif text-xl font-semibold text-ink">
We get to work
</h2>
<p className="mt-2 text-ink-soft">
After the kickoff, work starts the same week. You
get weekly updates by default; daily if your retainer
or block is in active mode.
</p>
</div>
</li>
</ol>
<hr className="my-16 border-line/70" />
<p className="text-sm text-muted">
Need to reach me before the kickoff?{" "}
<a
href="mailto:chris@fivedevs.com"
className="text-ink underline underline-offset-4"
>
chris@fivedevs.com
</a>
</p>
</Container>
</section>
);
}

View File

@@ -0,0 +1,207 @@
import { notFound } from "next/navigation";
import Link from "next/link";
import { Button } from "@/components/button";
import { Container } from "@/components/container";
import { JsonLd } from "@/components/jsonld";
import { Eyebrow } from "@/components/section-heading";
import { articleSchema, breadcrumbSchema } from "@/lib/jsonld";
import { caseStudies } from "@/lib/case-studies";
import type { Metadata } from "next";
type Params = Promise<{ slug: string }>;
const bodies: Record<string, React.ReactNode> = {
"pritikin-foods": (
<>
<h2>The shape of the problem</h2>
<p>
Pritikin Foods sells direct-to-consumer prepared meals nationwide.
Every order from the e-commerce site has to be parsed, validated,
priced for shipping, and handed off to their 3PL fulfillment
partner &mdash; on time, with the right items, to the right
address, in the right shipping window.
</p>
<p>
That sounds simple. It isn&rsquo;t. There&rsquo;s SKU
normalization, address validation, transit-time math against meal
perishability, special-handling rules, batch hand-offs, and a
whole tail of reconciliation when something doesn&rsquo;t match
what the warehouse actually shipped.
</p>
<h2>What I built and what I maintain</h2>
<ul>
<li>
The order-parsing pipeline that translates e-commerce orders
into the format the 3PL accepts.
</li>
<li>
The shipping and label flow: rate calculation, label
generation, and carrier-side error handling.
</li>
<li>
Reconciliation tooling so customer service can answer
&ldquo;where is my order?&rdquo; in seconds, not minutes.
</li>
<li>
The boring, on-call work of keeping it all running as carriers,
rates, and partners change.
</li>
</ul>
<h2>Why it&rsquo;s been a multi-year relationship</h2>
<p>
This work isn&rsquo;t glamorous, but it&rsquo;s the difference
between Pritikin shipping orders today and not. Emil
Boschert&rsquo;s words say it best:
</p>
<blockquote>
&ldquo;They are vital for our e-commerce site and our 3PL.&rdquo;
</blockquote>
<p>
That&rsquo;s the bar I aim for on every long engagement &mdash;
becoming the kind of help a small team can stop worrying about.
</p>
</>
),
americold: (
<>
<h2>The shape of the problem</h2>
<p>
Americold is one of the largest temperature-controlled warehousing
and logistics networks in the world. At their scale, even
&ldquo;small&rdquo; pieces of operational tooling touch a lot of
people, a lot of pallets, and a lot of carriers.
</p>
<h2>What the engagement looked like</h2>
<p>
Senior-level PHP work on customer-facing logistics tooling.
Solution-oriented, customer-focused, delivered consistently
&mdash; in the words of Americold&rsquo;s Timothy Dorcas:
</p>
<blockquote>
&ldquo;They are solution oriented, customer focused, and
consistently deliver at a high level. Highly recommended.&rdquo;
</blockquote>
<h2>Why this kind of work fits Five Devs</h2>
<p>
Enterprise logistics teams don&rsquo;t need another agency. They
need a senior developer who can read the existing code, talk to
the operations people, ship the change, and write down what they
learned &mdash; without three layers of project management
between them and the work.
</p>
<p>
That&rsquo;s the role I&rsquo;ve played for Americold and others.
I show up, get up to speed quickly on the existing stack, and
keep the work moving.
</p>
</>
),
};
export async function generateStaticParams() {
return caseStudies.map((c) => ({ slug: c.slug }));
}
export const dynamicParams = false;
export async function generateMetadata({
params,
}: {
params: Params;
}): Promise<Metadata> {
const { slug } = await params;
const study = caseStudies.find((c) => c.slug === slug);
if (!study) return {};
return {
title: `${study.client} — Case Study`,
description: study.summary,
};
}
export default async function CaseStudyPage({
params,
}: {
params: Params;
}) {
const { slug } = await params;
const study = caseStudies.find((c) => c.slug === slug);
if (!study) notFound();
const body = bodies[slug];
return (
<article className="py-20 sm:py-28">
<JsonLd
data={articleSchema({
title: `${study.client} — Case Study`,
description: study.summary,
slug,
date: "2026-01-01",
basePath: "work",
})}
/>
<JsonLd
data={breadcrumbSchema([
{ name: "Home", path: "/" },
{ name: "Work", path: "/work" },
{ name: study.client, path: `/work/${slug}` },
])}
/>
<Container width="narrow">
<Link
href="/work"
className="text-sm text-muted hover:text-ink"
>
&larr; All work
</Link>
<Eyebrow className="mt-8">Case study</Eyebrow>
<h1 className="mt-4 font-serif text-4xl font-semibold leading-tight tracking-tight text-ink sm:text-5xl">
{study.client}
</h1>
<p
className="mt-3 text-sm text-muted"
dangerouslySetInnerHTML={{ __html: study.industry }}
/>
<dl className="mt-10 grid grid-cols-2 gap-6 border-y border-line/70 py-6 text-sm sm:grid-cols-3">
<div>
<dt className="text-xs uppercase tracking-wider text-muted">
Role
</dt>
<dd className="mt-1 text-ink">{study.role ?? "Developer"}</dd>
</div>
{study.yearsRunning ? (
<div>
<dt className="text-xs uppercase tracking-wider text-muted">
Duration
</dt>
<dd className="mt-1 text-ink">{study.yearsRunning}</dd>
</div>
) : null}
<div>
<dt className="text-xs uppercase tracking-wider text-muted">
Outcome
</dt>
<dd className="mt-1 text-ink">{study.outcome}</dd>
</div>
</dl>
<div className="prose prose-lg mt-12 max-w-none text-ink-soft prose-headings:font-serif prose-headings:text-ink prose-headings:font-semibold prose-h2:mt-12 prose-h2:text-2xl prose-strong:text-ink prose-blockquote:border-accent prose-blockquote:text-ink">
{body ?? <p>{study.summary}</p>}
</div>
<div className="mt-16 flex flex-wrap items-center gap-4">
<Button href="/contact" variant="primary">
Talk about your project
</Button>
<Button href="/work" variant="secondary">
More work
</Button>
</div>
</Container>
</article>
);
}

View File

@@ -0,0 +1,65 @@
import Link from "next/link";
import { Container } from "@/components/container";
import { Eyebrow } from "@/components/section-heading";
import { caseStudies } from "@/lib/case-studies";
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Work",
description:
"Selected client engagements: e-commerce + 3PL integration work, ongoing ops support, and senior-level PHP development for teams that need it to just work.",
};
export default function WorkPage() {
return (
<section className="py-20 sm:py-28">
<Container>
<Eyebrow>Selected work</Eyebrow>
<h1 className="mt-6 max-w-3xl font-serif text-4xl font-semibold leading-tight tracking-tight text-ink sm:text-6xl">
Companies that quietly depend on Five Devs.
</h1>
<p className="mt-7 max-w-xl text-lg leading-relaxed text-ink-soft">
A few of the engagements I&rsquo;m most proud of. These are the
relationships that lasted &mdash; some of them for years.
</p>
<ul className="mt-16 divide-y divide-line/70 border-y border-line/70">
{caseStudies.map((c) => (
<li key={c.slug}>
<Link
href={`/work/${c.slug}`}
className="group grid gap-6 py-10 transition-colors hover:bg-cream-soft sm:grid-cols-[1fr_2fr] sm:gap-10"
>
<div className="space-y-1">
<p
className="text-sm text-muted"
dangerouslySetInnerHTML={{ __html: c.industry }}
/>
<h2 className="font-serif text-2xl font-semibold text-ink">
{c.client}
</h2>
{c.yearsRunning ? (
<p className="font-mono text-xs uppercase tracking-wider text-accent">
{c.yearsRunning}
</p>
) : null}
</div>
<div className="space-y-3 text-ink-soft">
<p className="text-lg leading-relaxed">{c.summary}</p>
<p className="text-sm font-medium text-ink underline-offset-4 group-hover:underline">
Read the case study &rarr;
</p>
</div>
</Link>
</li>
))}
</ul>
<p className="mt-12 max-w-xl text-sm text-muted">
Several long-running engagements aren&rsquo;t listed here under
NDA. Happy to share more on a call.
</p>
</Container>
</section>
);
}

View File

@@ -0,0 +1,42 @@
import Link from "next/link";
import type { AnchorHTMLAttributes, ReactNode } from "react";
import { cn } from "@/lib/cn";
type Variant = "primary" | "secondary" | "ghost";
const base =
"inline-flex items-center justify-center gap-2 rounded-full px-5 py-2.5 text-sm font-medium tracking-tight transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent";
const variants: Record<Variant, string> = {
primary: "bg-ink text-cream hover:bg-ink-soft",
secondary:
"bg-transparent text-ink border border-ink/20 hover:border-ink hover:bg-ink/[0.03]",
ghost: "text-ink hover:text-accent",
};
type Props = AnchorHTMLAttributes<HTMLAnchorElement> & {
href: string;
variant?: Variant;
children: ReactNode;
};
export function Button({
href,
variant = "primary",
className,
children,
...props
}: Props) {
const isExternal = href.startsWith("http") || href.startsWith("mailto:");
const Comp: typeof Link | "a" = isExternal ? "a" : Link;
return (
<Comp
href={href}
className={cn(base, variants[variant], className)}
{...(isExternal ? { rel: "noopener noreferrer" } : {})}
{...props}
>
{children}
</Comp>
);
}

View File

@@ -0,0 +1,67 @@
/**
* Renders the "Trusted by" strip on the home page.
*
* Currently uses styled text wordmarks. To swap in real logos later:
* 1. Drop SVG/PNG into /public/logos/<slug>.svg
* 2. Add a `logo` field to the matching entry below
* 3. Update the JSX to render <img> when `logo` is present
*
* Get explicit permission from each client before publishing real
* marks — see STRATEGY.md.
*/
type Client = {
name: string;
/** Optional URL the wordmark links to. Omit for non-linked. */
href?: string;
};
const clients: Client[] = [
{ name: "Americold", href: "https://www.americold.com" },
{ name: "Butcher Box", href: "https://www.butcherbox.com" },
{ name: "Perfect Bar", href: "https://www.perfectsnacks.com" },
{ name: "Pritikin Foods" },
{ name: "Potawatomi Business Development" },
{ name: "Fortune Fulfillment" },
{ name: "Vanderose Farms" },
{ name: "PERC Engage", href: "https://percengage.com" },
];
export function ClientLogos() {
return (
<div>
<p className="text-xs font-medium uppercase tracking-[0.18em] text-muted">
Trusted by teams at
</p>
<ul className="mt-7 grid grid-cols-2 items-center gap-x-10 gap-y-6 sm:grid-cols-4">
{clients.map((c) => {
const wordmark = (
<span className="block font-serif text-lg leading-tight tracking-tight text-ink-soft transition-colors group-hover:text-ink sm:text-xl">
{c.name}
</span>
);
return (
<li
key={c.name}
className="group flex min-h-[44px] items-center justify-center text-center"
>
{c.href ? (
<a
href={c.href}
target="_blank"
rel="noopener noreferrer"
className="block"
aria-label={c.name}
>
{wordmark}
</a>
) : (
wordmark
)}
</li>
);
})}
</ul>
</div>
);
}

View File

@@ -0,0 +1,103 @@
"use client";
import { useState } from "react";
import type { FormEvent } from "react";
const recipient = "chris@fivedevs.com";
export function ContactForm() {
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [company, setCompany] = useState("");
const [message, setMessage] = useState("");
function handleSubmit(e: FormEvent<HTMLFormElement>) {
e.preventDefault();
const subject = `New project inquiry from ${name || "site visitor"}`;
const lines = [
`Name: ${name}`,
`Reply-to: ${email}`,
company ? `Company: ${company}` : null,
"",
message,
].filter(Boolean);
const body = lines.join("\n");
const href =
`mailto:${recipient}` +
`?subject=${encodeURIComponent(subject)}` +
`&body=${encodeURIComponent(body)}`;
window.location.href = href;
}
const inputBase =
"w-full rounded-lg border border-line bg-cream-soft px-4 py-3 text-base text-ink placeholder:text-muted focus:border-ink focus:outline-none";
return (
<form onSubmit={handleSubmit} className="space-y-5">
<div className="grid gap-5 sm:grid-cols-2">
<label className="block">
<span className="mb-1.5 block text-sm font-medium text-ink-soft">
Your name
</span>
<input
required
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
className={inputBase}
placeholder="Jane Doe"
/>
</label>
<label className="block">
<span className="mb-1.5 block text-sm font-medium text-ink-soft">
Email
</span>
<input
required
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className={inputBase}
placeholder="jane@company.com"
/>
</label>
</div>
<label className="block">
<span className="mb-1.5 block text-sm font-medium text-ink-soft">
Company <span className="text-muted">(optional)</span>
</span>
<input
type="text"
value={company}
onChange={(e) => setCompany(e.target.value)}
className={inputBase}
placeholder="Acme Co."
/>
</label>
<label className="block">
<span className="mb-1.5 block text-sm font-medium text-ink-soft">
What&rsquo;s the project?
</span>
<textarea
required
rows={6}
value={message}
onChange={(e) => setMessage(e.target.value)}
className={`${inputBase} resize-y`}
placeholder="A few sentences on what's broken or what you'd like to build."
/>
</label>
<div className="flex flex-wrap items-center gap-4 pt-2">
<button
type="submit"
className="inline-flex items-center justify-center gap-2 rounded-full bg-ink px-6 py-3 text-sm font-medium text-cream transition-colors hover:bg-ink-soft"
>
Send message &rarr;
</button>
<p className="text-sm text-muted">
Opens in your email client.
</p>
</div>
</form>
);
}

View File

@@ -0,0 +1,27 @@
import { cn } from "@/lib/cn";
import type { HTMLAttributes } from "react";
type Width = "narrow" | "default" | "wide";
const widthClass: Record<Width, string> = {
narrow: "max-w-2xl",
default: "max-w-5xl",
wide: "max-w-6xl",
};
export function Container({
width = "default",
className,
...props
}: HTMLAttributes<HTMLDivElement> & { width?: Width }) {
return (
<div
{...props}
className={cn(
"mx-auto w-full px-6 sm:px-8",
widthClass[width],
className,
)}
/>
);
}

View File

@@ -0,0 +1,120 @@
import { Container } from "./container";
import { Eyebrow, SectionHeading } from "./section-heading";
import { cn } from "@/lib/cn";
export const guarantees = [
{
title: "30-day money back",
body:
"If your first month on a retainer or block doesn't fit, you get it back. No questions, no exit interview. Risk is on me.",
},
{
title: "30-day bug-fix window",
body:
"Bugs in code I ship are fixed at no charge for 30 days after handoff. If it broke under my watch, I own it.",
},
{
title: "One business day response",
body:
"Every retainer and block client gets a real reply inside one business day. Not a ticket number, not an out-of-office.",
},
{
title: "You own everything",
body:
"Code, docs, infra, accounts. Full IP transfer in writing. No subcontracting, no vendor lock-in, no surprises.",
},
];
type Props = {
className?: string;
/** Use 'compact' for narrower, in-context placement (e.g. inside another section). */
variant?: "default" | "compact";
/** Hide the section heading; useful when wrapped in a custom heading. */
hideHeading?: boolean;
};
export function Guarantees({
className,
variant = "default",
hideHeading = false,
}: Props) {
const isCompact = variant === "compact";
const heading = (
<SectionHeading
eyebrow="Promises in writing"
title="Four guarantees on every engagement."
subtitle="The senior-freelance market is full of vague promises. Here are mine, written down so we both have them."
/>
);
return (
<section
className={cn(
isCompact ? "py-16" : "border-y border-line/70 bg-cream-soft py-24 sm:py-32",
className,
)}
>
<Container>
{!hideHeading ? heading : null}
<ul
className={cn(
"grid gap-6",
isCompact
? "mt-10 sm:grid-cols-2"
: "mt-14 sm:grid-cols-2 lg:grid-cols-4",
)}
>
{guarantees.map((g, i) => (
<li
key={g.title}
className={cn(
"rounded-2xl border border-line/80 p-6",
isCompact ? "bg-cream" : "bg-cream",
)}
>
<div className="flex items-baseline gap-3">
<span className="font-mono text-sm text-accent">0{i + 1}</span>
<h3 className="font-serif text-lg font-semibold text-ink">
{g.title}
</h3>
</div>
<p className="mt-3 text-sm leading-relaxed text-ink-soft">
{g.body}
</p>
</li>
))}
</ul>
</Container>
</section>
);
}
/**
* Inline mini-list of guarantee titles only — for tight spots
* (e.g. inside the Services pricing area) where the full Guarantees
* section would be too heavy.
*/
export function GuaranteesMini() {
return (
<div className="rounded-2xl border border-line/80 bg-cream p-6">
<div className="flex flex-wrap items-baseline gap-3">
<Eyebrow>Every engagement includes</Eyebrow>
</div>
<ul className="mt-4 grid gap-x-8 gap-y-2 text-sm text-ink sm:grid-cols-2">
{guarantees.map((g) => (
<li key={g.title} className="flex items-start gap-2">
<span aria-hidden="true" className="mt-1 text-accent">
</span>
<span>
<span className="font-medium">{g.title}.</span>{" "}
<span className="text-ink-soft">
{g.body.split(".")[0]}.
</span>
</span>
</li>
))}
</ul>
</div>
);
}

View File

@@ -0,0 +1,40 @@
export const principles = [
{
title: "Senior, end-to-end",
body:
"You hire me, you get me. I read the code, talk to the people, ship the change, and write down what I learned.",
},
{
title: "Boring tech, on purpose",
body:
"Postgres, queues, plain PHP, small dependencies. Fewer moving parts means fewer 3am pages.",
},
{
title: "Small PRs, fast feedback",
body:
"You'll see what I'm doing every day, not at the end of a three-week dark period. Reviewing my work should take minutes, not hours.",
},
{
title: "Documented as I go",
body:
"Every change ships with a short note explaining why. Your team can pick the work up cleanly if you ever bring it in-house.",
},
{
title: "Honest about scope",
body:
"If I'm not the right person for the work, I'll say so — and where I can, point you at someone who is.",
},
];
export function HowIWork() {
return (
<ul className="mt-6 space-y-5">
{principles.map((p) => (
<li key={p.title}>
<strong className="text-ink">{p.title}.</strong>{" "}
<span className="text-ink-soft">{p.body}</span>
</li>
))}
</ul>
);
}

View File

@@ -0,0 +1,12 @@
/**
* Emits a JSON-LD <script> tag. Pass any schema.org-shaped object.
* Safe for Server Components.
*/
export function JsonLd({ data }: { data: object }) {
return (
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(data) }}
/>
);
}

View File

@@ -0,0 +1,126 @@
import { Container } from "./container";
import { Eyebrow } from "./section-heading";
import { Button } from "./button";
import {
blocks,
formatPrice,
retainer,
saleActive,
saleLabel,
type CheckoutOption,
} from "@/lib/pricing";
function priceText(option: CheckoutOption): string {
return option.salePrice !== undefined
? formatPrice(option.salePrice)
: formatPrice(option.regularPrice);
}
function strikeText(option: CheckoutOption): string | null {
return option.salePrice !== undefined
? formatPrice(option.regularPrice)
: null;
}
export function PricingTeaser() {
const smallestBlock = blocks[0];
const items: Array<{
option: CheckoutOption;
title: string;
sub: string;
perSuffix?: string;
buyLabel: string;
}> = [
{
option: smallestBlock,
title: smallestBlock.label,
sub: "First time working together? Start small.",
buyLabel: "Buy 5 hours",
},
{
option: retainer,
title: retainer.label,
sub: retainer.hoursLabel,
perSuffix: "/mo",
buyLabel: "Subscribe",
},
];
return (
<section className="py-24 sm:py-32">
<Container>
<div className="flex flex-wrap items-baseline justify-between gap-4">
<div>
<Eyebrow>Quick start</Eyebrow>
<h2 className="mt-4 font-serif text-3xl font-semibold tracking-tight text-ink sm:text-4xl">
Self-serve, two doors in.
</h2>
<p className="mt-3 max-w-xl text-ink-soft">
No discovery call required if you&rsquo;re ready to go.
{saleActive ? (
<>
{" "}
<span className="font-medium text-accent">
{saleLabel}
</span>{" "}
is live.
</>
) : null}
</p>
</div>
<a
href="/services"
className="text-sm font-medium text-ink underline-offset-4 hover:underline"
>
All engagement options &rarr;
</a>
</div>
<ul className="mt-10 grid gap-6 sm:grid-cols-2">
{items.map(({ option, title, sub, perSuffix, buyLabel }) => {
const wired = !!option.buyHref;
const strike = strikeText(option);
return (
<li
key={option.id}
className="flex flex-col rounded-2xl border border-line/80 bg-cream-soft p-7"
>
<div className="flex flex-wrap items-baseline justify-between gap-3">
<h3 className="font-serif text-xl font-semibold text-ink">
{title}
</h3>
</div>
<p className="mt-1 text-sm text-muted">{sub}</p>
<div className="mt-6 flex items-baseline gap-3">
<span className="text-3xl font-semibold tracking-tight text-ink">
{priceText(option)}
{perSuffix ? (
<span className="text-base font-normal text-muted">
{perSuffix}
</span>
) : null}
</span>
{strike ? (
<span className="text-lg text-muted line-through">
{strike}
</span>
) : null}
</div>
<div className="mt-7">
<Button
href={option.buyHref ?? "/contact"}
variant="primary"
>
{wired ? `${buyLabel}` : "Talk first →"}
</Button>
</div>
</li>
);
})}
</ul>
</Container>
</section>
);
}

View File

@@ -0,0 +1,50 @@
import { cn } from "@/lib/cn";
import type { ReactNode } from "react";
export function Eyebrow({
children,
className,
}: {
children: ReactNode;
className?: string;
}) {
return (
<p
className={cn(
"text-xs font-medium uppercase tracking-[0.18em] text-accent",
className,
)}
>
{children}
</p>
);
}
export function SectionHeading({
eyebrow,
title,
subtitle,
align = "left",
}: {
eyebrow?: string;
title: ReactNode;
subtitle?: ReactNode;
align?: "left" | "center";
}) {
return (
<div
className={cn(
"max-w-2xl space-y-4",
align === "center" && "mx-auto text-center",
)}
>
{eyebrow ? <Eyebrow>{eyebrow}</Eyebrow> : null}
<h2 className="font-serif text-3xl font-semibold tracking-tight text-ink sm:text-4xl">
{title}
</h2>
{subtitle ? (
<p className="text-lg leading-relaxed text-ink-soft">{subtitle}</p>
) : null}
</div>
);
}

View File

@@ -0,0 +1,52 @@
import Link from "next/link";
import { Container } from "./container";
export function SiteFooter() {
const year = new Date().getFullYear();
return (
<footer className="mt-32 border-t border-line/70 bg-cream-soft py-12 text-sm text-muted">
<Container className="flex flex-col gap-6 sm:flex-row sm:items-end sm:justify-between">
<div className="space-y-2">
<Link
href="/"
className="font-serif text-lg font-semibold text-ink"
>
Five Devs<span className="text-accent">.</span>
</Link>
<p className="max-w-md">
Senior PHP help for the unglamorous, mission-critical glue
between your store, your warehouse, and your books.
</p>
</div>
<div className="flex flex-col gap-2 sm:items-end">
<div className="flex gap-5">
<a
href="https://github.com/cgsmith"
className="hover:text-ink"
rel="noopener noreferrer"
>
GitHub
</a>
<a
href="https://www.linkedin.com/in/phpguy"
className="hover:text-ink"
rel="noopener noreferrer"
>
LinkedIn
</a>
<a
href="https://twitter.com/cgsmith105"
className="hover:text-ink"
rel="noopener noreferrer"
>
Twitter
</a>
</div>
<p>
&copy; {year} Five Devs, LLC &middot; Henderson, NV
</p>
</div>
</Container>
</footer>
);
}

View File

@@ -0,0 +1,43 @@
import Link from "next/link";
import { Container } from "./container";
import { Button } from "./button";
const nav = [
{ href: "/services", label: "Services" },
{ href: "/work", label: "Work" },
{ href: "/blog", label: "Writing" },
{ href: "/about", label: "About" },
];
export function SiteHeader() {
return (
<header className="sticky top-0 z-30 border-b border-line/60 bg-cream/80 backdrop-blur supports-[backdrop-filter]:bg-cream/70">
<Container className="flex h-16 items-center justify-between">
<Link
href="/"
className="font-serif text-xl font-semibold tracking-tight text-ink"
>
Five Devs
<span className="text-accent">.</span>
</Link>
<nav className="hidden items-center gap-7 sm:flex">
{nav.map((item) => (
<Link
key={item.href}
href={item.href}
className="text-sm text-ink-soft transition-colors hover:text-ink"
>
{item.label}
</Link>
))}
</nav>
<Button href="/contact" variant="primary" className="hidden sm:inline-flex">
Book a call
</Button>
<Button href="/contact" variant="primary" className="sm:hidden">
Contact
</Button>
</Container>
</header>
);
}

View File

@@ -0,0 +1,59 @@
import { Container } from "./container";
import { SectionHeading } from "./section-heading";
export const testimonials = [
{
name: "Emil Boschert",
role: "Pritikin Foods",
quote:
"I have worked together with Chris and Five Devs for several years now on our shipping and order parsing process. They are vital for our e-commerce site and our 3PL.",
},
{
name: "Timothy Dorcas",
role: "Americold",
quote:
"I cannot say enough good things about the team at Five Devs. They are solution oriented, customer focused, and consistently deliver at a high level. Highly recommended.",
},
{
name: "Katie Allen",
role: "Fortune Fulfillment",
quote:
"I have been working with Five Devs on multiple customer accounts for several years. Chris makes the set-up process flow so much easier!",
},
{
name: "Tom Deppe",
role: "HD Financial",
quote:
"Five Devs is an integral part of my business. Their tech support keeps us up and running. Promptly returns phone calls and is a pleasure to work with.",
},
];
export function Testimonials() {
return (
<section className="border-y border-line/70 bg-cream-soft py-24 sm:py-32">
<Container>
<SectionHeading
eyebrow="What clients say"
title="Trusted for years, not weeks."
subtitle="Five Devs is the kind of help that gets quietly embedded in how a business runs — and stays there."
/>
<ul className="mt-14 grid gap-6 sm:grid-cols-2">
{testimonials.map((t) => (
<li
key={t.name}
className="rounded-2xl border border-line/80 bg-cream p-7 shadow-[0_1px_0_rgba(0,0,0,0.02)]"
>
<p className="font-serif text-lg leading-relaxed text-ink">
&ldquo;{t.quote}&rdquo;
</p>
<p className="mt-5 text-sm text-muted">
<span className="font-medium text-ink">{t.name}</span>{" "}
&middot; {t.role}
</p>
</li>
))}
</ul>
</Container>
</section>
);
}

View File

@@ -0,0 +1,81 @@
export const metadata = {
title: "How to hire a senior PHP developer (without getting burned)",
description:
"A practical checklist for non-technical founders and ops leads who need to bring in a PHP freelancer for ongoing or project work.",
date: "2026-04-22",
readingMinutes: 7,
tags: ["hiring", "freelancing"],
};
If you're reading this, you probably have a PHP application that's important to your business and you're thinking about bringing in outside help — either for a one-time project or for ongoing maintenance.
Hiring well at the senior contractor level is its own skill. Here's the checklist I'd want a non-technical founder to use when evaluating someone like me.
## Step 1: Know what kind of help you actually need
There are roughly three:
- **Project work.** A defined deliverable: an integration, a rewrite, a migration. You want a fixed scope, a fixed price (after some discovery), and a clear handoff at the end.
- **Ongoing help.** A monthly retainer for the long tail: small features, bug fixes, the occasional production issue. You want predictability and someone who's already in the codebase.
- **A block of time.** Senior hours you can spend over a few months as needs come up. You want flexibility without re-negotiating every two weeks.
If a freelancer can only sell you one of these, that's information.
## Step 2: Look for a portfolio of relationships, not projects
Anyone can list five "projects" on a portfolio page. The harder thing to fake is a list of clients who have kept paying that person for two, three, five years.
When you talk to a candidate, ask:
- "Who's your longest-running client and what do you do for them?"
- "Tell me about a time you walked into a codebase you'd never seen and shipped something in the first week."
- "What's a project you turned down? Why?"
The third one matters most. Senior freelancers say no.
## Step 3: Get a paid trial, not a free one
If you're considering a multi-month engagement, run a small paid trial first. Two to four hours, scoped tightly: read this code, write a small fix, write a one-page review of what you found.
Pay for it. You learn three things in a paid trial that you can never learn from an unpaid one:
1. How they communicate when their professional reputation is on the line.
2. What their commit messages and PR descriptions look like.
3. How they ask questions — the good ones ask a lot, fast, and early.
A senior dev who won't take a paid trial is signaling something.
## Step 4: Beware the agency middle layer
Some "senior PHP freelancers" are actually a sales person who sells you on a senior, then quietly hands the work to a junior. This is the single biggest source of bad freelance experiences I hear about.
The check is simple: ask who specifically will be writing the code. Get it in writing. If the answer changes mid-project, that's your sign.
## Step 5: Watch how they handle the unknown
Halfway through your trial or first project, ask them about something that isn't in the spec — a weird edge case, a "what would you do if X" question.
You're looking for one of two answers:
- "Here's what I'd do, here's why, and here's what I'd want to confirm with you first." (Good.)
- A confident, immediate answer that ignores the ambiguity. (Bad.)
Senior contractors live in the gap between "what was specified" and "what the business actually needs." If they can't navigate that gap with you in real time, they won't navigate it well in production either.
## Step 6: Decide on the boring stuff up front
Before signing anything, agree in writing on:
- Hourly or fixed rate, and what triggers a re-quote
- Who owns the code (you should, period)
- How communication happens (email, Slack, GitHub issues — pick one)
- What "done" means for each piece of work
- How either side ends the engagement cleanly
Most freelance disasters trace back to one of these being assumed instead of written down.
---
If you're at the stage of having this conversation, you're already ahead of most people. The biggest mistake isn't picking the "wrong" senior dev — it's not running the process at all and ending up with whoever your cousin recommended.
If you'd like to put me through this checklist, [book a 20-minute call](/contact). I won't be offended by the questions.

View File

@@ -0,0 +1,44 @@
export const metadata = {
title: "Why I take 'glue work' seriously (and you should too)",
description:
"The unsexy code that lives between your store, your warehouse, and your books is doing more for the business than the cool new feature ever will.",
date: "2026-04-30",
readingMinutes: 5,
tags: ["e-commerce", "philosophy"],
};
There's a kind of code I love writing that almost nobody brags about.
It's the script that takes a CSV from a 1996-era ERP, normalizes the SKUs, decides whether each line is a real shipment or a return, validates the address against USPS, and hands the result to a 3PL's API in the format that 3PL expects this Tuesday. It's the cron that watches a folder for EDI 940s, parses them, and writes a row in your warehouse system. It's the one-page admin tool that lets a customer service rep look up an order without opening five tabs.
Glue work, integration work, ops tooling — whatever you call it, this is the code that keeps small businesses shipping.
## It looks dumb. It isn't.
The first time someone sees an integration like this, the reaction is usually some version of "wait, that's it?" A few hundred lines of PHP, a queue, a Postgres table, a few well-placed log lines. No clever framework. No machine learning. Sometimes not even a class.
But here's what that "few hundred lines" is actually doing:
- Encoding **how the business actually works**, including the exceptions nobody wrote down
- Surviving every change a vendor pushes in the next three years
- Logging enough that when something breaks at 4pm, you can tell *what* in under a minute
That's a lot of value packed into something that looks like a script.
## The skill is in the boring parts
Anyone can write the happy path. The skill is in:
1. **Reading the messy reality.** Real input data is never clean. Half the bugs in glue code come from someone shipping a SKU that "shouldn't exist" or an order with a comma in the customer name. Senior glue work assumes your data is messy and proves it before going to production.
2. **Logging like an adult.** A glue script that runs unattended needs to leave a trail you can read six months later when a CFO asks why a single order shipped twice. Structured logs, idempotency keys, retry semantics that won't double-charge a customer.
3. **Writing tests against contracts, not just code.** When the 3PL changes their API on a Sunday night, the test suite that catches it is the one that ran against a recorded fixture, not the one that mocked the function call.
None of that is glamorous. All of it is the difference between glue that lasts five years and glue you have to rip out in three months.
## Why I keep doing this work
I get to look at a small business and see, very concretely, where money is leaking out — usually because someone is doing in three hours of manual work what a 200-line PHP script could do in 200 milliseconds.
When I write that script, I get to give those three hours back to a real person. Repeat that across a year and the math is easy.
That's why glue work is worth taking seriously. The alternative is human beings spending their working lives moving CSVs around.

View File

@@ -0,0 +1,48 @@
export const metadata = {
title: "A pre-launch checklist for connecting your store to a 3PL",
description:
"Twelve things I check before flipping the switch on a new e-commerce-to-3PL integration. Skip them and you'll find out what they were the hard way.",
date: "2026-04-12",
readingMinutes: 6,
tags: ["e-commerce", "3pl", "integration"],
};
You signed a contract with a fulfillment partner. They've sent over their integration docs. Your store software has a "connect" button. You're three days from go-live.
Here's the checklist I run through before flipping that switch on a real production integration. Skip these and you'll learn them anyway — usually on a Friday afternoon.
## The setup checks
**1. Confirm SKU canonicalization.** The SKU in your store, the SKU in your warehouse system, and the SKU on the actual product label have to match exactly. Different leading zeros count as different SKUs. Different cases count as different SKUs. Run an audit before launch and fix the mismatches.
**2. Decide the source of truth for inventory.** It's the warehouse, every time. Your store should pull from there, not the other way around. Pick a sync interval and write down what happens when sync fails.
**3. Confirm address validation.** USPS, UPS, and FedEx will all happily accept addresses that none of them can deliver to. Validate before the order leaves your system, not after the warehouse has already picked it.
**4. Map every shipping method end-to-end.** Your "Standard Shipping" needs to map to a specific carrier, a specific service code, and a specific package type at the warehouse. "Standard" means nothing to a 3PL.
## The error-handling checks
**5. Define what "failed order" means.** Address invalid? Out of stock? SKU mismatch? Each one needs a different recovery path. Write them down. Build them in.
**6. Build retry that doesn't double-ship.** Idempotency keys on every outbound order. Test what happens when you retry an order the warehouse has already started picking.
**7. Decide who gets paged when things break.** "Both of us" is not an answer. One person, one channel, one escalation path.
**8. Log enough to reconstruct any order.** Six months from now, customer service is going to ask why order #4218 shipped twice. You need to be able to answer in two minutes, not two hours.
## The financial checks
**9. Reconcile shipped vs. ordered, daily.** Pull what was actually shipped from the 3PL, compare to what your store thinks shipped. Differences mean either a billing problem or a customer-experience problem. Catch them in 24 hours, not 30 days.
**10. Confirm carrier billing.** Whose UPS account is being charged for each shipment? You'd be amazed how often this is wrong.
**11. Track returns the same way as shipments.** Returns are shipments going the other way. They need the same SKU mapping, the same logging, the same reconciliation. They almost never get it.
## The post-launch check
**12. Schedule a 30-day post-launch review.** Not a meeting — a written review. What broke? What was harder than expected? What's still on the to-do list? You'll be tempted to skip this one. Don't.
---
If you're staring at this list and realizing you're three days from launch and you've checked maybe four of them, that's exactly the kind of project I help with. [Get in touch](/contact) and we'll figure out what's worth doing before launch and what can wait.

View File

@@ -0,0 +1,37 @@
export type CaseStudy = {
slug: string;
client: string;
industry: string;
summary: string;
outcome: string;
yearsRunning?: string;
role?: string;
};
export const caseStudies: CaseStudy[] = [
{
slug: "pritikin-foods",
client: "Pritikin Foods",
industry: "DTC food &middot; e-commerce + 3PL",
summary:
"Reliable shipping and order parsing pipeline between Pritikin's e-commerce site and their fulfillment partner.",
outcome:
"Multi-year partnership keeping the order-to-shipment loop quietly running.",
yearsRunning: "Several years",
role: "Embedded developer",
},
{
slug: "americold",
client: "Americold",
industry: "Cold-chain 3PL &middot; enterprise logistics",
summary:
"Solution-oriented engineering on operational tooling for one of the largest temperature-controlled warehousing networks in the US.",
outcome:
"Consistent senior-level delivery on customer-facing logistics tooling.",
role: "Contract developer",
},
];
export function getCaseStudy(slug: string): CaseStudy | undefined {
return caseStudies.find((c) => c.slug === slug);
}

5
new-site/src/lib/cn.ts Normal file
View File

@@ -0,0 +1,5 @@
export function cn(
...classes: Array<string | false | null | undefined>
): string {
return classes.filter(Boolean).join(" ");
}

174
new-site/src/lib/jsonld.ts Normal file
View File

@@ -0,0 +1,174 @@
import {
ADDRESS,
ORG_LEGAL_NAME,
PERSON_EMAIL,
PERSON_NAME,
SITE_NAME,
SITE_URL,
SOCIAL,
} from "./site";
const orgRef = {
"@type": "Organization",
name: ORG_LEGAL_NAME,
url: SITE_URL,
};
const personRef = {
"@type": "Person",
name: PERSON_NAME,
url: `${SITE_URL}/about`,
};
export function organizationSchema() {
return {
"@context": "https://schema.org",
"@type": "Organization",
"@id": `${SITE_URL}#organization`,
name: ORG_LEGAL_NAME,
alternateName: SITE_NAME,
url: SITE_URL,
logo: `${SITE_URL}/og-image.png`,
description:
"Senior PHP help for the unglamorous, mission-critical glue between your store, your warehouse, and your books. Fifteen years of e-commerce and 3PL/logistics integration work.",
founder: {
...personRef,
jobTitle: "Senior PHP Developer",
},
address: {
"@type": "PostalAddress",
...ADDRESS,
},
contactPoint: {
"@type": "ContactPoint",
email: PERSON_EMAIL,
contactType: "customer service",
availableLanguage: ["English", "German"],
},
sameAs: [SOCIAL.linkedin, SOCIAL.github, SOCIAL.twitter],
};
}
export function personSchema() {
return {
"@context": "https://schema.org",
"@type": "Person",
"@id": `${SITE_URL}/about#person`,
name: PERSON_NAME,
jobTitle: "Senior PHP Developer",
worksFor: orgRef,
url: `${SITE_URL}/about`,
email: PERSON_EMAIL,
sameAs: [SOCIAL.linkedin, SOCIAL.github, SOCIAL.twitter],
knowsAbout: [
"PHP",
"Laravel",
"Symfony",
"E-commerce",
"Third-party logistics (3PL)",
"Order management systems",
"EDI integrations",
"Shipping and fulfillment integrations",
"Inventory synchronization",
"Magento",
"MySQL",
"PostgreSQL",
],
knowsLanguage: ["English", "German"],
address: {
"@type": "PostalAddress",
addressLocality: ADDRESS.addressLocality,
addressRegion: ADDRESS.addressRegion,
addressCountry: ADDRESS.addressCountry,
},
};
}
type ServiceInput = {
name: string;
description: string;
url: string;
/** Total / starting price in USD. Omit for "custom quote" services. */
price?: number;
/** "OneTime" | "Subscription" */
priceType?: "OneTime" | "Subscription";
};
export function serviceSchema(s: ServiceInput) {
const offer: Record<string, unknown> = {
"@type": "Offer",
url: s.url,
availability: "https://schema.org/InStock",
};
if (s.price !== undefined) {
offer.price = s.price.toString();
offer.priceCurrency = "USD";
if (s.priceType === "Subscription") {
offer.priceSpecification = {
"@type": "UnitPriceSpecification",
price: s.price,
priceCurrency: "USD",
unitText: "MONTH",
};
}
}
return {
"@context": "https://schema.org",
"@type": "Service",
name: s.name,
description: s.description,
provider: orgRef,
serviceType: "Software development",
areaServed: { "@type": "Country", name: "United States" },
url: s.url,
offers: offer,
};
}
type ArticleInput = {
title: string;
description: string;
slug: string;
date: string;
basePath: "blog" | "work";
};
export function articleSchema(a: ArticleInput) {
const url = `${SITE_URL}/${a.basePath}/${a.slug}`;
return {
"@context": "https://schema.org",
"@type": "Article",
headline: a.title,
description: a.description,
author: personRef,
publisher: {
...orgRef,
logo: {
"@type": "ImageObject",
url: `${SITE_URL}/og-image.png`,
},
},
datePublished: a.date,
dateModified: a.date,
mainEntityOfPage: {
"@type": "WebPage",
"@id": url,
},
url,
};
}
export function breadcrumbSchema(
items: Array<{ name: string; path: string }>,
) {
return {
"@context": "https://schema.org",
"@type": "BreadcrumbList",
itemListElement: items.map((item, i) => ({
"@type": "ListItem",
position: i + 1,
name: item.name,
item: `${SITE_URL}${item.path}`,
})),
};
}

58
new-site/src/lib/posts.ts Normal file
View File

@@ -0,0 +1,58 @@
import type { ComponentType } from "react";
import * as glueJob from "@/content/blog/the-glue-job.mdx";
import * as hireDev from "@/content/blog/how-to-hire-a-php-dev.mdx";
import * as threePl from "@/content/blog/three-pl-integration-checklist.mdx";
type PostMetadata = {
title: string;
description: string;
date: string;
readingMinutes: number;
tags?: string[];
};
type MdxModule = {
metadata: PostMetadata;
default: ComponentType;
};
type Post = {
slug: string;
metadata: PostMetadata;
Content: ComponentType;
};
const registry: Record<string, MdxModule> = {
"the-glue-job": glueJob as MdxModule,
"how-to-hire-a-php-dev": hireDev as MdxModule,
"three-pl-integration-checklist": threePl as MdxModule,
};
export const posts: Post[] = Object.entries(registry)
.map(([slug, mod]) => ({
slug,
metadata: mod.metadata,
Content: mod.default,
}))
.sort(
(a, b) =>
new Date(b.metadata.date).getTime() -
new Date(a.metadata.date).getTime(),
);
export function getPost(slug: string): Post | undefined {
return posts.find((p) => p.slug === slug);
}
export function getAllSlugs(): string[] {
return posts.map((p) => p.slug);
}
export function formatDate(iso: string): string {
const d = new Date(iso);
return d.toLocaleDateString("en-US", {
year: "numeric",
month: "long",
day: "numeric",
});
}

110
new-site/src/lib/pricing.ts Normal file
View File

@@ -0,0 +1,110 @@
/**
* Pricing & checkout config for self-serve engagements.
*
* Stripe URLs are read from NEXT_PUBLIC_STRIPE_* env vars at build
* time. When unset, the Buy button falls back to /contact so the page
* still reads sensibly.
*
* To run a sale (e.g. soft launch), set `salePrice` and `saleLabel`
* on a tier and point its env-var URL at a Stripe Payment Link
* configured at the sale price. To end the sale: clear `salePrice` /
* `saleLabel` and swap the env-var URL back to the regular Payment
* Link.
*/
export type CheckoutOption = {
id: string;
label: string;
hoursLabel: string;
regularPrice: number;
salePrice?: number;
saleLabel?: string;
effectiveHourly: number;
saleEffectiveHourly?: number;
/** Resolved at module load. Falls back to undefined when unset. */
buyHref?: string;
};
const sale = {
active: true,
label: "Website redesign launch · 50% off",
};
function maybeSale(regular: number): number | undefined {
return sale.active ? Math.round(regular / 2) : undefined;
}
function maybeSaleLabel(): string | undefined {
return sale.active ? sale.label : undefined;
}
function effective(price: number, hours: number): number {
return Math.round(price / hours);
}
export const retainer: CheckoutOption = {
id: "retainer",
label: "Monthly retainer",
hoursLabel: "32 reserved hours / month",
regularPrice: 5000,
salePrice: maybeSale(5000),
saleLabel: maybeSaleLabel(),
effectiveHourly: effective(5000, 32),
saleEffectiveHourly: sale.active
? effective(Math.round(5000 / 2), 32)
: undefined,
buyHref: process.env.NEXT_PUBLIC_STRIPE_RETAINER,
};
export const blocks: CheckoutOption[] = [
{
id: "block-5",
label: "5-hour block",
hoursLabel: "5 hours · use within 6 months",
regularPrice: 750,
salePrice: maybeSale(750),
saleLabel: maybeSaleLabel(),
effectiveHourly: effective(750, 5),
saleEffectiveHourly: sale.active
? effective(Math.round(750 / 2), 5)
: undefined,
buyHref: process.env.NEXT_PUBLIC_STRIPE_BLOCK_5,
},
{
id: "block-10",
label: "10-hour block",
hoursLabel: "10 hours · use within 6 months",
regularPrice: 1400,
salePrice: maybeSale(1400),
saleLabel: maybeSaleLabel(),
effectiveHourly: effective(1400, 10),
saleEffectiveHourly: sale.active
? effective(Math.round(1400 / 2), 10)
: undefined,
buyHref: process.env.NEXT_PUBLIC_STRIPE_BLOCK_10,
},
{
id: "block-20",
label: "20-hour block",
hoursLabel: "20 hours · use within 6 months",
regularPrice: 2600,
salePrice: maybeSale(2600),
saleLabel: maybeSaleLabel(),
effectiveHourly: effective(2600, 20),
saleEffectiveHourly: sale.active
? effective(Math.round(2600 / 2), 20)
: undefined,
buyHref: process.env.NEXT_PUBLIC_STRIPE_BLOCK_20,
},
];
export const saleActive = sale.active;
export const saleLabel = sale.label;
export function formatPrice(amount: number): string {
return amount.toLocaleString("en-US", {
style: "currency",
currency: "USD",
maximumFractionDigits: 0,
});
}

19
new-site/src/lib/site.ts Normal file
View File

@@ -0,0 +1,19 @@
export const SITE_URL = "https://fivedevs.com";
export const SITE_NAME = "Five Devs";
export const ORG_LEGAL_NAME = "Five Devs, LLC";
export const PERSON_NAME = "Chris Smith";
export const PERSON_EMAIL = "chris@fivedevs.com";
export const SOCIAL = {
linkedin: "https://www.linkedin.com/in/phpguy",
github: "https://github.com/cgsmith",
twitter: "https://twitter.com/cgsmith105",
};
export const ADDRESS = {
streetAddress: "1887 Whitney Mesa Dr. PMB 7325",
addressLocality: "Henderson",
addressRegion: "NV",
postalCode: "89014",
addressCountry: "US",
};

34
new-site/tsconfig.json Normal file
View File

@@ -0,0 +1,34 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./src/*"]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts",
"**/*.mts"
],
"exclude": ["node_modules"]
}