Compare commits

...

4 Commits

Author SHA1 Message Date
c25d568149 add gitea actions workflow for vercel auto-deploy
Some checks failed
Deploy to Vercel / deploy (push) Has been cancelled
On push to master, Gitea Actions checks out, installs deps, type-
checks, and runs `vercel deploy --prod`. See .gitea/RUNNER_SETUP.md
for one-time setup (act_runner install, Vercel token, three Gitea
secrets).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 13:28:41 +02:00
a7c05104af flatten new-site/ to repo root and remove old hugo site
Moves the Next.js app's contents from new-site/ to the repository
root and deletes the previous Hugo site (assets/, content/, themes/,
hugo.toml, etc.). Also retires the AWS Amplify config and old
Netlify _redirects file — the new site deploys to Vercel.

Updates STRATEGY.md path references to drop the new-site/ prefix.
LICENSE preserved.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 13:14:01 +02:00
3b5cd5d58d add vercel analytics to new-site/
Wires @vercel/analytics into the root layout. Free tier on Hobby
plan covers the first ~2,500 events/mo. No cookie banner needed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 12:28:12 +02:00
f96adbc99a 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>
2026-04-30 12:22:37 +02:00
141 changed files with 8944 additions and 16162 deletions

110
.gitea/RUNNER_SETUP.md Normal file
View File

@@ -0,0 +1,110 @@
# Gitea Actions runner + Vercel auto-deploy
Pushes to `master` on Gitea trigger
[`.gitea/workflows/deploy.yml`](workflows/deploy.yml), which type-checks
the project and ships to Vercel production.
You only need to do this setup once.
---
## 1. Enable Actions on the Gitea instance
If you've never used Actions on this Gitea before, enable it in
`/etc/gitea/app.ini` (path varies by install):
```ini
[actions]
ENABLED = true
DEFAULT_ACTIONS_URL = https://github.com
```
Restart Gitea (`systemctl restart gitea` or however you run it).
Then on the **repo** in Gitea: Settings → Actions → Enable Actions
for this repository.
## 2. Install a Gitea Actions runner on the Debian box
Gitea ships its own runner binary called `act_runner`. SSH into your
Debian box and:
```bash
# Download latest act_runner (check https://gitea.com/gitea/act_runner/releases for newest)
wget -O act_runner https://dl.gitea.com/act_runner/act_runner-linux-amd64
chmod +x act_runner
sudo mv act_runner /usr/local/bin/
# Generate config
act_runner generate-config > config.yaml
# Register with your Gitea instance
# Get a registration token from: https://git.sometimescode.com/-/admin/actions/runners
# (admin level — registers a global runner) OR from the repo settings (repo-scoped)
act_runner register --no-interactive \
--instance https://git.sometimescode.com \
--token <REGISTRATION_TOKEN> \
--name debian-runner \
--labels ubuntu-latest:docker://node:24-bookworm,self-hosted
# Run as a service (systemd)
sudo cp /usr/local/bin/act_runner /etc/systemd/system/
# (Gitea docs walk through the .service file; or just run in tmux for now)
act_runner daemon
```
The `--labels ubuntu-latest:docker://node:24-bookworm` line tells the
runner: when a workflow says `runs-on: ubuntu-latest`, use the
`node:24-bookworm` Docker image. That image has Node 24 + git
preinstalled, which is what our workflow needs.
Verify it's online: Gitea → Repo → Settings → Actions → Runners
should show one online runner.
## 3. Get a Vercel API token
1. https://vercel.com/account/tokens
2. Create Token, name it "Gitea fivedevs deploy"
3. Scope: full account (or just the `fivedevs` project if Vercel
supports per-project tokens on your plan)
4. Copy the token — you'll only see it once
## 4. Add the three secrets to Gitea
Repo → Settings → Secrets → New Secret. Add three:
| Secret name | Value |
|-----------------------|--------------------------------------------------|
| `VERCEL_TOKEN` | The token from step 3 |
| `VERCEL_ORG_ID` | `team_dvgkcoMxfZwVhSau0vhTeT1I` |
| `VERCEL_PROJECT_ID` | `prj_QVFJhWqmkrzGVP0HBsJJrDxQ96c3` |
(The org and project IDs come from `.vercel/project.json` in this
repo. They're not sensitive but treating them as secrets keeps the
workflow file portable.)
## 5. Test it
Push any tiny change to master. Within ~10 seconds:
- Gitea: Repo → Actions tab → you should see a workflow run
- After ~2-3 min: Vercel dashboard → fivedevs project → new
deployment with the matching commit SHA
If the workflow fails, click into it for logs. Common issues:
- **`vercel deploy` fails with "missing project"** → double-check
`VERCEL_ORG_ID` / `VERCEL_PROJECT_ID` secret values
- **`pnpm: command not found`** → runner isn't using a Node-equipped
image; revisit step 2's `--labels` line
- **Workflow doesn't trigger at all** → repo Settings → Actions
isn't enabled
## What the workflow does
1. Checks out the repo (full history)
2. Installs pnpm + Node 24 with dependency caching
3. `pnpm install --frozen-lockfile`
4. `tsc --noEmit` to fail fast on type errors
5. `vercel deploy --prod --yes` — uploads source to Vercel, Vercel
builds & deploys, returns the prod URL
The whole pipeline is ~2 min on a warm cache.

View File

@@ -0,0 +1,35 @@
name: Deploy to Vercel
on:
push:
branches: [master]
jobs:
deploy:
runs-on: ubuntu-latest
env:
VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }}
VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 10
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 24
cache: pnpm
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Type check
run: pnpm exec tsc --noEmit
- name: Deploy to Vercel
run: npx vercel deploy --prod --yes --token=${{ secrets.VERCEL_TOKEN }}

58
.gitignore vendored Executable file → Normal file
View File

@@ -1,19 +1,41 @@
Thumbs.db
# 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
.dist
.tmp
.lock
.sass-cache
npm-debug.log
node_modules
builds
package-lock.json
public
resources
.hugo_build.lock
jsconfig.json
hugo_stats.json
go.sum
yarn.lock
.idea
.vscode
*.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

View File

@@ -1,59 +0,0 @@
{
"maxerr": 50,
"bitwise": true,
"camelcase": false,
"curly": true,
"eqeqeq": true,
"forin": true,
"freeze": true,
"immed": true,
"indent": 2,
"latedef": true,
"newcap": false,
"noarg": true,
"noempty": true,
"nonbsp": true,
"nonew": true,
"plusplus": false,
"undef": true,
"unused": false,
"strict": true,
"maxparams": false,
"maxdepth": 4,
"maxstatements": false,
"maxcomplexity": false,
"maxlen": 400,
"browser": true,
"devel": true,
"asi": false,
"boss": false,
"debug": false,
"eqnull": false,
"es3": false,
"es5": false,
"esversion": 12,
"moz": false,
"evil": true,
"expr": true,
"funcscope": false,
"globalstrict": false,
"iterator": false,
"lastsemic": false,
"laxbreak": false,
"laxcomma": false,
"loopfunc": true,
"multistr": true,
"noyield": false,
"notypeof": false,
"proto": false,
"scripturl": false,
"shadow": false,
"sub": false,
"supernew": false,
"validthis": false,
"globals": {
"jQuery": false,
"google": false,
"$": false
}
}

View File

@@ -1,5 +0,0 @@
{
"MD033": false,
"MD034": false,
"MD013": false
}

View File

@@ -1,13 +0,0 @@
{
"plugins": ["prettier-plugin-go-template"],
"overrides": [
{
"files": ["*.html"],
"options": {
"parser": "go-template",
"goTemplateBracketSpacing": true,
"bracketSameLine": true
}
}
]
}

5
AGENTS.md Normal file
View File

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

1
CLAUDE.md Normal file
View File

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

36
README.md Normal file
View File

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

340
STRATEGY.md Normal file
View File

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

View File

@@ -1 +0,0 @@
/* /en/404.html 404

View File

@@ -1,29 +0,0 @@
version: 1
frontend:
phases:
preBuild:
commands:
- yum install -y curl
- curl -LO "https://github.com/gohugoio/hugo/releases/download/v0.134.3/hugo_extended_0.134.3_Linux-64bit.tar.gz"
- tar -xvf hugo_extended_0.134.3_Linux-64bit.tar.gz
- mv hugo /usr/local/bin/
- rm hugo_extended_0.134.3_Linux-64bit.tar.gz
- echo "HUGO 0.134.3 INSTALLED"
- curl -LO "https://dl.google.com/go/go1.22.2.linux-amd64.tar.gz"
- tar -C /usr/local -xzf go1.22.2.linux-amd64.tar.gz
- export PATH=$PATH:/usr/local/go/bin
- rm go1.22.2.linux-amd64.tar.gz
- echo "GO 1.22.2 INSTALLED"
- npm install
build:
commands:
- npm run project-setup
- npm run build
artifacts:
# IMPORTANT - Please verify your build output directory
baseDirectory: /public
files:
- "**/*"
cache:
paths:
- node_modules/**/*

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

View File

@@ -1 +0,0 @@
// Add your own custom styles here

View File

@@ -1,6 +0,0 @@
################ English language ##################
[en]
languageName = "En"
languageCode = "en-us"
contentDir = "content/english"
weight = 1

View File

@@ -1,24 +0,0 @@
############# English navigation ##############
# main menu
[[main]]
name = "Home"
url = "/"
weight = 1
[[main]]
name = "About"
url = "about/"
weight = 2
# footer menu
[[footer]]
name = "About"
url = "about/"
weight = 1
[[footer]]
name = "Privacy Policy"
url = "privacy-policy/"
weight = 3

View File

@@ -1,87 +0,0 @@
[hugoVersion]
extended = true
min = "0.134.3"
# [[imports]]
# path = "github.com/zeon-studio/hugoplate"
[[imports]]
path = "github.com/gethugothemes/hugo-modules/search"
[[imports]]
path = "github.com/gethugothemes/hugo-modules/pwa"
[[imports]]
path = "github.com/gethugothemes/hugo-modules/images"
[[imports]]
path = "github.com/gethugothemes/hugo-modules/videos"
[[imports]]
path = "github.com/gethugothemes/hugo-modules/icons/font-awesome"
# [[imports]]
# path = "github.com/gethugothemes/hugo-modules/icons/themify-icons"
[[imports]]
path = "github.com/gethugothemes/hugo-modules/gzip-caching"
[[imports]]
path = "github.com/gethugothemes/hugo-modules/adsense"
[[imports]]
path = "github.com/gethugothemes/hugo-modules/accordion"
[[imports]]
path = "github.com/gethugothemes/hugo-modules/table-of-contents"
[[imports]]
path = "github.com/gethugothemes/hugo-modules/tab"
[[imports]]
path = "github.com/gethugothemes/hugo-modules/modal"
[[imports]]
path = "github.com/gethugothemes/hugo-modules/gallery-slider"
[[imports]]
path = "github.com/gethugothemes/hugo-modules/components/preloader"
[[imports]]
path = "github.com/gethugothemes/hugo-modules/components/social-share"
[[imports]]
path = "github.com/gethugothemes/hugo-modules/components/cookie-consent"
[[imports]]
path = "github.com/gethugothemes/hugo-modules/components/announcement"
[[imports]]
path = "github.com/gethugothemes/hugo-modules/components/custom-script"
[[imports]]
path = "github.com/gethugothemes/hugo-modules/components/render-link"
# [[imports]]
# path = "github.com/gethugothemes/hugo-modules/components/valine-comment"
# [[imports]]
# path = "github.com/gethugothemes/hugo-modules/components/crisp-chat"
[[imports]]
path = "github.com/gethugothemes/hugo-modules/shortcodes/button"
[[imports]]
path = "github.com/gethugothemes/hugo-modules/shortcodes/notice"
[[imports]]
path = "github.com/gethugothemes/hugo-modules/seo-tools/basic-seo"
[[imports]]
path = "github.com/gethugothemes/hugo-modules/seo-tools/site-verifications"
[[imports]]
path = "github.com/gethugothemes/hugo-modules/seo-tools/google-tag-manager"
[[imports]]
path = "github.com/hugomods/mermaid"

