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:
16
new-site/.env.local.example
Normal file
16
new-site/.env.local.example
Normal 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
41
new-site/.gitignore
vendored
Normal 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
5
new-site/AGENTS.md
Normal 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
1
new-site/CLAUDE.md
Normal file
@@ -0,0 +1 @@
|
|||||||
|
@AGENTS.md
|
||||||
36
new-site/README.md
Normal file
36
new-site/README.md
Normal 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
340
new-site/STRATEGY.md
Normal 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 $80–110/hr; they
|
||||||
|
pay a domain specialist $150–250/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 4–6 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 1–2: review and ship the v1 site (above checklist)
|
||||||
|
- [ ] Day 3: announce on LinkedIn/Twitter — short post, one sentence
|
||||||
|
about positioning, link to home
|
||||||
|
- [ ] Day 4–5: 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.*
|
||||||
18
new-site/eslint.config.mjs
Normal file
18
new-site/eslint.config.mjs
Normal 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;
|
||||||
5
new-site/mdx-components.tsx
Normal file
5
new-site/mdx-components.tsx
Normal 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
18
new-site/next.config.ts
Normal 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
33
new-site/package.json
Normal 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
5377
new-site/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
3
new-site/pnpm-workspace.yaml
Normal file
3
new-site/pnpm-workspace.yaml
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
ignoredBuiltDependencies:
|
||||||
|
- sharp
|
||||||
|
- unrs-resolver
|
||||||
7
new-site/postcss.config.mjs
Normal file
7
new-site/postcss.config.mjs
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
const config = {
|
||||||
|
plugins: {
|
||||||
|
"@tailwindcss/postcss": {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
96
new-site/src/app/about/page.tsx
Normal file
96
new-site/src/app/about/page.tsx
Normal 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’m Chris.
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<div className="prose prose-lg mt-10 max-w-none text-ink-soft">
|
||||||
|
<p className="text-xl leading-relaxed">
|
||||||
|
I’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 — and when it doesn’t, you
|
||||||
|
can’t ship a single box.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
I’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’s the part of
|
||||||
|
this work I take real pride in — 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’ 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 — 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
112
new-site/src/app/blog/[slug]/page.tsx
Normal file
112
new-site/src/app/blog/[slug]/page.tsx
Normal 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"
|
||||||
|
>
|
||||||
|
← 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>·</span>
|
||||||
|
<span>{metadata.readingMinutes} min read</span>
|
||||||
|
{metadata.tags?.length ? (
|
||||||
|
<>
|
||||||
|
<span>·</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
52
new-site/src/app/blog/page.tsx
Normal file
52
new-site/src/app/blog/page.tsx
Normal 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>·</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
95
new-site/src/app/contact/page.tsx
Normal file
95
new-site/src/app/contact/page.tsx
Normal 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’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 — just a
|
||||||
|
real conversation about what you’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’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 →
|
||||||
|
</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 · 1887 Whitney Mesa Dr. PMB 7325
|
||||||
|
· Henderson, NV 89014 · 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
BIN
new-site/src/app/favicon.ico
Normal file
BIN
new-site/src/app/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
141
new-site/src/app/globals.css
Normal file
141
new-site/src/app/globals.css
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
60
new-site/src/app/layout.tsx
Normal file
60
new-site/src/app/layout.tsx
Normal 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
140
new-site/src/app/page.tsx
Normal 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 · 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’m Chris Smith. I’ve spent fifteen years building
|
||||||
|
the unglamorous, mission-critical glue between online stores,
|
||||||
|
warehouses, and back offices — 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 →
|
||||||
|
</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 — 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 →
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</Container>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<PricingTeaser />
|
||||||
|
|
||||||
|
<Testimonials />
|
||||||
|
|
||||||
|
<Guarantees />
|
||||||
|
|
||||||
|
{/* CTA */}
|
||||||
|
<section className="py-24 sm:py-32">
|
||||||
|
<Container width="narrow" className="text-center">
|
||||||
|
<Eyebrow>Let’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’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’s broken or
|
||||||
|
stuck and I’ll tell you whether I can help — 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
16
new-site/src/app/robots.ts
Normal file
16
new-site/src/app/robots.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
373
new-site/src/app/services/page.tsx
Normal file
373
new-site/src/app/services/page.tsx
Normal 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>{" "}
|
||||||
|
— check out below, or book a call first if
|
||||||
|
you’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 →
|
||||||
|
</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 — 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’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’re
|
||||||
|
committing to. Retainer and block prices are listed above
|
||||||
|
and you can check out directly — 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’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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
33
new-site/src/app/sitemap.ts
Normal file
33
new-site/src/app/sitemap.ts
Normal 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];
|
||||||
|
}
|
||||||
107
new-site/src/app/thank-you/page.tsx
Normal file
107
new-site/src/app/thank-you/page.tsx
Normal 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’re in. Thank you.
|
||||||
|
</h1>
|
||||||
|
<p className="mt-7 text-lg leading-relaxed text-ink-soft">
|
||||||
|
Stripe has the receipt on the way. Here’s what happens
|
||||||
|
next — 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 →
|
||||||
|
</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’ll send a short onboarding note within one
|
||||||
|
business day — what to expect, how I track time,
|
||||||
|
and a one-page mutual NDA if you’d like one. If
|
||||||
|
you don’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>
|
||||||
|
);
|
||||||
|
}
|
||||||
207
new-site/src/app/work/[slug]/page.tsx
Normal file
207
new-site/src/app/work/[slug]/page.tsx
Normal 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 — on time, with the right items, to the right
|
||||||
|
address, in the right shipping window.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
That sounds simple. It isn’t. There’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’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
|
||||||
|
“where is my order?” 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’s been a multi-year relationship</h2>
|
||||||
|
<p>
|
||||||
|
This work isn’t glamorous, but it’s the difference
|
||||||
|
between Pritikin shipping orders today and not. Emil
|
||||||
|
Boschert’s words say it best:
|
||||||
|
</p>
|
||||||
|
<blockquote>
|
||||||
|
“They are vital for our e-commerce site and our 3PL.”
|
||||||
|
</blockquote>
|
||||||
|
<p>
|
||||||
|
That’s the bar I aim for on every long engagement —
|
||||||
|
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
|
||||||
|
“small” 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
|
||||||
|
— in the words of Americold’s Timothy Dorcas:
|
||||||
|
</p>
|
||||||
|
<blockquote>
|
||||||
|
“They are solution oriented, customer focused, and
|
||||||
|
consistently deliver at a high level. Highly recommended.”
|
||||||
|
</blockquote>
|
||||||
|
|
||||||
|
<h2>Why this kind of work fits Five Devs</h2>
|
||||||
|
<p>
|
||||||
|
Enterprise logistics teams don’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 — without three layers of project management
|
||||||
|
between them and the work.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
That’s the role I’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"
|
||||||
|
>
|
||||||
|
← 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
65
new-site/src/app/work/page.tsx
Normal file
65
new-site/src/app/work/page.tsx
Normal 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’m most proud of. These are the
|
||||||
|
relationships that lasted — 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 →
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<p className="mt-12 max-w-xl text-sm text-muted">
|
||||||
|
Several long-running engagements aren’t listed here under
|
||||||
|
NDA. Happy to share more on a call.
|
||||||
|
</p>
|
||||||
|
</Container>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
42
new-site/src/components/button.tsx
Normal file
42
new-site/src/components/button.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
67
new-site/src/components/client-logos.tsx
Normal file
67
new-site/src/components/client-logos.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
103
new-site/src/components/contact-form.tsx
Normal file
103
new-site/src/components/contact-form.tsx
Normal 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’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 →
|
||||||
|
</button>
|
||||||
|
<p className="text-sm text-muted">
|
||||||
|
Opens in your email client.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
27
new-site/src/components/container.tsx
Normal file
27
new-site/src/components/container.tsx
Normal 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,
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
120
new-site/src/components/guarantees.tsx
Normal file
120
new-site/src/components/guarantees.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
40
new-site/src/components/how-i-work.tsx
Normal file
40
new-site/src/components/how-i-work.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
12
new-site/src/components/jsonld.tsx
Normal file
12
new-site/src/components/jsonld.tsx
Normal 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) }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
126
new-site/src/components/pricing-teaser.tsx
Normal file
126
new-site/src/components/pricing-teaser.tsx
Normal 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’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 →
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
50
new-site/src/components/section-heading.tsx
Normal file
50
new-site/src/components/section-heading.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
52
new-site/src/components/site-footer.tsx
Normal file
52
new-site/src/components/site-footer.tsx
Normal 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>
|
||||||
|
© {year} Five Devs, LLC · Henderson, NV
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Container>
|
||||||
|
</footer>
|
||||||
|
);
|
||||||
|
}
|
||||||
43
new-site/src/components/site-header.tsx
Normal file
43
new-site/src/components/site-header.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
59
new-site/src/components/testimonials.tsx
Normal file
59
new-site/src/components/testimonials.tsx
Normal 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">
|
||||||
|
“{t.quote}”
|
||||||
|
</p>
|
||||||
|
<p className="mt-5 text-sm text-muted">
|
||||||
|
<span className="font-medium text-ink">{t.name}</span>{" "}
|
||||||
|
· {t.role}
|
||||||
|
</p>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</Container>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
81
new-site/src/content/blog/how-to-hire-a-php-dev.mdx
Normal file
81
new-site/src/content/blog/how-to-hire-a-php-dev.mdx
Normal 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.
|
||||||
44
new-site/src/content/blog/the-glue-job.mdx
Normal file
44
new-site/src/content/blog/the-glue-job.mdx
Normal 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.
|
||||||
48
new-site/src/content/blog/three-pl-integration-checklist.mdx
Normal file
48
new-site/src/content/blog/three-pl-integration-checklist.mdx
Normal 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.
|
||||||
37
new-site/src/lib/case-studies.ts
Normal file
37
new-site/src/lib/case-studies.ts
Normal 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 · 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 · 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
5
new-site/src/lib/cn.ts
Normal 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
174
new-site/src/lib/jsonld.ts
Normal 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
58
new-site/src/lib/posts.ts
Normal 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
110
new-site/src/lib/pricing.ts
Normal 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
19
new-site/src/lib/site.ts
Normal 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
34
new-site/tsconfig.json
Normal 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"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user