View File

@@ -1,101 +0,0 @@
#################### default parameters ################################
# favicon module: https://github.com/gethugothemes/hugo-modules/tree/master/images#favicon-implementation
favicon = "images/favicon.png"
# logo module: https://github.com/gethugothemes/hugo-modules/tree/master/images#logo-implementation
logo = "images/logo.png"
logo_darkmode = "images/logo-darkmode.png"
# use `px` or `x` with logo_width, example: "100px".
# Note: logo_width is not work with .svg file
logo_width = "160px"
logo_height = "32px"
# if logo_webp set false, will not generate WEBP version of logo | default is true
logo_webp = true
# logo text will only show when logo is missing.
logo_text = "Hugoplate"
# navbar fixed to top
navbar_fixed = true
# theme-mode
theme_switcher = true
theme_default = "system" # available options [light/dark/system]
# Main Sections
mainSections = ["blog"]
# contact form action
contact_form_action = "https://formspree.io/f/myyrgrdd" # contact form works with [https://airform.io/] or [https://formspree.io]
# google tag manager, see https://developers.google.com/tag-manager/
google_tag_manager = "" # example: G-XXXXXXXXXX
google_adsense = "" # example: ca-pub-xxxxxxxxxxxxxxxx
# custom script on header, example: custom_script= "<script>console.log(\"Hello World\")</script>"
custom_script = ""
# copyright
copyright = "&copy; 2024 Five Devs, LLC"
# Preloader
# preloader module: https://github.com/gethugothemes/hugo-modules/tree/master/components/preloader
[preloader]
enable = false
preloader = "" # use jpg, png, svg or gif format.
# Navigation button
[navigation_button]
enable = true
label = "get a quote"
link = "contact"
# search
# search module: https://github.com/gethugothemes/hugo-modules/tree/master/search
[search]
enable = false
primary_color = "#121212"
include_sections = ["blog"]
show_image = true
show_description = true
show_tags = true
show_categories = true
# seo meta data for OpenGraph / Twitter Card
# seo module: https://github.com/gethugothemes/hugo-modules/tree/master/seo-tools/basic-seo
[metadata]
keywords = ["Boilerplate", "Hugo"]
description = "Hugo & Tailwindcss Starter"
author = "zeon.studio"
image = "images/og-image.png"
# site verifications
# verification module: https://github.com/gethugothemes/hugo-modules/tree/master/seo-tools/site-verifications
[site_verification]
google = "" # Your verification code
bing = "" # Your verification code
baidu = "" # Your verification code
facebook = "" # Your verification code
mastodon = "" # Your verification code
# cookies
# cookies module: https://github.com/gethugothemes/hugo-modules/tree/master/components/cookie-consent
[cookies]
enable = false
expire_days = 60
content = "This site uses cookies. By continuing to use this website, you agree to their use."
button = "I Accept"
######################## sidebar widgets #########################
[widgets]
sidebar = ["categories", "tags"]
# google map
[google_map]
enable = false
map_api_key = "AIzaSyCcABaamniA6OL5YvYSpB3pFMNrXwXnLwU"
map_latitude = "51.5223477"
map_longitude = "-0.1622023"
map_marker = "images/marker.png"
# Subscription
[subscription]
enable = false
# mailchimp subsciption
mailchimp_form_action = "https://gmail.us4.list-manage.com/subscribe/post?u=463ee871f45d2d93748e77cad&amp;id=a0a2c6d074" # replace this url with yours
mailchimp_form_name = "b_463ee871f45d2d93748e77cad_a0a2c6d074"

View File

@@ -1,13 +0,0 @@
# defaultContentLanguageInSubdir must be true for this to work.
# Other languages redirects
# [[redirects]]
# from = '/fr/**'
# to = '/fr/404.html'
# status = 404
# Default language must be last.
[[redirects]]
from = '/**'
to = '/en/404.html'
status = 404

View File

@@ -1,12 +0,0 @@
---
# Banner
banner:
title: "PHP Experts Helping Small Shops or Large Fortune 500 Companies"
content: "Five Devs specializes in software development. We fix small issues and tackle large projects to help you save valuable time."
image: "/images/banner.png"
button:
enable: true
label: "Let's Talk!"
link: "/contact"
---

View File

@@ -1,24 +0,0 @@
---
title: "👋 I'm Chris!"
meta_title: "About"
description: "this is meta description"
image: "/images/avatar.png"
draft: false
---
I have been doing software development **since 2010**. I started working with PHP with local businesses and joined
multiple teams. I started **Five Devs, LLC** to help small and large businesses streamline their operations. If you have
a project that is just at the idea stage or an existing problem on a large application - I can help!
### 📬 Sending a letter?
Prefer to do things the old fashioned way? Sure thing! Just no fax 📠
```php
/**
Five Devs, LLC
1887 Whitney Mesa Dr. Pmb 7325
Henderson, NV 89014
USA
*/
```

View File

@@ -1,3 +0,0 @@
---
title: "Authors"
---

View File

@@ -1,5 +0,0 @@
---
title: "Blog Posts"
meta_title: ""
description: "this is meta description"
---

View File

@@ -1,23 +0,0 @@
---
title: "How to build an Application with modern Technology"
meta_title: ""
description: "this is meta description"
date: 2022-04-04T05:00:00Z
image: "/images/image-placeholder.png"
categories: ["Application", "Data"]
author: "John Doe"
tags: ["nextjs", "tailwind"]
draft: false
---
Nemo vel ad consectetur namut rutrum ex, venenatis sollicitudin urna. Aliquam erat volutpat. Integer eu ipsum sem. Ut bibendum lacus vestibulum maximus suscipit. Quisque vitae nibh iaculis neque blandit euismod.
Lorem ipsum dolor sit amet consectetur adipisicing elit. Nemo vel ad consectetur ut aperiam. Itaque eligendi natus aperiam? Excepturi repellendus consequatur quibusdam optio expedita praesentium est adipisci dolorem ut eius!
## Creative Design
Nam ut rutrum ex, venenatis sollicitudin urna. Aliquam erat volutpat. Integer eu ipsum sem. Ut bibendum lacus vestibulum maximus suscipit. Quisque vitae nibh iaculis neque blandit euismod.
> Lorem ipsum dolor sit amet consectetur adipisicing elit. Nemo vel ad consectetur ut aperiam. Itaque eligendi natus aperiam? Excepturi repellendus consequatur quibusdam optio expedita praesentium est adipisci dolorem ut eius!
Lorem ipsum dolor sit amet consectetur adipisicing elit. Nemo vel ad consectetur ut aperiam. Itaque eligendi natus aperiam? Excepturi repellendus consequatur quibusdam optio expedita praesentium est adipisci dolorem ut eius!

View File

@@ -1,6 +0,0 @@
---
title: "Contact"
meta_title: ""
description: "this is meta description"
draft: false
---

View File

@@ -1,42 +0,0 @@
---
title: "Privacy Policy"
# meta title
meta_title: ""
# meta description
description: "This is our company privacy policy"
# save as draft
draft: false
---
Five Devs, LLC takes your privacy seriously. To better protect your privacy we provide this privacy policy notice explaining the way your personal information is collected and used.
## Collection of Routine Information
This website track basic information about their visitors. This information includes, but is not limited to, IP addresses, browser details, timestamps and referring pages. None of this information can personally identify specific visitors to this website. The information is tracked for routine administration and maintenance purposes. We use Amazon Amplify for site hosting if you're super curious and want to check out their privacy policies.
## Cookies
We don't use cookies. They are tasty though.
## Advertisement and Other Third Parties
We don't use advertising or have third party tracking. The pages are just HTML pages rendered by Amazon Amplify when updated on GitHub.
## Links to Third Party Websites
We have included links on this website for your use and reference. We are not responsible for the privacy policies on these websites. You should be aware that the privacy policies of these websites may differ from our own.
## Security
The security of your personal information is important to us, but remember that no method of transmission over the Internet, or method of electronic storage, is 100% secure. While we strive to use commercially acceptable means to protect your personal information, we cannot guarantee its absolute security.
## Changes To This Privacy Policy
This Privacy Policy is effective as of June 27, 2024 and will remain in effect except with respect to any changes in its provisions in the future, which will be in effect immediately after being posted on this page.
We reserve the right to update or change our Privacy Policy at any time and you should check this Privacy Policy periodically. If we make any material changes to this Privacy Policy, we will notify you by placing a prominent notice on our website.
## Contact Information
For any questions or concerns regarding the privacy policy, please send us an email using the contact form on our website or by mailing to a letter to our address.

View File

@@ -1,14 +0,0 @@
---
enable: true
title: "Ready to build your next project with Hugo?"
image: "/images/call-to-action.png"
description: "Experience the future of web development with Hugoplate and Hugo. Build lightning-fast static sites with ease and flexibility."
button:
enable: true
label: "Get Started Now"
link: "https://github.com/zeon-studio/hugoplate"
# don't create a separate page
_build:
render: "never"
---

View File

@@ -1,31 +0,0 @@
---
enable: true
title: "What Others Are Saying About Five Devs"
description: "Don't just take our word for it - hear from some of our satisfied users! Check out some of our testimonials below to see what others are saying about Hugoplate."
# Testimonials
testimonials:
- name: "Emil Boschert"
designation: "Pritikin Foods"
avatar: "/images/avatar-sm.png"
content: "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: "Katie Allen"
designation: "Fortune Fulfillment"
avatar: "/images/avatar-sm.png"
content: "I have been working with this Five Devs on multiple customer accounts for several years. Chris makes the set-up process flow so much easier!"
- name: "Tom Deppe"
designation: "HD Financial"
avatar: "/images/avatar-sm.png"
content: "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."
- name: "Timothy Dorcas"
designation: "Americold"
avatar: "/images/avatar-sm.png"
content: "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."
# don't create a separate page
_build:
render: "never"
---

View File

@@ -1,19 +0,0 @@
{
"main": [
{
"name": "twitter",
"icon": "fab fa-x-twitter",
"link": "https://twitter.com/cgsmith105"
},
{
"name": "github",
"icon": "fab fa-github",
"link": "https://github.com/cgsmith/fivedevs.com"
},
{
"name": "linkedin",
"icon": "fab fa-linkedin",
"link": "https://www.linkedin.com/in/phpguy"
}
]
}

View File

@@ -1,44 +0,0 @@
{
"colors": {
"default": {
"theme_color": {
"primary": "#121212",
"body": "#fff",
"border": "#eaeaea",
"theme_light": "#f6f6f6",
"theme_dark": ""
},
"text_color": {
"default": "#444444",
"dark": "#040404",
"light": "#717171"
}
},
"darkmode": {
"theme_color": {
"primary": "#fff",
"body": "#1c1c1c",
"border": "#3E3E3E",
"theme_light": "#222222",
"theme_dark": ""
},
"text_color": {
"default": "#B4AFB6",
"dark": "#fff",
"light": "#B4AFB6"
}
}
},
"fonts": {
"font_family": {
"primary": "Heebo:wght@400;600",
"primary_type": "sans-serif",
"secondary": "Signika:wght@500;700",
"secondary_type": "sans-serif"
},
"font_size": {
"base": "16",
"scale": "1.250"
}
}
}

18
eslint.config.mjs Normal file
View File

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

31
go.mod
View File

@@ -1,31 +0,0 @@
module hugoplate.netlify.app
go 1.21
require (
github.com/gethugothemes/hugo-modules/accordion v0.0.0-20240925042433-d2b5d05977e8 // indirect
github.com/gethugothemes/hugo-modules/adsense v0.0.0-20240925042433-d2b5d05977e8 // indirect
github.com/gethugothemes/hugo-modules/components/announcement v0.0.0-20240925042433-d2b5d05977e8 // indirect
github.com/gethugothemes/hugo-modules/components/cookie-consent v0.0.0-20240925042433-d2b5d05977e8 // indirect
github.com/gethugothemes/hugo-modules/components/custom-script v0.0.0-20240925042433-d2b5d05977e8 // indirect
github.com/gethugothemes/hugo-modules/components/preloader v0.0.0-20240925042433-d2b5d05977e8 // indirect
github.com/gethugothemes/hugo-modules/components/render-link v0.0.0-20240925042433-d2b5d05977e8 // indirect
github.com/gethugothemes/hugo-modules/components/social-share v0.0.0-20240925042433-d2b5d05977e8 // indirect
github.com/gethugothemes/hugo-modules/gallery-slider v0.0.0-20240925042433-d2b5d05977e8 // indirect
github.com/gethugothemes/hugo-modules/gzip-caching v0.0.0-20240925042433-d2b5d05977e8 // indirect
github.com/gethugothemes/hugo-modules/icons/font-awesome v0.0.0-20240925042433-d2b5d05977e8 // indirect
github.com/gethugothemes/hugo-modules/images v0.0.0-20240925042433-d2b5d05977e8 // indirect
github.com/gethugothemes/hugo-modules/modal v0.0.0-20240925042433-d2b5d05977e8 // indirect
github.com/gethugothemes/hugo-modules/pwa v0.0.0-20240925042433-d2b5d05977e8 // indirect
github.com/gethugothemes/hugo-modules/search v0.0.0-20240925042433-d2b5d05977e8 // indirect
github.com/gethugothemes/hugo-modules/seo-tools/basic-seo v0.0.0-20240925042433-d2b5d05977e8 // indirect
github.com/gethugothemes/hugo-modules/seo-tools/google-tag-manager v0.0.0-20240925042433-d2b5d05977e8 // indirect
github.com/gethugothemes/hugo-modules/seo-tools/site-verifications v0.0.0-20240925042433-d2b5d05977e8 // indirect
github.com/gethugothemes/hugo-modules/shortcodes/button v0.0.0-20240925042433-d2b5d05977e8 // indirect
github.com/gethugothemes/hugo-modules/shortcodes/notice v0.0.0-20240925042433-d2b5d05977e8 // indirect
github.com/gethugothemes/hugo-modules/tab v0.0.0-20240925042433-d2b5d05977e8 // indirect
github.com/gethugothemes/hugo-modules/table-of-contents v0.0.0-20240925042433-d2b5d05977e8 // indirect
github.com/gethugothemes/hugo-modules/videos v0.0.0-20240925042433-d2b5d05977e8 // indirect
github.com/hugomods/mermaid v0.1.4 // indirect
github.com/zeon-studio/hugoplate v0.0.0-20240925044951-fe74d0e62893 // indirect
)

178
hugo.toml
View File

@@ -1,178 +0,0 @@
######################## default configuration ####################
# The base URL of your site (required). This will be prepended to all relative URLs.
baseURL = "/"
# Title of your website (required).
title = "Five Devs"
# Your theme name
theme = "hugoplate"
# Default time zone for time stamps; use any valid tz database name: https://en.wikipedia.org/wiki/List_of_tz_database_time_zones#List
timeZone = "America/Chicago"
# post excerpt
summaryLength = 10 # see https://gohugo.io/content-management/excerpts/
# disable language
disableLanguages = [
] # example: ["fr"] for disable french language. see https://gohugo.io/content-management/multilingual/
hasCJKLanguage = false # If hasCJKLanguage true, auto-detect Chinese/Japanese/Korean Languages in the content. see: https://gohugo.io/getting-started/configuration/#hascjklanguage
# default language
defaultContentLanguage = 'en'
# defaultContentLanguageInSubdir need to be true if you want to use the language code as a subdirectory and language specific 404 page
defaultContentLanguageInSubdir = false
########################### Services #############################
[services]
[services.googleAnalytics]
ID = 'G-MEASUREMENT_ID' # see https://gohugo.io/templates/internal/#configure-google-analytics
[services.disqus]
shortname = 'themefisher-template' # we use disqus to show comments in blog posts . To install disqus please follow this tutorial https://portfolio.peter-baumgartner.net/2017/09/10/how-to-install-disqus-on-hugo/
########################## Permalinks ############################
[permalinks.page]
"pages" = "/:slugorfilename/"
########################## Pagination ############################
[pagination]
disableAliases = false
pagerSize = 10
path = 'page'
############################# Modules ############################
[module]
[[module.mounts]]
source = "assets"
target = "assets"
[[module.mounts]]
source = "hugo_stats.json"
target = "assets/watching/hugo_stats.json"
############################# Build ##############################
[build]
noJSConfigInAssets = false
useResourceCacheWhen = 'fallback'
[build.buildStats]
enable = true
[[build.cachebusters]]
source = 'assets/watching/hugo_stats\.json'
target = 'style\.css'
[[build.cachebusters]]
source = '(postcss|tailwind)\.config\.js'
target = 'css'
[[build.cachebusters]]
source = 'assets/.*\.(js|ts|jsx|tsx)'
target = 'js'
[[build.cachebusters]]
source = 'assets/.*\.(css|scss|sass)'
target = 'css'
[[build.cachebusters]]
source = 'data/.*\.(.*)$'
target = 'css'
[[build.cachebusters]]
source = 'assets/.*\.(.*)$'
target = '$1'
############################# Outputs ############################
[outputs]
home = ["HTML", "RSS", "WebAppManifest", "SearchIndex"]
############################# Imaging ############################
[imaging]
# See https://github.com/disintegration/imaging
# Default JPEG or WebP quality setting. Default is 75.
quality = 80
resampleFilter = "Lanczos"
############################ Caches ##############################
[caches]
[caches.images]
dir = ":resourceDir/_gen"
maxAge = "720h"
[caches.assets]
dir = ":resourceDir/_gen"
maxAge = "720h"
############################ Markup ##############################
[markup]
[markup.goldmark.renderer]
unsafe = true
[markup.highlight]
style = 'monokai' # see https://xyproto.github.io/splash/docs/all.html
[markup.tableOfContents]
startLevel = 2
endLevel = 5
ordered = true
########################### Media types ###########################
[mediaTypes]
[mediaTypes."application/manifest+json"]
suffixes = ["webmanifest"]
########################### Output Format ##########################
[outputFormats]
[outputFormats.WebAppManifest]
mediaType = "application/manifest+json"
rel = "manifest"
[outputFormats.SearchIndex]
mediaType = "application/json"
baseName = "searchindex"
isPlainText = true
notAlternative = true
############################# Plugins ##############################
# CSS Plugins
[[params.plugins.css]]
link = "plugins/swiper/swiper-bundle.css"
lazy = true
[[params.plugins.css]]
link = "plugins/glightbox/glightbox.css"
lazy = true
[[params.plugins.css]]
link = "plugins/font-awesome/v6/brands.css"
lazy = true
[[params.plugins.css]]
link = "plugins/font-awesome/v6/solid.css"
lazy = true
[[params.plugins.css]]
link = "plugins/font-awesome/v6/icons.css"
lazy = true
# JS Plugins
[[params.plugins.js]]
link = "js/search.js"
lazy = false
[[params.plugins.js]]
link = "plugins/swiper/swiper-bundle.js"
lazy = false
[[params.plugins.js]]
link = "plugins/cookie.js"
lazy = false
[[params.plugins.js]]
link = "plugins/glightbox/glightbox.js"
lazy = true
[[params.plugins.js]]
link = "js/gallery-slider.js"
lazy = true
[[params.plugins.js]]
link = "js/accordion.js"
lazy = true
[[params.plugins.js]]
link = "js/tab.js"
lazy = true
[[params.plugins.js]]
link = "js/modal.js"
lazy = true
[[params.plugins.js]]
link = "plugins/youtube-lite.js"
lazy = true

View File

@@ -1,14 +0,0 @@
home: Home
read_more: Read More
send: Send
related_posts: Related Posts
categories: Categories
tags: Tags
toc: Table of Contents
share: Share
search_input_placeholder: Search Post...
search_no_results: No results for
search_initial_message: Type something to search..
search_navigate: to navigate
search_select: to select
search_close: to close

5
mdx-components.tsx Normal file
View File

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

18
next.config.ts Normal file
View File

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

View File

@@ -1,39 +1,34 @@
{
"name": "hugoplate",
"description": "hugo tailwindcss boilerplate",
"version": "1.17.0",
"license": "MIT",
"author": "zeon.studio",
"name": "new-site",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "hugo server",
"build": "hugo --gc --minify --templateMetrics --templateMetricsHints --forceSyncStatic",
"preview": "hugo server --disableFastRender --navigateToChanged --templateMetrics --templateMetricsHints --watch --forceSyncStatic -e production --minify",
"dev:example": "cd exampleSite && hugo server",
"build:example": "cd exampleSite && hugo --gc --minify --templateMetrics --templateMetricsHints --forceSyncStatic",
"preview:example": "cd exampleSite && hugo server --disableFastRender --navigateToChanged --templateMetrics --templateMetricsHints --watch --forceSyncStatic -e production --minify",
"update-modules": "node ./scripts/clearModules.js && hugo mod clean --all && hugo mod get -u ./... && hugo mod tidy",
"remove-darkmode": "node ./scripts/removeDarkmode.js && yarn format",
"project-setup": "node ./scripts/projectSetup.js",
"theme-setup": "node ./scripts/themeSetup.js",
"update-theme": "node ./scripts/themeUpdate.js",
"format": "prettier -w ."
"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",
"@vercel/analytics": "^2.0.1",
"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/forms": "^0.5.9",
"@tailwindcss/typography": "^0.5.15",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.47",
"postcss-cli": "^11.0.0",
"prettier": "^3.3.3",
"prettier-plugin-go-template": "0.0.15",
"prettier-plugin-tailwindcss": "^0.6.8",
"tailwind-bootstrap-grid": "^5.1.0",
"tailwindcss": "^3.4.13"
},
"postcss": {
"plugins": {
"tailwindcss": {},
"autoprefixer": {}
}
"@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"
}
}

5414
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

3
pnpm-workspace.yaml Normal file
View File

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

View File

@@ -1,44 +0,0 @@
const purgecss = {
content: ["./hugo_stats.json"],
defaultExtractor: (content) => {
const elements = JSON.parse(content).htmlElements;
return [
...(elements.tags || []),
...(elements.classes || []),
...(elements.ids || []),
];
},
safelist: [
/^swiper-/,
/^lb-/,
/^gl/,
/^go/,
/^gc/,
/^gs/,
/^gi/,
/^gz/,
/^gprev/,
/^gnext/,
/^desc/,
/^zoom/,
/^search/,
/^:is/,
/dark/,
/show/,
/dragging/,
/fullscreen/,
/loaded/,
/visible/,
/current/,
/active/,
],
};
module.exports = {
plugins: {
tailwindcss: {},
"@fullhuman/postcss-purgecss":
process.env.HUGO_ENVIRONMENT === "production" ? purgecss : false,
autoprefixer: process.env.HUGO_ENVIRONMENT === "production" ? {} : false,
},
};

7
postcss.config.mjs Normal file
View File

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

34
public/llms.txt Normal file
View File

@@ -0,0 +1,34 @@
# Five Devs
> 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.
Five Devs is the freelance PHP practice of Chris Smith, operating as Five Devs, LLC out of Henderson, Nevada. Engagements are structured as monthly retainers, time blocks (5/10/20 hours), or fixed-price project sprints, primarily for companies running e-commerce, third-party logistics (3PL), or operational tooling on PHP-based stacks.
Long-term clients have included Pritikin Foods, Americold, Fortune Fulfillment, HD Financial, Butcher Box, Perfect Bar, Vanderose Farms, Potawatomi Business Development, and PERC Engage.
## Pages
- [Home](https://fivedevs.com/): Service overview and positioning
- [About](https://fivedevs.com/about): Background, principles, and how Chris works
- [Services](https://fivedevs.com/services): Engagement options, pricing, and self-serve checkout
- [Work](https://fivedevs.com/work): Selected client engagements and case studies
- [Contact](https://fivedevs.com/contact): Book an intro call or send a message
## Writing
- [Why I take 'glue work' seriously (and you should too)](https://fivedevs.com/blog/the-glue-job): On the integration code that quietly keeps small businesses shipping
- [How to hire a senior PHP developer (without getting burned)](https://fivedevs.com/blog/how-to-hire-a-php-dev): Practical buyer's guide for non-technical founders
- [A pre-launch checklist for connecting your store to a 3PL](https://fivedevs.com/blog/three-pl-integration-checklist): Twelve things to verify before flipping the switch on a new fulfillment integration
## Case studies
- [Pritikin Foods](https://fivedevs.com/work/pritikin-foods): Multi-year e-commerce + 3PL shipping and order parsing pipeline
- [Americold](https://fivedevs.com/work/americold): Senior PHP work on customer-facing logistics tooling for one of the largest cold-chain warehousing networks in the US
## Contact
- Email: chris@fivedevs.com
- LinkedIn: https://www.linkedin.com/in/phpguy
- GitHub: https://github.com/cgsmith
- Twitter / X: https://twitter.com/cgsmith105
- Languages: English, German

239
readme.md
View File

@@ -1,239 +0,0 @@
<h1 align="center">Hugo + Tailwind CSS Starter and Boilerplate</h1>
<p align="center">Hugoplate is a free starter template built with Hugo, and TailwindCSS, providing everything you need to jumpstart your Hugo project and save valuable time.</p>
<p align="center">Made with ♥ by <a href="https://zeon.studio/"> Zeon Studio</a></p>
<p align=center> If you find this project useful, please give it a ⭐ to show your support.</p>
<h2 align="center"> <a target="_blank" href="https://zeon.studio/preview?project=hugoplate" rel="nofollow">👀 Demo</a> | <a target="_blank" href="https://pagespeed.web.dev/analysis/https-hugoplate-netlify-app/6lyxjw6t4r?form_factor=desktop">Page Speed (95+)🚀</a>
</h2>
<p align="center">
<a href="https://github.com/gohugoio/hugo/releases/tag/v0.126.0" alt="Contributors">
<img src="https://img.shields.io/static/v1?label=min-HUGO-version&message=0.126.0&color=f00&logo=hugo" />
</a>
<a href="https://github.com/zeon-studio/hugoplate/blob/main/LICENSE">
<img src="https://img.shields.io/github/license/zeon-studio/hugoplate" alt="license">
</a>
<a href="https://github.com/zeon-studio/hugoplate">
<img src="https://img.shields.io/github/languages/code-size/zeon-studio/hugoplate" alt="code size">
</a>
<a href="https://github.com/zeon-studio/hugoplate/graphs/contributors">
<img src="https://img.shields.io/github/contributors/zeon-studio/hugoplate" alt="contributors">
</a>
</p>
## 🎁 What's Included
We have included almost everything you need to start your Hugo project. Let's see what's included in this template:
### 📌 Key Features
- 👥 Multi-Authors
- 🎯 Similar Posts Suggestion
- 🔍 Search Functionality
- 🌑 Dark Mode
- 🏷️ Tags & Categories
- 🔗 Netlify setting pre-configured
- 📞 Support contact form
- 📱 Fully responsive
- 📝 Write and update content in Markdown
- 💬 Disqus Comment
- 🔳 Syntax Highlighting
### 📄 15+ Pre-designed Pages
- 🏠 Homepage
- 👤 About
- 📞 Contact
- 👥 Authors
- 👤 Author Single
- 📝 Blog
- 📝 Blog Single
- 🚫 Custom 404
- 💡 Elements
- 📄 Privacy Policy
- 🏷️ Tags
- 🏷️ Tag Single
- 🗂️ Categories
- 🗂️ Category Single
- 🔍 Search
### 📦 Tech Stack
- [Hugo](https://gohugo.io/)
- [Tailwind CSS](https://tailwindcss.com/)
- [PostCSS](https://postcss.org/)
- [PurgeCSS](https://purgecss.com/)
- [AutoPrefixer](https://autoprefixer.github.io/)
- [Hugo Modules](https://gohugo.io/hugo-modules/) by [Gethugothemes](https://gethugothemes.com/hugo-modules)
- [Markdown](https://markdownguide.org/)
- [Prettier](https://prettier.io/)
- [Jshint](https://jshint.com/)
- [Netlify](https://www.netlify.com/)
- [Vercel](https://vercel.com/)
- [Github Actions](https://github.com/features/actions)
- [Gitlab Ci](https://docs.gitlab.com/ee/ci/)
- [AWS Amplify](https://aws.amazon.com/amplify/)
---
## 🚀 Getting Started
First you need to [clone](https://github.com/zeon-studio/hugoplate) or [download](https://github.com/zeon-studio/hugoplate/archive/refs/heads/main.zip) the template repository, and then let's get started with the following process:
### ⚙️ Prerequisites
To start using this template, you need to have some prerequisites installed on your machine.
- [Hugo Extended v0.124+](https://gohugo.io/installation/)
- [Node v20+](https://nodejs.org/en/download/)
- [Go v1.22+](https://go.dev/doc/install)
### 👉 Project Setup
We build this custom script to make your project setup easier. It will create a new Hugo theme folder, and clone the Hugoplate theme into it. Then move the exampleSite folder into the root directory. So that you can start your Hugo server without going into the exampleSite folder. Use the following command to setup your project.
```bash
npm run project-setup
```
### 👉 Install Dependencies
Install all the dependencies using the following command.
```bash
npm install
```
### 👉 Development Command
Start the development server using the following command.
```bash
npm run dev
```
### 🎬 Still Confused? Watch a Quick Video
https://github.com/zeon-studio/hugoplate/assets/58769763/c260c0ae-91be-42ce-b8db-aa7f11f777bd
---
## 📝 Customization
This template has been designed with a lot of customization options in mind. You can customize almost anything you want, including:
### 👉 Site Config
You can change the site title, base URL, language, theme, plugins, and more from the `hugo.toml` file.
### 👉 Site Params
You can customize all the parameters from the `config/_default/params.toml` file. This includes the logo, favicon, search, SEO metadata, and more.
### 👉 Colors and Fonts
You can change the colors and fonts from the `data/theme.json` file. This includes the primary color, secondary color, font family, and font size.
### 👉 Social Links
You can change the social links from the `data/social.json` file. Add your social links here, and they will automatically be displayed on the site.
---
## 🛠 Advanced Usage
We have added some custom scripts to make your life easier. You can use these scripts to help you with your development.
### 👉 Update Theme
If you want to update the theme, then you can use the following command. It will update the theme to the latest version.
```bash
npm run update-theme
```
> **Note:** This command will work after running `project-setup` script.
### 👉 Update Modules
We have added a lot of modules to this template. You can update all the modules using the following command.
```bash
npm run update-modules
```
### 👉 Remove Dark Mode
If you want to remove dark mode from your project, you can use the following command to remove dark mode from your project.
```bash
npm run remove-darkmode
```
> **Note:** This command will work before running `project-setup` script. If you already run the `project-setup` command, then you have to run `npm run theme-setup` first, and then you can run this command. afterward, you can run `npm run project-setup` again.
---
## 🚀 Build And Deploy
After you finish your development, you can build or deploy your project almost everywhere. Let's see the process:
### 👉 Build Command
To build your project locally, you can use the following command. It will purge all the unused CSS and minify all the files.
```bash
npm run build
```
### 👉 Deploy Site
We have provided 5 different deploy platform configurations with this template, so you can deploy easily.
- [Netlify](https://www.netlify.com/)
- [Vercel](https://vercel.com/)
- [Github Actions](https://github.com/features/actions)
- [Gitlab Ci](https://docs.gitlab.com/ee/ci/)
- [AWS Amplify](https://aws.amazon.com/amplify/)
And if you want to Host some other hosting platforms. then you can build your project, and you will get a `public` folder. that you can copy and paste on your hosting platform.
> **Note:** You must change the `baseURL` in the `hugo.toml` file. Otherwise, your site will not work properly.
---
## 🔒 Guide to Staying Compliant
### 🐞 Reporting Issues
We use GitHub Issues as the official bug tracker for this Template. Please Search [existing issues](https://github.com/zeon-studio/hugoplate/issues). Its possible someone has already reported the same problem.
If your problem or idea has not been addressed yet, feel free to [open a new issue](https://github.com/zeon-studio/hugoplate/issues).
### 📝 License
Copyright (c) 2023 - Present, Designed & Developed by [Zeon Studio](https://zeon.studio/)
**Code License:** Released under the [MIT](https://github.com/zeon-studio/hugoplate/blob/main/LICENSE) license.
**Image license:** The images are only for demonstration purposes. They have their license, we don't have permission to share those images.
---
## 🖼️ Showcase
List of some projects people are building with **Hugoplate**!
| [![Open Neuromorphic](https://tinyurl.com/hp7avtje)](https://open-neuromorphic.org/) | [![AI Models](https://tinyurl.com/mu4p7dhb)](https://aimodels.org/) | [![Hugobricks](https://tinyurl.com/4x3uwhm9)](https://www.hugobricks.preview.usecue.com/) | [![ONO LLC](https://tinyurl.com/2fbjzwzn)](https://ono.day/)
|:---:|:---:|:---:|:---:|
| **Open Neuromorphic** | **AI Models** | **Hugobricks** | **ONO LLC** |
---
## 💻 Need Customization?
If you need a custom theme, theme customization, or complete website development services from scratch you can [Hire Us](https://zeon.studio/estimate-project).

View File

@@ -1,14 +0,0 @@
const fs = require("fs");
const clearModules = (filePath) => {
if (fs.existsSync(filePath)) {
let fileContent = fs.readFileSync(filePath, "utf8");
fileContent = fileContent.replace(/require\s*\([\s\S]*?\)/, "");
fs.writeFileSync(filePath, fileContent, "utf8");
} else {
console.log("File does not exist.");
}
};
clearModules("go.mod");
clearModules("exampleSite/go.mod");

View File

@@ -1,116 +0,0 @@
const fs = require("fs");
const path = require("path");
const toggleComment = ({ filepath, regex }) => {
let updatedContent = fs.readFileSync(filepath, "utf8");
const match = updatedContent.match(regex);
if (match) {
const matchedContent = match[0];
const hasComment = matchedContent.startsWith("# ");
if (hasComment) {
updatedContent = updatedContent.replace(
regex,
matchedContent.replace("# ", ""),
);
fs.writeFileSync(filepath, updatedContent, "utf8");
} else {
const hasBreakline = matchedContent.includes("\n");
if (hasBreakline) {
const content = matchedContent
.split("\n")
.map((line) => "# " + line)
.join("\n");
updatedContent = updatedContent.replace(regex, content);
fs.writeFileSync(filepath, updatedContent, "utf8");
}
}
}
};
const getFolderName = (rootfolder) => {
const configPath = path.join(rootfolder, "exampleSite/hugo.toml");
const getConfig = fs.readFileSync(configPath, "utf8");
const match = getConfig.match(/theme\s*=\s*\[?"([^"\]]+)"\]?/);
let selectedTheme = null;
if (match && match[1]) {
selectedTheme = match[1];
}
return selectedTheme;
};
const deleteFolder = (folderPath) => {
if (fs.existsSync(folderPath)) {
fs.rmSync(folderPath, { recursive: true, force: true });
}
};
const createNewfolder = (rootfolder, folderName) => {
const newFolder = path.join(rootfolder, folderName);
fs.mkdirSync(newFolder, { recursive: true });
return newFolder;
};
const iterateFilesAndFolders = (rootFolder, { destinationRoot }) => {
const directory = path.join(rootFolder);
const items = fs.readdirSync(directory, { withFileTypes: true });
items.forEach((item) => {
if (item.isDirectory()) {
createNewfolder(destinationRoot, item.name);
iterateFilesAndFolders(path.join(directory, item.name), {
currentFolder: item.name,
destinationRoot: path.join(destinationRoot, item.name),
});
} else {
const sourceFile = path.join(directory, item.name);
const destinationFile = path.join(destinationRoot, item.name);
fs.renameSync(sourceFile, destinationFile);
}
});
};
const setupProject = () => {
const rootfolder = path.join(__dirname, "../");
if (!fs.existsSync(path.join(rootfolder, "themes"))) {
// remove this part if you don't using theme demo as a module
[
{
filepath: path.join(rootfolder, "exampleSite/hugo.toml"),
regex: /^.*theme\s*=\s*("[^"\]]+"|\S+)/m,
},
{
filepath: path.join(
rootfolder,
"exampleSite/config/_default/module.toml",
),
regex: /\[\[imports\]\]\s*\r?\npath = "([^"]+)"/,
},
].forEach(toggleComment);
const folderList = ["layouts", "assets", "static"];
const folderName = getFolderName(rootfolder);
const newfolderName = createNewfolder(
path.join(rootfolder, "themes"),
folderName,
);
folderList.forEach((folder) => {
const source = path.join(rootfolder, folder);
const destination = path.join(newfolderName, folder);
if (fs.existsSync(source)) {
fs.mkdirSync(destination, { recursive: true });
iterateFilesAndFolders(source, {
currentFolder: folder,
destinationRoot: destination,
});
deleteFolder(source);
}
});
const exampleSite = path.join(rootfolder, "exampleSite");
iterateFilesAndFolders(exampleSite, { destinationRoot: rootfolder });
deleteFolder(exampleSite);
}
};
setupProject();

View File

@@ -1,69 +0,0 @@
const fs = require("fs");
const path = require("path");
const rootDirs = ["assets/scss", "layouts"];
const configFiles = [
{
filePath: "exampleSite/tailwind.config.js",
patterns: ["darkmode:\\s*{[^}]*},", 'darkMode:\\s*"class",'],
},
{
filePath: "exampleSite/data/theme.json",
patterns: ["colors.darkmode"],
},
];
rootDirs.forEach(removeDarkModeFromPages);
configFiles.forEach(removeDarkMode);
function removeDarkModeFromFiles(filePath, regexPatterns) {
const fileContent = fs.readFileSync(filePath, "utf8");
let updatedContent = fileContent;
regexPatterns.forEach((pattern) => {
const regex = new RegExp(pattern, "g");
updatedContent = updatedContent.replace(regex, "");
});
fs.writeFileSync(filePath, updatedContent, "utf8");
}
function removeDarkModeFromPages(directoryPath) {
const files = fs.readdirSync(directoryPath);
files.forEach((file) => {
const filePath = path.join(directoryPath, file);
const stats = fs.statSync(filePath);
if (stats.isDirectory()) {
removeDarkModeFromPages(filePath);
} else if (stats.isFile()) {
removeDarkModeFromFiles(filePath, [
'(?:(?!["])\\S)*dark:(?:(?![,;"])\\S)*',
"@apply?(\\s)*;",
]);
}
});
}
function removeDarkMode(configFile) {
const { filePath, patterns } = configFile;
if (filePath === "exampleSite/tailwind.config.js") {
removeDarkModeFromFiles(filePath, patterns);
} else {
const contentFile = JSON.parse(fs.readFileSync(filePath, "utf8"));
patterns.forEach((pattern) => deleteNestedProperty(contentFile, pattern));
fs.writeFileSync(filePath, JSON.stringify(contentFile));
}
}
function deleteNestedProperty(obj, propertyPath) {
const properties = propertyPath.split(".");
let currentObj = obj;
for (let i = 0; i < properties.length - 1; i++) {
const property = properties[i];
if (currentObj.hasOwnProperty(property)) {
currentObj = currentObj[property];
} else {
return; // Property not found, no need to continue
}
}
delete currentObj[properties[properties.length - 1]];
}

View File

@@ -1,125 +0,0 @@
const fs = require("fs");
const path = require("path");
const toggleComment = ({ filepath, regex }) => {
let updatedContent = fs.readFileSync(filepath, "utf8");
const match = updatedContent.match(regex);
if (match) {
const matchedContent = match[0];
const hasComment = matchedContent.startsWith("# ");
if (hasComment) {
const hasBreakline = matchedContent.includes("\n");
if (hasBreakline) {
updatedContent = updatedContent.replace(
regex,
matchedContent.replace(/# /gm, ""),
);
fs.writeFileSync(filepath, updatedContent, "utf8");
}
} else {
updatedContent = updatedContent.replace(regex, "# " + matchedContent);
fs.writeFileSync(filepath, updatedContent, "utf8");
}
}
};
const createNewfolder = (rootfolder, folderName) => {
const newFolder = path.join(rootfolder, folderName);
fs.mkdirSync(newFolder, { recursive: true });
return newFolder;
};
const deleteFolder = (folderPath) => {
if (fs.existsSync(folderPath)) {
fs.rmSync(folderPath, { recursive: true, force: true });
}
};
const getFolderName = (rootfolder) => {
const configPath = path.join(rootfolder, "exampleSite/hugo.toml");
const getConfig = fs.readFileSync(configPath, "utf8");
const match = getConfig.match(/theme\s*=\s*\[?"([^"\]]+)"\]?/);
let selectedTheme = null;
if (match && match[1]) {
selectedTheme = match[1];
}
return selectedTheme;
};
const iterateFilesAndFolders = (rootFolder, { destinationRoot }) => {
const directory = path.join(rootFolder);
const items = fs.readdirSync(directory, { withFileTypes: true });
items.forEach((item) => {
if (item.isDirectory()) {
createNewfolder(destinationRoot, item.name);
iterateFilesAndFolders(path.join(directory, item.name), {
currentFolder: item.name,
destinationRoot: path.join(destinationRoot, item.name),
});
} else {
const sourceFile = path.join(directory, item.name);
const destinationFile = path.join(destinationRoot, item.name);
fs.renameSync(sourceFile, destinationFile);
}
});
};
const setupTheme = () => {
const rootFolder = path.join(__dirname, "../");
if (!fs.existsSync(path.join(rootFolder, "exampleSite"))) {
// remove this part if you don't using theme demo as a module
[
{
filepath: path.join(rootFolder, "config/_default/module.toml"),
regex: /# \[\[imports\]\]\s*\r?\n# path = "([^"]+)"/,
},
{
filepath: path.join(rootFolder, "hugo.toml"),
regex: /^.*theme\s*=\s*("[^"\]]+"|\S+)/m,
},
].forEach(toggleComment);
const includesFiles = [
"tailwind.config.js",
"postcss.config.js",
"go.mod",
"hugo.toml",
"assets",
"config",
"data",
"content",
"i18n",
"static",
];
const folder = createNewfolder(rootFolder, "exampleSite");
fs.readdirSync(rootFolder, { withFileTypes: true }).forEach((file) => {
if (includesFiles.includes(file.name)) {
if (file.isDirectory()) {
const destination = path.join(rootFolder, "exampleSite", file.name);
fs.mkdirSync(destination, { recursive: true });
iterateFilesAndFolders(path.join(rootFolder, file.name), {
destinationRoot: destination,
});
deleteFolder(path.join(rootFolder, file.name));
} else {
fs.renameSync(
path.join(rootFolder, file.name),
path.join(folder, file.name),
);
}
}
});
const themes = path.join(rootFolder, "themes");
iterateFilesAndFolders(path.join(themes, getFolderName(rootFolder)), {
destinationRoot: rootFolder,
});
deleteFolder(themes);
}
};
setupTheme();

View File

@@ -1,19 +0,0 @@
const { exec } = require("child_process");
const repositoryUrl = "https://github.com/zeon-studio/hugoplate";
const localDirectory = "./themes/hugoplate";
const foldersToFetch = ["assets", "layouts"];
const foldersToSkip = ["exampleSite"];
const fetchFolder = (folder) => {
exec(
`curl -L ${repositoryUrl}/tarball/main | tar -xz --strip-components=1 --directory=${localDirectory} --exclude=$(curl -sL ${repositoryUrl}/tarball/main | tar -tz | grep -E "/(${foldersToSkip.join(
"|",
)})/") */${folder}`,
);
};
// Fetch each specified folder
foldersToFetch.forEach((folder) => {
fetchFolder(folder);
});

96
src/app/about/page.tsx Normal file
View File

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

View File

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

52
src/app/blog/page.tsx Normal file
View File

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

95
src/app/contact/page.tsx Normal file
View File

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

BIN
src/app/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

141
src/app/globals.css Normal file
View File

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

62
src/app/layout.tsx Normal file
View File

@@ -0,0 +1,62 @@
import type { Metadata } from "next";
import { Fraunces, Inter } from "next/font/google";
import { Analytics } from "@vercel/analytics/next";
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 />
<Analytics />
</body>
</html>
);
}

140
src/app/page.tsx Normal file
View File

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

16
src/app/robots.ts Normal file
View File

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

373
src/app/services/page.tsx Normal file
View File

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

33
src/app/sitemap.ts Normal file
View File

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

107
src/app/thank-you/page.tsx Normal file
View File

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

View File

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

65
src/app/work/page.tsx Normal file
View File

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

42
src/components/button.tsx Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

12
src/components/jsonld.tsx Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

37
src/lib/case-studies.ts Normal file
View File

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

5
src/lib/cn.ts Normal file
View File

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

174
src/lib/jsonld.ts Normal file
View File

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

58
src/lib/posts.ts Normal file
View File

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

110
src/lib/pricing.ts Normal file
View File

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

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

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

View File

@@ -1,103 +0,0 @@
const fs = require("fs");
const path = require("path");
const themePath = path.join(__dirname, "data/theme.json");
const themeRead = fs.readFileSync(themePath, "utf8");
const theme = JSON.parse(themeRead);
let font_base = Number(theme.fonts.font_size.base.replace("px", ""));
let font_scale = Number(theme.fonts.font_size.scale);
let h6 = font_scale;
let h5 = h6 * font_scale;
let h4 = h5 * font_scale;
let h3 = h4 * font_scale;
let h2 = h3 * font_scale;
let h1 = h2 * font_scale;
let fontPrimary, fontPrimaryType, fontSecondary, fontSecondaryType;
if (theme.fonts.font_family.primary) {
fontPrimary = theme.fonts.font_family.primary
.replace(/\+/g, " ")
.replace(/:[ital,]*[ital@]*[wght@]*[0-9,;.]+/gi, "");
fontPrimaryType = theme.fonts.font_family.primary_type;
}
if (theme.fonts.font_family.secondary) {
fontSecondary = theme.fonts.font_family.secondary
.replace(/\+/g, " ")
.replace(/:[ital,]*[ital@]*[wght@]*[0-9,;.]+/gi, "");
fontSecondaryType = theme.fonts.font_family.secondary_type;
}
/** @type {import('tailwindcss').Config} */
module.exports = {
content: ["./hugo_stats.json"],
safelist: [{ pattern: /^swiper-/ }],
darkMode: "class",
theme: {
screens: {
sm: "540px",
md: "768px",
lg: "1024px",
xl: "1280px",
"2xl": "1536px",
},
container: {
center: true,
padding: "2rem",
},
extend: {
colors: {
text: theme.colors.default.text_color.default,
light: theme.colors.default.text_color.light,
dark: theme.colors.default.text_color.dark,
primary: theme.colors.default.theme_color.primary,
secondary: theme.colors.default.theme_color.secondary,
body: theme.colors.default.theme_color.body,
border: theme.colors.default.theme_color.border,
"theme-light": theme.colors.default.theme_color.theme_light,
"theme-dark": theme.colors.default.theme_color.theme_dark,
darkmode: {
text: theme.colors.darkmode.text_color.default,
light: theme.colors.darkmode.text_color.light,
dark: theme.colors.darkmode.text_color.dark,
primary: theme.colors.darkmode.theme_color.primary,
secondary: theme.colors.darkmode.theme_color.secondary,
body: theme.colors.darkmode.theme_color.body,
border: theme.colors.darkmode.theme_color.border,
"theme-light": theme.colors.darkmode.theme_color.theme_light,
"theme-dark": theme.colors.darkmode.theme_color.theme_dark,
},
},
fontSize: {
base: font_base + "px",
"base-sm": font_base * 0.8 + "px",
h1: h1 + "rem",
"h1-sm": h1 * 0.9 + "rem",
h2: h2 + "rem",
"h2-sm": h2 * 0.9 + "rem",
h3: h3 + "rem",
"h3-sm": h3 * 0.9 + "rem",
h4: h4 + "rem",
h5: h5 + "rem",
h6: h6 + "rem",
},
fontFamily: {
primary: [fontPrimary, fontPrimaryType],
secondary: [fontSecondary, fontSecondaryType],
},
},
},
plugins: [
require("@tailwindcss/typography"),
require("@tailwindcss/forms"),
require("tailwind-bootstrap-grid")({
generateContainer: false,
gridGutterWidth: "2rem",
gridGutters: {
1: "0.25rem",
2: "0.5rem",
3: "1rem",
4: "1.5rem",
5: "3rem",
},
}),
],
};

View File

@@ -1,29 +0,0 @@
name = "Five Devs"
license = "MIT"
licenselink = "https://github.com/zeon-studio/hugoplate/blob/main/LICENSE"
description = "Hugoplate is a free starter template built with Hugo, and TailwindCSS, providing everything you need to jumpstart your Hugo project and save valuable time."
homepage = "https://github.com/zeon-studio/hugoplate"
demosite = "https://zeon.studio/preview?project=hugoplate"
min_version = "0.134.3"
tags = [
"blog",
"responsive",
"minimal",
"personal",
"light",
"dark",
"multilingual",
"landing",
"contact",
"dark mode",
"tailwindcss",
]
features = [
"Multi-Authors",
"Search",
"Multilingual",
"Dark Mode",
"Taxonomies",
]

View File

@@ -1,36 +0,0 @@
// main script
(function () {
"use strict";
// Dropdown Menu Toggler For Mobile
// ----------------------------------------
const dropdownMenuToggler = document.querySelectorAll(
".nav-dropdown > .nav-link",
);
dropdownMenuToggler.forEach((toggler) => {
toggler?.addEventListener("click", (e) => {
e.target.parentElement.classList.toggle("active");
});
});
// Testimonial Slider
// ----------------------------------------
new Swiper(".testimonial-slider", {
spaceBetween: 24,
loop: true,
pagination: {
el: ".testimonial-slider-pagination",
type: "bullets",
clickable: true,
},
breakpoints: {
768: {
slidesPerView: 2,
},
992: {
slidesPerView: 3,
},
},
});
})();

View File

@@ -1,179 +0,0 @@
/*!***************************************************
* Google Map
*****************************************************/
window.marker = null;
function initialize() {
var map,
mapId = document.getElementById("map");
var latitude = mapId.getAttribute("data-latitude");
var longitude = mapId.getAttribute("data-longitude");
var mapMarker = mapId.getAttribute("data-marker");
var mapMarkerName = mapId.getAttribute("data-marker-name");
var nottingham = new google.maps.LatLng(latitude, longitude);
var style = [
{
featureType: "administrative",
elementType: "all",
stylers: [
{
saturation: "-100",
},
],
},
{
featureType: "administrative.province",
elementType: "all",
stylers: [
{
visibility: "off",
},
],
},
{
featureType: "landscape",
elementType: "all",
stylers: [
{
saturation: -100,
},
{
lightness: 65,
},
{
visibility: "on",
},
],
},
{
featureType: "poi",
elementType: "all",
stylers: [
{
saturation: -100,
},
{
lightness: "50",
},
{
visibility: "simplified",
},
],
},
{
featureType: "road",
elementType: "all",
stylers: [
{
saturation: "-100",
},
],
},
{
featureType: "road.highway",
elementType: "all",
stylers: [
{
visibility: "simplified",
},
],
},
{
featureType: "road.arterial",
elementType: "all",
stylers: [
{
lightness: "30",
},
],
},
{
featureType: "road.local",
elementType: "all",
stylers: [
{
lightness: "40",
},
],
},
{
featureType: "transit",
elementType: "all",
stylers: [
{
saturation: -100,
},
{
visibility: "simplified",
},
],
},
{
featureType: "water",
elementType: "geometry",
stylers: [
{
hue: "#ffff00",
},
{
lightness: -25,
},
{
saturation: -97,
},
],
},
{
featureType: "water",
elementType: "labels",
stylers: [
{
lightness: -25,
},
{
saturation: -100,
},
],
},
];
var mapOptions = {
center: nottingham,
mapTypeId: google.maps.MapTypeId.ROADMAP,
backgroundColor: "#000",
zoom: 15,
panControl: !1,
zoomControl: !0,
mapTypeControl: !1,
scaleControl: !1,
streetViewControl: !1,
overviewMapControl: !1,
zoomControlOptions: {
style: google.maps.ZoomControlStyle.LARGE,
},
};
map = new google.maps.Map(document.getElementById("map"), mapOptions);
var mapType = new google.maps.StyledMapType(style, {
name: "Grayscale",
});
map.mapTypes.set("grey", mapType);
map.setMapTypeId("grey");
var marker_image = mapMarker;
var pinIcon = new google.maps.MarkerImage(
marker_image,
null,
null,
null,
new google.maps.Size(30, 50),
);
marker = new google.maps.Marker({
position: nottingham,
map: map,
icon: pinIcon,
title: mapMarkerName,
});
}
var map = document.getElementById("map");
if (map != null) {
google.maps.event.addDomListener(window, "load", initialize);
}

View File

@@ -1,667 +0,0 @@
/**
* Swiper 8.0.7
* Most modern mobile touch slider and framework with hardware accelerated transitions
* https://swiperjs.com
*
* Copyright 2014-2022 Vladimir Kharlampidi
*
* Released under the MIT License
*
* Released on: March 4, 2022
*/
@font-face {
font-family: "swiper-icons";
src: url("data:application/font-woff;charset=utf-8;base64, d09GRgABAAAAAAZgABAAAAAADAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABGRlRNAAAGRAAAABoAAAAci6qHkUdERUYAAAWgAAAAIwAAACQAYABXR1BPUwAABhQAAAAuAAAANuAY7+xHU1VCAAAFxAAAAFAAAABm2fPczU9TLzIAAAHcAAAASgAAAGBP9V5RY21hcAAAAkQAAACIAAABYt6F0cBjdnQgAAACzAAAAAQAAAAEABEBRGdhc3AAAAWYAAAACAAAAAj//wADZ2x5ZgAAAywAAADMAAAD2MHtryVoZWFkAAABbAAAADAAAAA2E2+eoWhoZWEAAAGcAAAAHwAAACQC9gDzaG10eAAAAigAAAAZAAAArgJkABFsb2NhAAAC0AAAAFoAAABaFQAUGG1heHAAAAG8AAAAHwAAACAAcABAbmFtZQAAA/gAAAE5AAACXvFdBwlwb3N0AAAFNAAAAGIAAACE5s74hXjaY2BkYGAAYpf5Hu/j+W2+MnAzMYDAzaX6QjD6/4//Bxj5GA8AuRwMYGkAPywL13jaY2BkYGA88P8Agx4j+/8fQDYfA1AEBWgDAIB2BOoAeNpjYGRgYNBh4GdgYgABEMnIABJzYNADCQAACWgAsQB42mNgYfzCOIGBlYGB0YcxjYGBwR1Kf2WQZGhhYGBiYGVmgAFGBiQQkOaawtDAoMBQxXjg/wEGPcYDDA4wNUA2CCgwsAAAO4EL6gAAeNpj2M0gyAACqxgGNWBkZ2D4/wMA+xkDdgAAAHjaY2BgYGaAYBkGRgYQiAHyGMF8FgYHIM3DwMHABGQrMOgyWDLEM1T9/w8UBfEMgLzE////P/5//f/V/xv+r4eaAAeMbAxwIUYmIMHEgKYAYjUcsDAwsLKxc3BycfPw8jEQA/gZBASFhEVExcQlJKWkZWTl5BUUlZRVVNXUNTQZBgMAAMR+E+gAEQFEAAAAKgAqACoANAA+AEgAUgBcAGYAcAB6AIQAjgCYAKIArAC2AMAAygDUAN4A6ADyAPwBBgEQARoBJAEuATgBQgFMAVYBYAFqAXQBfgGIAZIBnAGmAbIBzgHsAAB42u2NMQ6CUAyGW568x9AneYYgm4MJbhKFaExIOAVX8ApewSt4Bic4AfeAid3VOBixDxfPYEza5O+Xfi04YADggiUIULCuEJK8VhO4bSvpdnktHI5QCYtdi2sl8ZnXaHlqUrNKzdKcT8cjlq+rwZSvIVczNiezsfnP/uznmfPFBNODM2K7MTQ45YEAZqGP81AmGGcF3iPqOop0r1SPTaTbVkfUe4HXj97wYE+yNwWYxwWu4v1ugWHgo3S1XdZEVqWM7ET0cfnLGxWfkgR42o2PvWrDMBSFj/IHLaF0zKjRgdiVMwScNRAoWUoH78Y2icB/yIY09An6AH2Bdu/UB+yxopYshQiEvnvu0dURgDt8QeC8PDw7Fpji3fEA4z/PEJ6YOB5hKh4dj3EvXhxPqH/SKUY3rJ7srZ4FZnh1PMAtPhwP6fl2PMJMPDgeQ4rY8YT6Gzao0eAEA409DuggmTnFnOcSCiEiLMgxCiTI6Cq5DZUd3Qmp10vO0LaLTd2cjN4fOumlc7lUYbSQcZFkutRG7g6JKZKy0RmdLY680CDnEJ+UMkpFFe1RN7nxdVpXrC4aTtnaurOnYercZg2YVmLN/d/gczfEimrE/fs/bOuq29Zmn8tloORaXgZgGa78yO9/cnXm2BpaGvq25Dv9S4E9+5SIc9PqupJKhYFSSl47+Qcr1mYNAAAAeNptw0cKwkAAAMDZJA8Q7OUJvkLsPfZ6zFVERPy8qHh2YER+3i/BP83vIBLLySsoKimrqKqpa2hp6+jq6RsYGhmbmJqZSy0sraxtbO3sHRydnEMU4uR6yx7JJXveP7WrDycAAAAAAAH//wACeNpjYGRgYOABYhkgZgJCZgZNBkYGLQZtIJsFLMYAAAw3ALgAeNolizEKgDAQBCchRbC2sFER0YD6qVQiBCv/H9ezGI6Z5XBAw8CBK/m5iQQVauVbXLnOrMZv2oLdKFa8Pjuru2hJzGabmOSLzNMzvutpB3N42mNgZGBg4GKQYzBhYMxJLMlj4GBgAYow/P/PAJJhLM6sSoWKfWCAAwDAjgbRAAB42mNgYGBkAIIbCZo5IPrmUn0hGA0AO8EFTQAA");
font-weight: 400;
font-style: normal;
}
:root {
--swiper-theme-color: #007aff;
}
.swiper {
margin-left: auto;
margin-right: auto;
position: relative;
overflow: hidden;
list-style: none;
padding: 0;
/* Fix of Webkit flickering */
z-index: 1;
}
.swiper-vertical > .swiper-wrapper {
flex-direction: column;
}
.swiper-wrapper {
position: relative;
width: 100%;
height: 100%;
z-index: 1;
display: flex;
transition-property: transform;
box-sizing: content-box;
}
.swiper-android .swiper-slide,
.swiper-wrapper {
transform: translate3d(0px, 0, 0);
}
.swiper-pointer-events {
touch-action: pan-y;
}
.swiper-pointer-events.swiper-vertical {
touch-action: pan-x;
}
.swiper-slide {
flex-shrink: 0;
width: 100%;
height: 100%;
position: relative;
transition-property: transform;
}
.swiper-slide-invisible-blank {
visibility: hidden;
}
/* Auto Height */
.swiper-autoheight,
.swiper-autoheight .swiper-slide {
height: auto;
}
.swiper-autoheight .swiper-wrapper {
align-items: flex-start;
transition-property: transform, height;
}
.swiper-backface-hidden .swiper-slide {
transform: translateZ(0);
-webkit-backface-visibility: hidden;
backface-visibility: hidden;
}
/* 3D Effects */
.swiper-3d,
.swiper-3d.swiper-css-mode .swiper-wrapper {
perspective: 1200px;
}
.swiper-3d .swiper-wrapper,
.swiper-3d .swiper-slide,
.swiper-3d .swiper-slide-shadow,
.swiper-3d .swiper-slide-shadow-left,
.swiper-3d .swiper-slide-shadow-right,
.swiper-3d .swiper-slide-shadow-top,
.swiper-3d .swiper-slide-shadow-bottom,
.swiper-3d .swiper-cube-shadow {
transform-style: preserve-3d;
}
.swiper-3d .swiper-slide-shadow,
.swiper-3d .swiper-slide-shadow-left,
.swiper-3d .swiper-slide-shadow-right,
.swiper-3d .swiper-slide-shadow-top,
.swiper-3d .swiper-slide-shadow-bottom {
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
pointer-events: none;
z-index: 10;
}
.swiper-3d .swiper-slide-shadow {
background: rgba(0, 0, 0, 0.15);
}
.swiper-3d .swiper-slide-shadow-left {
background-image: linear-gradient(
to left,
rgba(0, 0, 0, 0.5),
rgba(0, 0, 0, 0)
);
}
.swiper-3d .swiper-slide-shadow-right {
background-image: linear-gradient(
to right,
rgba(0, 0, 0, 0.5),
rgba(0, 0, 0, 0)
);
}
.swiper-3d .swiper-slide-shadow-top {
background-image: linear-gradient(
to top,
rgba(0, 0, 0, 0.5),
rgba(0, 0, 0, 0)
);
}
.swiper-3d .swiper-slide-shadow-bottom {
background-image: linear-gradient(
to bottom,
rgba(0, 0, 0, 0.5),
rgba(0, 0, 0, 0)
);
}
/* CSS Mode */
.swiper-css-mode > .swiper-wrapper {
overflow: auto;
scrollbar-width: none;
/* For Firefox */
-ms-overflow-style: none;
/* For Internet Explorer and Edge */
}
.swiper-css-mode > .swiper-wrapper::-webkit-scrollbar {
display: none;
}
.swiper-css-mode > .swiper-wrapper > .swiper-slide {
scroll-snap-align: start start;
}
.swiper-horizontal.swiper-css-mode > .swiper-wrapper {
scroll-snap-type: x mandatory;
}
.swiper-vertical.swiper-css-mode > .swiper-wrapper {
scroll-snap-type: y mandatory;
}
.swiper-centered > .swiper-wrapper::before {
content: "";
flex-shrink: 0;
order: 9999;
}
.swiper-centered.swiper-horizontal
> .swiper-wrapper
> .swiper-slide:first-child {
margin-inline-start: var(--swiper-centered-offset-before);
}
.swiper-centered.swiper-horizontal > .swiper-wrapper::before {
height: 100%;
min-height: 1px;
width: var(--swiper-centered-offset-after);
}
.swiper-centered.swiper-vertical > .swiper-wrapper > .swiper-slide:first-child {
margin-block-start: var(--swiper-centered-offset-before);
}
.swiper-centered.swiper-vertical > .swiper-wrapper::before {
width: 100%;
min-width: 1px;
height: var(--swiper-centered-offset-after);
}
.swiper-centered > .swiper-wrapper > .swiper-slide {
scroll-snap-align: center center;
}
.swiper-virtual .swiper-slide {
-webkit-backface-visibility: hidden;
transform: translateZ(0);
}
.swiper-virtual.swiper-css-mode .swiper-wrapper::after {
content: "";
position: absolute;
left: 0;
top: 0;
pointer-events: none;
}
.swiper-virtual.swiper-css-mode.swiper-horizontal .swiper-wrapper::after {
height: 1px;
width: var(--swiper-virtual-size);
}
.swiper-virtual.swiper-css-mode.swiper-vertical .swiper-wrapper::after {
width: 1px;
height: var(--swiper-virtual-size);
}
:root {
--swiper-navigation-size: 44px;
/*
--swiper-navigation-color: var(--swiper-theme-color);
*/
}
.swiper-button-prev,
.swiper-button-next {
position: absolute;
top: 50%;
width: calc(var(--swiper-navigation-size) / 44 * 27);
height: var(--swiper-navigation-size);
margin-top: calc(0px - (var(--swiper-navigation-size) / 2));
z-index: 10;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
color: var(--swiper-navigation-color, var(--swiper-theme-color));
}
.swiper-button-prev.swiper-button-disabled,
.swiper-button-next.swiper-button-disabled {
opacity: 0.35;
cursor: auto;
pointer-events: none;
}
.swiper-button-prev:after,
.swiper-button-next:after {
font-family: swiper-icons;
font-size: var(--swiper-navigation-size);
text-transform: none !important;
letter-spacing: 0;
text-transform: none;
font-variant: initial;
line-height: 1;
}
.swiper-button-prev,
.swiper-rtl .swiper-button-next {
left: 10px;
right: auto;
}
.swiper-button-prev:after,
.swiper-rtl .swiper-button-next:after {
content: "prev";
}
.swiper-button-next,
.swiper-rtl .swiper-button-prev {
right: 10px;
left: auto;
}
.swiper-button-next:after,
.swiper-rtl .swiper-button-prev:after {
content: "next";
}
.swiper-button-lock {
display: none;
}
:root {
/*
--swiper-pagination-color: var(--swiper-theme-color);
--swiper-pagination-bullet-size: 8px;
--swiper-pagination-bullet-width: 8px;
--swiper-pagination-bullet-height: 8px;
--swiper-pagination-bullet-inactive-color: #000;
--swiper-pagination-bullet-inactive-opacity: 0.2;
--swiper-pagination-bullet-opacity: 1;
--swiper-pagination-bullet-horizontal-gap: 4px;
--swiper-pagination-bullet-vertical-gap: 6px;
*/
}
.swiper-pagination {
position: absolute;
text-align: center;
transition: 300ms opacity;
transform: translate3d(0, 0, 0);
z-index: 10;
}
.swiper-pagination.swiper-pagination-hidden {
opacity: 0;
}
/* Common Styles */
.swiper-pagination-fraction,
.swiper-pagination-custom,
.swiper-horizontal > .swiper-pagination-bullets,
.swiper-pagination-bullets.swiper-pagination-horizontal {
bottom: 10px;
left: 0;
width: 100%;
}
/* Bullets */
.swiper-pagination-bullets-dynamic {
overflow: hidden;
font-size: 0;
}
.swiper-pagination-bullets-dynamic .swiper-pagination-bullet {
transform: scale(0.33);
position: relative;
}
.swiper-pagination-bullets-dynamic .swiper-pagination-bullet-active {
transform: scale(1);
}
.swiper-pagination-bullets-dynamic .swiper-pagination-bullet-active-main {
transform: scale(1);
}
.swiper-pagination-bullets-dynamic .swiper-pagination-bullet-active-prev {
transform: scale(0.66);
}
.swiper-pagination-bullets-dynamic .swiper-pagination-bullet-active-prev-prev {
transform: scale(0.33);
}
.swiper-pagination-bullets-dynamic .swiper-pagination-bullet-active-next {
transform: scale(0.66);
}
.swiper-pagination-bullets-dynamic .swiper-pagination-bullet-active-next-next {
transform: scale(0.33);
}
.swiper-pagination-bullet {
width: var(
--swiper-pagination-bullet-width,
var(--swiper-pagination-bullet-size, 8px)
);
height: var(
--swiper-pagination-bullet-height,
var(--swiper-pagination-bullet-size, 8px)
);
display: inline-block;
border-radius: 50%;
background: var(--swiper-pagination-bullet-inactive-color, #000);
opacity: var(--swiper-pagination-bullet-inactive-opacity, 0.2);
}
button.swiper-pagination-bullet {
border: none;
margin: 0;
padding: 0;
box-shadow: none;
-webkit-appearance: none;
appearance: none;
}
.swiper-pagination-clickable .swiper-pagination-bullet {
cursor: pointer;
}
.swiper-pagination-bullet:only-child {
display: none !important;
}
.swiper-pagination-bullet-active {
opacity: var(--swiper-pagination-bullet-opacity, 1);
background: var(--swiper-pagination-color, var(--swiper-theme-color));
}
.swiper-vertical > .swiper-pagination-bullets,
.swiper-pagination-vertical.swiper-pagination-bullets {
right: 10px;
top: 50%;
transform: translate3d(0px, -50%, 0);
}
.swiper-vertical > .swiper-pagination-bullets .swiper-pagination-bullet,
.swiper-pagination-vertical.swiper-pagination-bullets
.swiper-pagination-bullet {
margin: var(--swiper-pagination-bullet-vertical-gap, 6px) 0;
display: block;
}
.swiper-vertical > .swiper-pagination-bullets.swiper-pagination-bullets-dynamic,
.swiper-pagination-vertical.swiper-pagination-bullets.swiper-pagination-bullets-dynamic {
top: 50%;
transform: translateY(-50%);
width: 8px;
}
.swiper-vertical
> .swiper-pagination-bullets.swiper-pagination-bullets-dynamic
.swiper-pagination-bullet,
.swiper-pagination-vertical.swiper-pagination-bullets.swiper-pagination-bullets-dynamic
.swiper-pagination-bullet {
display: inline-block;
transition:
200ms transform,
200ms top;
}
.swiper-horizontal > .swiper-pagination-bullets .swiper-pagination-bullet,
.swiper-pagination-horizontal.swiper-pagination-bullets
.swiper-pagination-bullet {
margin: 0 var(--swiper-pagination-bullet-horizontal-gap, 4px);
}
.swiper-horizontal
> .swiper-pagination-bullets.swiper-pagination-bullets-dynamic,
.swiper-pagination-horizontal.swiper-pagination-bullets.swiper-pagination-bullets-dynamic {
left: 50%;
transform: translateX(-50%);
white-space: nowrap;
}
.swiper-horizontal
> .swiper-pagination-bullets.swiper-pagination-bullets-dynamic
.swiper-pagination-bullet,
.swiper-pagination-horizontal.swiper-pagination-bullets.swiper-pagination-bullets-dynamic
.swiper-pagination-bullet {
transition:
200ms transform,
200ms left;
}
.swiper-horizontal.swiper-rtl
> .swiper-pagination-bullets-dynamic
.swiper-pagination-bullet {
transition:
200ms transform,
200ms right;
}
/* Progress */
.swiper-pagination-progressbar {
background: rgba(0, 0, 0, 0.25);
position: absolute;
}
.swiper-pagination-progressbar .swiper-pagination-progressbar-fill {
background: var(--swiper-pagination-color, var(--swiper-theme-color));
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
transform: scale(0);
transform-origin: left top;
}
.swiper-rtl .swiper-pagination-progressbar .swiper-pagination-progressbar-fill {
transform-origin: right top;
}
.swiper-horizontal > .swiper-pagination-progressbar,
.swiper-pagination-progressbar.swiper-pagination-horizontal,
.swiper-vertical
> .swiper-pagination-progressbar.swiper-pagination-progressbar-opposite,
.swiper-pagination-progressbar.swiper-pagination-vertical.swiper-pagination-progressbar-opposite {
width: 100%;
height: 4px;
left: 0;
top: 0;
}
.swiper-vertical > .swiper-pagination-progressbar,
.swiper-pagination-progressbar.swiper-pagination-vertical,
.swiper-horizontal
> .swiper-pagination-progressbar.swiper-pagination-progressbar-opposite,
.swiper-pagination-progressbar.swiper-pagination-horizontal.swiper-pagination-progressbar-opposite {
width: 4px;
height: 100%;
left: 0;
top: 0;
}
.swiper-pagination-lock {
display: none;
}
/* Scrollbar */
.swiper-scrollbar {
border-radius: 10px;
position: relative;
-ms-touch-action: none;
background: rgba(0, 0, 0, 0.1);
}
.swiper-horizontal > .swiper-scrollbar {
position: absolute;
left: 1%;
bottom: 3px;
z-index: 50;
height: 5px;
width: 98%;
}
.swiper-vertical > .swiper-scrollbar {
position: absolute;
right: 3px;
top: 1%;
z-index: 50;
width: 5px;
height: 98%;
}
.swiper-scrollbar-drag {
height: 100%;
width: 100%;
position: relative;
background: rgba(0, 0, 0, 0.5);
border-radius: 10px;
left: 0;
top: 0;
}
.swiper-scrollbar-cursor-drag {
cursor: move;
}
.swiper-scrollbar-lock {
display: none;
}
.swiper-zoom-container {
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
text-align: center;
}
.swiper-zoom-container > img,
.swiper-zoom-container > svg,
.swiper-zoom-container > canvas {
max-width: 100%;
max-height: 100%;
object-fit: contain;
}
.swiper-slide-zoomed {
cursor: move;
}
/* Preloader */
:root {
/*
--swiper-preloader-color: var(--swiper-theme-color);
*/
}
.swiper-lazy-preloader {
width: 42px;
height: 42px;
position: absolute;
left: 50%;
top: 50%;
margin-left: -21px;
margin-top: -21px;
z-index: 10;
transform-origin: 50%;
box-sizing: border-box;
border: 4px solid var(--swiper-preloader-color, var(--swiper-theme-color));
border-radius: 50%;
border-top-color: transparent;
}
.swiper-slide-visible .swiper-lazy-preloader {
animation: swiper-preloader-spin 1s infinite linear;
}
.swiper-lazy-preloader-white {
--swiper-preloader-color: #fff;
}
.swiper-lazy-preloader-black {
--swiper-preloader-color: #000;
}
@keyframes swiper-preloader-spin {
100% {
transform: rotate(360deg);
}
}
/* a11y */
.swiper .swiper-notification {
position: absolute;
left: 0;
top: 0;
pointer-events: none;
opacity: 0;
z-index: -1000;
}
.swiper-free-mode > .swiper-wrapper {
transition-timing-function: ease-out;
margin: 0 auto;
}
.swiper-grid > .swiper-wrapper {
flex-wrap: wrap;
}
.swiper-grid-column > .swiper-wrapper {
flex-wrap: wrap;
flex-direction: column;
}
.swiper-fade.swiper-free-mode .swiper-slide {
transition-timing-function: ease-out;
}
.swiper-fade .swiper-slide {
pointer-events: none;
transition-property: opacity;
}
.swiper-fade .swiper-slide .swiper-slide {
pointer-events: none;
}
.swiper-fade .swiper-slide-active,
.swiper-fade .swiper-slide-active .swiper-slide-active {
pointer-events: auto;
}
.swiper-cube {
overflow: visible;
}
.swiper-cube .swiper-slide {
pointer-events: none;
-webkit-backface-visibility: hidden;
backface-visibility: hidden;
z-index: 1;
visibility: hidden;
transform-origin: 0 0;
width: 100%;
height: 100%;
}
.swiper-cube .swiper-slide .swiper-slide {
pointer-events: none;
}
.swiper-cube.swiper-rtl .swiper-slide {
transform-origin: 100% 0;
}
.swiper-cube .swiper-slide-active,
.swiper-cube .swiper-slide-active .swiper-slide-active {
pointer-events: auto;
}
.swiper-cube .swiper-slide-active,
.swiper-cube .swiper-slide-next,
.swiper-cube .swiper-slide-prev,
.swiper-cube .swiper-slide-next + .swiper-slide {
pointer-events: auto;
visibility: visible;
}
.swiper-cube .swiper-slide-shadow-top,
.swiper-cube .swiper-slide-shadow-bottom,
.swiper-cube .swiper-slide-shadow-left,
.swiper-cube .swiper-slide-shadow-right {
z-index: 0;
-webkit-backface-visibility: hidden;
backface-visibility: hidden;
}
.swiper-cube .swiper-cube-shadow {
position: absolute;
left: 0;
bottom: 0px;
width: 100%;
height: 100%;
opacity: 0.6;
z-index: 0;
}
.swiper-cube .swiper-cube-shadow:before {
content: "";
background: #000;
position: absolute;
left: 0;
top: 0;
bottom: 0;
right: 0;
filter: blur(50px);
}
.swiper-flip {
overflow: visible;
}
.swiper-flip .swiper-slide {
pointer-events: none;
-webkit-backface-visibility: hidden;
backface-visibility: hidden;
z-index: 1;
}
.swiper-flip .swiper-slide .swiper-slide {
pointer-events: none;
}
.swiper-flip .swiper-slide-active,
.swiper-flip .swiper-slide-active .swiper-slide-active {
pointer-events: auto;
}
.swiper-flip .swiper-slide-shadow-top,
.swiper-flip .swiper-slide-shadow-bottom,
.swiper-flip .swiper-slide-shadow-left,
.swiper-flip .swiper-slide-shadow-right {
z-index: 0;
-webkit-backface-visibility: hidden;
backface-visibility: hidden;
}
.swiper-creative .swiper-slide {
-webkit-backface-visibility: hidden;
backface-visibility: hidden;
overflow: hidden;
transition-property: transform, opacity, height;
}
.swiper-cards {
overflow: visible;
}
.swiper-cards .swiper-slide {
transform-origin: center bottom;
-webkit-backface-visibility: hidden;
backface-visibility: hidden;
overflow: hidden;
}

Some files were not shown because too many files have changed in this diff Show More