Compare commits
4 Commits
e45746cb9b
...
c25d568149
| Author | SHA1 | Date | |
|---|---|---|---|
| c25d568149 | |||
| a7c05104af | |||
| 3b5cd5d58d | |||
| f96adbc99a |
110
.gitea/RUNNER_SETUP.md
Normal 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.
|
||||||
35
.gitea/workflows/deploy.yml
Normal 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
@@ -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
|
.DS_Store
|
||||||
.dist
|
*.pem
|
||||||
.tmp
|
|
||||||
.lock
|
# debug
|
||||||
.sass-cache
|
npm-debug.log*
|
||||||
npm-debug.log
|
yarn-debug.log*
|
||||||
node_modules
|
yarn-error.log*
|
||||||
builds
|
.pnpm-debug.log*
|
||||||
package-lock.json
|
|
||||||
public
|
# env files (can opt-in for committing if needed)
|
||||||
resources
|
.env*
|
||||||
.hugo_build.lock
|
|
||||||
jsconfig.json
|
# vercel
|
||||||
hugo_stats.json
|
.vercel
|
||||||
go.sum
|
|
||||||
yarn.lock
|
# typescript
|
||||||
.idea
|
*.tsbuildinfo
|
||||||
.vscode
|
next-env.d.ts
|
||||||
|
|||||||
59
.jshintrc
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
{
|
|
||||||
"MD033": false,
|
|
||||||
"MD034": false,
|
|
||||||
"MD013": false
|
|
||||||
}
|
|
||||||
13
.prettierrc
@@ -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
@@ -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 -->
|
||||||
36
README.md
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
||||||
|
|
||||||
|
## Getting Started
|
||||||
|
|
||||||
|
First, run the development server:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
# or
|
||||||
|
yarn dev
|
||||||
|
# or
|
||||||
|
pnpm dev
|
||||||
|
# or
|
||||||
|
bun dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||||
|
|
||||||
|
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
||||||
|
|
||||||
|
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
||||||
|
|
||||||
|
## Learn More
|
||||||
|
|
||||||
|
To learn more about Next.js, take a look at the following resources:
|
||||||
|
|
||||||
|
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
||||||
|
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||||
|
|
||||||
|
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
||||||
|
|
||||||
|
## Deploy on Vercel
|
||||||
|
|
||||||
|
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
||||||
|
|
||||||
|
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
||||||
340
STRATEGY.md
Normal file
@@ -0,0 +1,340 @@
|
|||||||
|
# Five Devs — Positioning & 30-Day Content Strategy
|
||||||
|
|
||||||
|
This document is the strategy behind the v1 site. Treat it as a living
|
||||||
|
document — update it as the work changes shape.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Positioning
|
||||||
|
|
||||||
|
**One-line:** Senior PHP help for the unglamorous, mission-critical glue
|
||||||
|
between your store, your warehouse, and your books.
|
||||||
|
|
||||||
|
**Why this and not "PHP generalist":**
|
||||||
|
|
||||||
|
- The real testimonials are *all* in this lane (Pritikin, Americold,
|
||||||
|
Fortune Fulfillment).
|
||||||
|
- Premium hourly rates ($150/hr+) come from domain expertise, not from
|
||||||
|
raw PHP skill. Buyers will pay a senior generalist $80–110/hr; they
|
||||||
|
pay a domain specialist $150–250/hr because the alternative is
|
||||||
|
hiring an in-house team.
|
||||||
|
- Aaron & Joel at nocompromises.io own the "Laravel maintenance
|
||||||
|
subscription" lane already. Differentiate by going *deeper* on a
|
||||||
|
vertical they don't claim (e-commerce/3PL ops) rather than competing
|
||||||
|
on the same playbook.
|
||||||
|
- Generalist work will still come in via referrals. Sharp positioning
|
||||||
|
doesn't shrink your top-of-funnel as much as people think — it
|
||||||
|
changes who shows up at the bottom of it.
|
||||||
|
|
||||||
|
**Three engagement shapes** (live on `/services`):
|
||||||
|
|
||||||
|
1. **Project sprint** — fixed scope, fixed price after paid discovery
|
||||||
|
2. **Monthly retainer** — reserved senior hours, the long tail
|
||||||
|
3. **Block of time** — 40/80/160-hour blocks usable over 6 months
|
||||||
|
|
||||||
|
Pricing strategy: don't put numbers on the site yet. Quote on the call.
|
||||||
|
Once the rate has crept comfortably to $150/hr, *then* publish a "from
|
||||||
|
$X" anchor on the services page — that's the move that filters out
|
||||||
|
unqualified leads at the top of the funnel.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What's on the site (v1)
|
||||||
|
|
||||||
|
- `/` — Home: hero, services teaser, real client logo strip, 4
|
||||||
|
testimonials, CTA
|
||||||
|
- `/services` — three engagement tiers, plain-language pricing note
|
||||||
|
- `/work` — index + two case studies (Pritikin Foods, Americold)
|
||||||
|
- `/about` — voice, tools, story, mailing address
|
||||||
|
- `/contact` — Cal.com booking link, contact form, email, address
|
||||||
|
- `/blog` — index + 3 seed posts
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Things YOU need to do before launch
|
||||||
|
|
||||||
|
These are placeholders / wiring tasks I couldn't do without you:
|
||||||
|
|
||||||
|
1. **Cal.com link** — `src/app/contact/page.tsx` currently points to
|
||||||
|
`https://cal.com/cgsmith/intro`. Set up your Cal.com account, create
|
||||||
|
a 20-minute "Intro call" event type, and update that constant.
|
||||||
|
2. **Email address** — the site uses `chris@fivedevs.com` throughout.
|
||||||
|
Either set up that mailbox (Google Workspace / Fastmail) or
|
||||||
|
global-replace to whatever you actually want public.
|
||||||
|
3. **Case study sign-off** — re-read the Pritikin and Americold case
|
||||||
|
studies. They're written from public testimonials only, no
|
||||||
|
confidential details. But you should still confirm with each client
|
||||||
|
before the site goes live — a quick "hey, mind if I tell this story
|
||||||
|
on my site?" email goes a long way and often becomes its own
|
||||||
|
testimonial moment.
|
||||||
|
4. **Photo / avatar** — drop a real headshot into `/public/chris.jpg`
|
||||||
|
and add it to the About page. Even a phone selfie outdoors is fine
|
||||||
|
— the absence of a face is the single thing that makes a freelancer
|
||||||
|
site feel cold.
|
||||||
|
5. **Domain + DNS** — when ready to flip, point `fivedevs.com` at
|
||||||
|
Vercel (or stay on AWS Amplify and add the Next.js Amplify config).
|
||||||
|
6. **Optional: contact form upgrade** — the form currently opens the
|
||||||
|
visitor's email client via `mailto:`. Works on day 1, looks
|
||||||
|
slightly amateur. Upgrade to a real server action when convenient
|
||||||
|
(10 lines of Resend code).
|
||||||
|
7. **Stripe Payment Links** — the Services page now has self-serve
|
||||||
|
checkout for the retainer and the three block sizes. The site
|
||||||
|
reads four URLs from environment variables; until they're set,
|
||||||
|
the buttons fall back to "Talk first" and route to /contact.
|
||||||
|
|
||||||
|
**Soft-launch sale: 50% off** is currently active and shows the
|
||||||
|
crossed-out regular price next to the discounted price. Each
|
||||||
|
Stripe Payment Link should be configured at the **sale price**
|
||||||
|
while the soft launch runs.
|
||||||
|
|
||||||
|
**Setup (~15 min in the Stripe Dashboard):**
|
||||||
|
|
||||||
|
1. **Stripe Dashboard → Products → + Add product** — create four
|
||||||
|
products. Use these names and prices (all USD, the *sale*
|
||||||
|
amounts that are charged today):
|
||||||
|
|
||||||
|
| Product | Type | Price (sale) | Regular |
|
||||||
|
|------------------------|-----------|--------------|---------|
|
||||||
|
| Five Devs Retainer | Recurring | **$2,500/mo** | $5,000/mo |
|
||||||
|
| 40-Hour Block | One-time | **$2,750** | $5,500 |
|
||||||
|
| 80-Hour Block | One-time | **$5,250** | $10,500 |
|
||||||
|
| 160-Hour Block | One-time | **$10,000** | $20,000 |
|
||||||
|
|
||||||
|
2. **Stripe Dashboard → Payment Links → + New** — create one
|
||||||
|
Payment Link per product. For each link:
|
||||||
|
- **After payment:** "Don't show confirmation page. Instead,
|
||||||
|
send customers to" → `https://fivedevs.com/thank-you`
|
||||||
|
- **Collect customer info:** name, email, billing address
|
||||||
|
- **Allow promotion codes:** off (the price already includes
|
||||||
|
the discount)
|
||||||
|
- **Tax behavior:** if you've enabled Stripe Tax, leave on;
|
||||||
|
otherwise inclusive vs. exclusive doesn't change anything
|
||||||
|
for B2B customers in most US states.
|
||||||
|
|
||||||
|
3. Copy each Payment Link URL (looks like
|
||||||
|
`https://buy.stripe.com/abc123`) into your local
|
||||||
|
`.env.local` (use `.env.local.example` as a template):
|
||||||
|
|
||||||
|
```
|
||||||
|
NEXT_PUBLIC_STRIPE_RETAINER=https://buy.stripe.com/...
|
||||||
|
NEXT_PUBLIC_STRIPE_BLOCK_40=https://buy.stripe.com/...
|
||||||
|
NEXT_PUBLIC_STRIPE_BLOCK_80=https://buy.stripe.com/...
|
||||||
|
NEXT_PUBLIC_STRIPE_BLOCK_160=https://buy.stripe.com/...
|
||||||
|
```
|
||||||
|
|
||||||
|
And paste the same four into your Vercel project's
|
||||||
|
Environment Variables (Project Settings → Environment
|
||||||
|
Variables → add for **Production** and **Preview**).
|
||||||
|
|
||||||
|
4. **Test once.** Use Stripe's test mode link first, then flip
|
||||||
|
to live. Stripe's test card is `4242 4242 4242 4242`, any
|
||||||
|
future expiry, any CVC.
|
||||||
|
|
||||||
|
**Ending the soft-launch sale (later):**
|
||||||
|
|
||||||
|
1. Edit `src/lib/pricing.ts` and set `sale.active = false`.
|
||||||
|
The crossed-out prices and badge disappear.
|
||||||
|
2. Create new Payment Links in Stripe at the *regular* prices
|
||||||
|
and update the four env vars to point at them.
|
||||||
|
3. Redeploy.
|
||||||
|
|
||||||
|
**What sales tax do you owe?** Nevada doesn't tax most
|
||||||
|
professional services to other businesses, so for most
|
||||||
|
B2B customers there's nothing to collect. If you take on
|
||||||
|
significant work for clients in tax-aggressive states (CA, WA,
|
||||||
|
TX) or sell internationally, enable Stripe Tax — it figures
|
||||||
|
it out per-transaction for $0.50/transaction. Talk to a CPA
|
||||||
|
before you cross $250K/year.
|
||||||
|
|
||||||
|
8. **Client logos** — the home page strip currently uses styled text
|
||||||
|
wordmarks for the eight named clients (Americold, Butcher Box,
|
||||||
|
Perfect Bar, Pritikin Foods, Potawatomi Business Development,
|
||||||
|
Fortune Fulfillment, Vanderose Farms, PERC Engage). Two things to
|
||||||
|
do before publishing real logo marks:
|
||||||
|
- **Get permission.** Email each company a one-liner: "I'd like
|
||||||
|
to list you as a past client on my site — happy to send a
|
||||||
|
mockup; let me know if you'd prefer me not to." Most will say
|
||||||
|
yes; a couple of CPG brands (Butcher Box, Perfect Bar) may
|
||||||
|
require formal sign-off via PR/legal. Fortune 500 and tribal
|
||||||
|
entities (Americold, Potawatomi) often have specific usage
|
||||||
|
requirements — ask first.
|
||||||
|
- **Source the assets.** Download proper SVGs from each brand's
|
||||||
|
press kit / "About Us" media page, drop into
|
||||||
|
`public/logos/<slug>.svg`, then update
|
||||||
|
`src/components/client-logos.tsx` to render `<img>` for entries
|
||||||
|
with a `logo` field. Style: grayscale by default, brighten on
|
||||||
|
hover, max-height ~32px.
|
||||||
|
- Note: don't use the Clearbit Logo API — HubSpot shut it down
|
||||||
|
in late 2024. Brandfetch and Logo.dev are paid alternatives if
|
||||||
|
you want a managed solution; otherwise self-host the SVGs.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 30-day content plan
|
||||||
|
|
||||||
|
The goal of the next month is **two things**, in this order:
|
||||||
|
|
||||||
|
1. Get the site live with a real face on it.
|
||||||
|
2. Publish enough writing that when someone types "[your name] PHP
|
||||||
|
developer" or "PHP developer for 3PL integration" into Google or
|
||||||
|
asks an LLM, *something of yours* shows up.
|
||||||
|
|
||||||
|
You only need 4–6 posts to start moving the needle. Quality over
|
||||||
|
volume. Each post should answer a specific question a buyer would
|
||||||
|
actually type.
|
||||||
|
|
||||||
|
### Week 1 — Launch + one post
|
||||||
|
|
||||||
|
- [ ] Day 1–2: review and ship the v1 site (above checklist)
|
||||||
|
- [ ] Day 3: announce on LinkedIn/Twitter — short post, one sentence
|
||||||
|
about positioning, link to home
|
||||||
|
- [ ] Day 4–5: Write Post #4 (see below). One per week is the cadence.
|
||||||
|
|
||||||
|
### Week 2 — Post #5
|
||||||
|
|
||||||
|
- "What 'integrating with a 3PL' actually means in practice"
|
||||||
|
- Long-form, technical-but-readable. Walks through the actual data
|
||||||
|
flows (orders out, ASNs in, inventory snapshots, rate quotes).
|
||||||
|
- Target reader: ops director at a $5M–$50M DTC brand who's about to
|
||||||
|
switch fulfillment partners.
|
||||||
|
|
||||||
|
### Week 3 — Post #6
|
||||||
|
|
||||||
|
- "Five questions to ask before signing a Magento maintenance
|
||||||
|
contract" (or Shopify Plus, or BigCommerce — pick the platform you
|
||||||
|
see most in your pipeline)
|
||||||
|
- Format: short, punchy, pitch-adjacent. The kind of thing that gets
|
||||||
|
shared in operator Slack groups.
|
||||||
|
|
||||||
|
### Week 4 — Post #7 + outreach
|
||||||
|
|
||||||
|
- "How I structure block-of-time engagements (and why)" — meta-post
|
||||||
|
about the work, modeled on Aaron & Joel's transparent posts about
|
||||||
|
their model.
|
||||||
|
- Outreach: send one personalized note per day (5 total) to someone
|
||||||
|
in your network who runs a small e-commerce or logistics business.
|
||||||
|
Not "looking for work" — "I'm publishing again, here's a piece you
|
||||||
|
might find useful."
|
||||||
|
|
||||||
|
### Already-shipped seed posts
|
||||||
|
|
||||||
|
These are live in `src/content/blog/`:
|
||||||
|
|
||||||
|
1. **"Why I take 'glue work' seriously (and you should too)"** —
|
||||||
|
manifesto / positioning post. The thing to link from your bio.
|
||||||
|
2. **"How to hire a senior PHP developer (without getting burned)"** —
|
||||||
|
the buyer's guide. Long-tail SEO + great share fodder for
|
||||||
|
founders considering hiring.
|
||||||
|
3. **"A pre-launch checklist for connecting your store to a 3PL"** —
|
||||||
|
demonstrates expertise; works as a bookmark/leadmagnet.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Lead-capture levers (cheap & high-leverage)
|
||||||
|
|
||||||
|
In rough order of effort vs payoff:
|
||||||
|
|
||||||
|
1. **Update LinkedIn headline** to match the home page positioning,
|
||||||
|
*exactly*. ("PHP for e-commerce + 3PL operations" — not "Senior
|
||||||
|
PHP Developer".)
|
||||||
|
2. **Add the Cal.com link to your LinkedIn About section.** Make
|
||||||
|
booking a call a one-click action from anywhere you exist online.
|
||||||
|
3. **Update GitHub profile README** with the same one-liner + a link.
|
||||||
|
4. **Pin the "How to hire a PHP dev" post** to your Twitter/X profile.
|
||||||
|
5. **Submit the site to relevant directories**:
|
||||||
|
- Laravel Artisan (if doing Laravel work)
|
||||||
|
- PHP-FIG members directory (long shot)
|
||||||
|
- The Codeable / Toptal alternative directories
|
||||||
|
6. **Newsletter (later, not now).** Once you have 6+ posts, set up a
|
||||||
|
simple email signup. Don't bother before that — there's nothing to
|
||||||
|
subscribe *to*.
|
||||||
|
7. **Speak at one Laravel/PHP meetup or conference in the next 6
|
||||||
|
months.** Even a 10-minute lightning talk on a real war story from
|
||||||
|
the Pritikin or Americold work. Conference speaking is the single
|
||||||
|
highest-leverage move for moving from $75 → $150/hr.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## How to talk about the rate
|
||||||
|
|
||||||
|
When the call gets to "what do you charge":
|
||||||
|
|
||||||
|
- **Don't lead with the number.** Lead with the engagement shape.
|
||||||
|
("For projects like what you're describing, I usually structure it
|
||||||
|
as a fixed-price sprint after a paid discovery week. The discovery
|
||||||
|
is $X; the sprint is quoted from there.")
|
||||||
|
- **Anchor on outcomes.** ("The work usually pays for itself within Y
|
||||||
|
months because [specific operational savings].")
|
||||||
|
- **State the hourly with a context, not a defense.** ("My hourly is
|
||||||
|
$150 — you're paying for fifteen years of e-commerce/3PL work and
|
||||||
|
the fact that I'm reading your codebase, not directing a junior.")
|
||||||
|
- **Have a "no" ready.** ("If your budget is closer to $X, I can
|
||||||
|
recommend two people who do excellent work in that range — happy
|
||||||
|
to introduce you.")
|
||||||
|
|
||||||
|
The faster you can say "no" cleanly, the easier "yes" gets.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What I won't put on the site
|
||||||
|
|
||||||
|
- **Stock photos of "developers."** Nothing erodes a freelance brand
|
||||||
|
faster.
|
||||||
|
- **Lorem ipsum.** None of it. Real copy or no copy.
|
||||||
|
- **A "process" page with seven generic steps.** Every consultancy
|
||||||
|
has one. They convert nobody. The case studies do this job better.
|
||||||
|
- **Pricing tiers in dollars** — yet. Not until you're consistently
|
||||||
|
closing at $150/hr.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What's wired for SEO / LLM discoverability
|
||||||
|
|
||||||
|
- **JSON-LD structured data** on every important page:
|
||||||
|
- Home → `Organization`
|
||||||
|
- About → `Person` (with `knowsLanguage: [English, German]` and
|
||||||
|
`knowsAbout` listing your tech stack)
|
||||||
|
- Services → multiple `Service` objects, one per engagement option,
|
||||||
|
with the current sale prices
|
||||||
|
- Each blog post → `Article` + `BreadcrumbList`
|
||||||
|
- Each case study → `Article` + `BreadcrumbList`
|
||||||
|
- **`/sitemap.xml`** — auto-generated from the route list and the
|
||||||
|
blog/work registries (`src/app/sitemap.ts`)
|
||||||
|
- **`/robots.txt`** — allows everything except `/thank-you`; points at
|
||||||
|
the sitemap (`src/app/robots.ts`)
|
||||||
|
- **`/llms.txt`** at site root (`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.*
|
||||||
@@ -1 +0,0 @@
|
|||||||
/* /en/404.html 404
|
|
||||||
29
amplify.yml
@@ -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/**/*
|
|
||||||
|
Before Width: | Height: | Size: 3.9 KiB |
|
Before Width: | Height: | Size: 77 KiB |
|
Before Width: | Height: | Size: 79 KiB |
|
Before Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 4.8 KiB |
|
Before Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 8.1 KiB |
|
Before Width: | Height: | Size: 105 KiB |
|
Before Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 16 KiB |
@@ -1 +0,0 @@
|
|||||||
// Add your own custom styles here
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
################ English language ##################
|
|
||||||
[en]
|
|
||||||
languageName = "En"
|
|
||||||
languageCode = "en-us"
|
|
||||||
contentDir = "content/english"
|
|
||||||
weight = 1
|
|
||||||
@@ -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
|
|
||||||
@@ -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"
|
|
||||||
@@ -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 = "© 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&id=a0a2c6d074" # replace this url with yours
|
|
||||||
mailchimp_form_name = "b_463ee871f45d2d93748e77cad_a0a2c6d074"
|
|
||||||
@@ -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
|
|
||||||
@@ -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"
|
|
||||||
|
|
||||||
---
|
|
||||||
@@ -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
|
|
||||||
*/
|
|
||||||
```
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
---
|
|
||||||
title: "Authors"
|
|
||||||
---
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
---
|
|
||||||
title: "Blog Posts"
|
|
||||||
meta_title: ""
|
|
||||||
description: "this is meta description"
|
|
||||||
---
|
|
||||||
@@ -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!
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
---
|
|
||||||
title: "Contact"
|
|
||||||
meta_title: ""
|
|
||||||
description: "this is meta description"
|
|
||||||
draft: false
|
|
||||||
---
|
|
||||||
@@ -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.
|
|
||||||
@@ -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"
|
|
||||||
---
|
|
||||||
@@ -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"
|
|
||||||
---
|
|
||||||
@@ -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"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -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
@@ -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
@@ -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
@@ -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
|
|
||||||
14
i18n/en.yaml
@@ -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
@@ -0,0 +1,5 @@
|
|||||||
|
import type { MDXComponents } from "mdx/types";
|
||||||
|
|
||||||
|
export function useMDXComponents(components: MDXComponents): MDXComponents {
|
||||||
|
return { ...components };
|
||||||
|
}
|
||||||
18
next.config.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import type { NextConfig } from "next";
|
||||||
|
import createMDX from "@next/mdx";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
const nextConfig: NextConfig = {
|
||||||
|
pageExtensions: ["ts", "tsx", "md", "mdx"],
|
||||||
|
turbopack: {
|
||||||
|
root: path.resolve(__dirname),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const withMDX = createMDX({
|
||||||
|
options: {
|
||||||
|
remarkPlugins: ["remark-gfm"],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default withMDX(nextConfig);
|
||||||
61
package.json
@@ -1,39 +1,34 @@
|
|||||||
{
|
{
|
||||||
"name": "hugoplate",
|
"name": "new-site",
|
||||||
"description": "hugo tailwindcss boilerplate",
|
"version": "0.1.0",
|
||||||
"version": "1.17.0",
|
"private": true,
|
||||||
"license": "MIT",
|
|
||||||
"author": "zeon.studio",
|
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "hugo server",
|
"dev": "next dev",
|
||||||
"build": "hugo --gc --minify --templateMetrics --templateMetricsHints --forceSyncStatic",
|
"build": "next build",
|
||||||
"preview": "hugo server --disableFastRender --navigateToChanged --templateMetrics --templateMetricsHints --watch --forceSyncStatic -e production --minify",
|
"start": "next start",
|
||||||
"dev:example": "cd exampleSite && hugo server",
|
"lint": "eslint"
|
||||||
"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",
|
"dependencies": {
|
||||||
"update-modules": "node ./scripts/clearModules.js && hugo mod clean --all && hugo mod get -u ./... && hugo mod tidy",
|
"@mdx-js/loader": "^3.1.1",
|
||||||
"remove-darkmode": "node ./scripts/removeDarkmode.js && yarn format",
|
"@mdx-js/react": "^3.1.1",
|
||||||
"project-setup": "node ./scripts/projectSetup.js",
|
"@next/mdx": "^16.2.4",
|
||||||
"theme-setup": "node ./scripts/themeSetup.js",
|
"@types/mdx": "^2.0.13",
|
||||||
"update-theme": "node ./scripts/themeUpdate.js",
|
"@vercel/analytics": "^2.0.1",
|
||||||
"format": "prettier -w ."
|
"gray-matter": "^4.0.3",
|
||||||
|
"next": "16.2.4",
|
||||||
|
"react": "19.2.4",
|
||||||
|
"react-dom": "19.2.4",
|
||||||
|
"remark-gfm": "^4.0.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/forms": "^0.5.9",
|
"@tailwindcss/postcss": "^4",
|
||||||
"@tailwindcss/typography": "^0.5.15",
|
"@tailwindcss/typography": "^0.5.19",
|
||||||
"autoprefixer": "^10.4.20",
|
"@types/node": "^20",
|
||||||
"postcss": "^8.4.47",
|
"@types/react": "^19",
|
||||||
"postcss-cli": "^11.0.0",
|
"@types/react-dom": "^19",
|
||||||
"prettier": "^3.3.3",
|
"eslint": "^9",
|
||||||
"prettier-plugin-go-template": "0.0.15",
|
"eslint-config-next": "16.2.4",
|
||||||
"prettier-plugin-tailwindcss": "^0.6.8",
|
"tailwindcss": "^4",
|
||||||
"tailwind-bootstrap-grid": "^5.1.0",
|
"typescript": "^5"
|
||||||
"tailwindcss": "^3.4.13"
|
|
||||||
},
|
|
||||||
"postcss": {
|
|
||||||
"plugins": {
|
|
||||||
"tailwindcss": {},
|
|
||||||
"autoprefixer": {}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
5414
pnpm-lock.yaml
generated
Normal file
3
pnpm-workspace.yaml
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
ignoredBuiltDependencies:
|
||||||
|
- sharp
|
||||||
|
- unrs-resolver
|
||||||
@@ -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
@@ -0,0 +1,7 @@
|
|||||||
|
const config = {
|
||||||
|
plugins: {
|
||||||
|
"@tailwindcss/postcss": {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
34
public/llms.txt
Normal 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
@@ -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). It’s 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**!
|
|
||||||
|
|
||||||
| [](https://open-neuromorphic.org/) | [](https://aimodels.org/) | [](https://www.hugobricks.preview.usecue.com/) | [](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).
|
|
||||||
@@ -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");
|
|
||||||
@@ -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();
|
|
||||||
@@ -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]];
|
|
||||||
}
|
|
||||||
@@ -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();
|
|
||||||
@@ -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
@@ -0,0 +1,96 @@
|
|||||||
|
import { Button } from "@/components/button";
|
||||||
|
import { Container } from "@/components/container";
|
||||||
|
import { HowIWork } from "@/components/how-i-work";
|
||||||
|
import { JsonLd } from "@/components/jsonld";
|
||||||
|
import { Eyebrow } from "@/components/section-heading";
|
||||||
|
import { personSchema } from "@/lib/jsonld";
|
||||||
|
import type { Metadata } from "next";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "About",
|
||||||
|
description:
|
||||||
|
"Chris Smith — solo PHP developer running Five Devs, LLC out of Henderson, NV. Fifteen years of shipping the unglamorous parts of e-commerce and logistics.",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function AboutPage() {
|
||||||
|
return (
|
||||||
|
<article className="py-20 sm:py-28">
|
||||||
|
<JsonLd data={personSchema()} />
|
||||||
|
<Container width="narrow">
|
||||||
|
<Eyebrow>About</Eyebrow>
|
||||||
|
<h1 className="mt-6 font-serif text-4xl font-semibold leading-tight tracking-tight text-ink sm:text-6xl">
|
||||||
|
Hi, I’m Chris.
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<div className="prose prose-lg mt-10 max-w-none text-ink-soft">
|
||||||
|
<p className="text-xl leading-relaxed">
|
||||||
|
I’ve been writing PHP since 2010 and running{" "}
|
||||||
|
<strong className="text-ink">Five Devs, LLC</strong> for years
|
||||||
|
out of Henderson, Nevada. Before Five Devs I ran CGSmith LLC,
|
||||||
|
doing more or less the same work under a different name.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
Most of my work lives in the same place: the messy seam between
|
||||||
|
an online store, a warehouse, and a back office. Order parsing.
|
||||||
|
EDI translations. Shipping rates and labels. Inventory sync.
|
||||||
|
Carrier integrations. The kind of code that, when it works, you
|
||||||
|
forget exists — and when it doesn’t, you
|
||||||
|
can’t ship a single box.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
I’ve worked with companies like{" "}
|
||||||
|
<strong className="text-ink">Pritikin Foods</strong>,{" "}
|
||||||
|
<strong className="text-ink">Americold</strong>,{" "}
|
||||||
|
<strong className="text-ink">Fortune Fulfillment</strong>, and{" "}
|
||||||
|
<strong className="text-ink">HD Financial</strong>. Some of
|
||||||
|
those engagements have run for years. That’s the part of
|
||||||
|
this work I take real pride in — not the one-off fires,
|
||||||
|
but being someone a small team can rely on for the long haul.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2 className="mt-12 font-serif text-2xl font-semibold text-ink">
|
||||||
|
How I work
|
||||||
|
</h2>
|
||||||
|
<HowIWork />
|
||||||
|
|
||||||
|
<h2 className="mt-12 font-serif text-2xl font-semibold text-ink">
|
||||||
|
Tools I reach for
|
||||||
|
</h2>
|
||||||
|
<p>
|
||||||
|
PHP (Laravel, Symfony, plain ol’ PHP), MySQL/Postgres,
|
||||||
|
Redis, queue workers (Horizon, Supervisor), Filament for
|
||||||
|
internal admin, and whatever JS makes the page render. On the
|
||||||
|
ops side: Linux, Docker, GitHub Actions, AWS, Cloudflare. I
|
||||||
|
care more about the shape of the system than the brand on the
|
||||||
|
box.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2 className="mt-12 font-serif text-2xl font-semibold text-ink">
|
||||||
|
Sending a letter?
|
||||||
|
</h2>
|
||||||
|
<p>Sure. No fax.</p>
|
||||||
|
<pre className="overflow-x-auto rounded-xl border border-line/80 bg-cream-soft p-5 font-mono text-sm text-ink-soft">{`Five Devs, LLC
|
||||||
|
1887 Whitney Mesa Dr. PMB 7325
|
||||||
|
Henderson, NV 89014
|
||||||
|
USA`}</pre>
|
||||||
|
|
||||||
|
<p className="mt-10 text-sm italic text-muted">
|
||||||
|
Auch auf Deutsch verfügbar — schreiben Sie mir gern auf
|
||||||
|
Deutsch.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-14 flex flex-wrap items-center gap-4">
|
||||||
|
<Button href="/contact" variant="primary">
|
||||||
|
Book a call
|
||||||
|
</Button>
|
||||||
|
<Button href="/work" variant="secondary">
|
||||||
|
See some work
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Container>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
112
src/app/blog/[slug]/page.tsx
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import { Button } from "@/components/button";
|
||||||
|
import { Container } from "@/components/container";
|
||||||
|
import { JsonLd } from "@/components/jsonld";
|
||||||
|
import { Eyebrow } from "@/components/section-heading";
|
||||||
|
import { articleSchema, breadcrumbSchema } from "@/lib/jsonld";
|
||||||
|
import { formatDate, getAllSlugs, getPost } from "@/lib/posts";
|
||||||
|
import type { Metadata } from "next";
|
||||||
|
|
||||||
|
type Params = Promise<{ slug: string }>;
|
||||||
|
|
||||||
|
export async function generateStaticParams() {
|
||||||
|
return getAllSlugs().map((slug) => ({ slug }));
|
||||||
|
}
|
||||||
|
|
||||||
|
export const dynamicParams = false;
|
||||||
|
|
||||||
|
export async function generateMetadata({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Params;
|
||||||
|
}): Promise<Metadata> {
|
||||||
|
const { slug } = await params;
|
||||||
|
const post = getPost(slug);
|
||||||
|
if (!post) return {};
|
||||||
|
return {
|
||||||
|
title: post.metadata.title,
|
||||||
|
description: post.metadata.description,
|
||||||
|
openGraph: {
|
||||||
|
title: post.metadata.title,
|
||||||
|
description: post.metadata.description,
|
||||||
|
type: "article",
|
||||||
|
publishedTime: post.metadata.date,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function BlogPostPage({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Params;
|
||||||
|
}) {
|
||||||
|
const { slug } = await params;
|
||||||
|
const post = getPost(slug);
|
||||||
|
if (!post) notFound();
|
||||||
|
|
||||||
|
const { Content, metadata } = post;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article className="py-20 sm:py-28">
|
||||||
|
<JsonLd
|
||||||
|
data={articleSchema({
|
||||||
|
title: metadata.title,
|
||||||
|
description: metadata.description,
|
||||||
|
slug,
|
||||||
|
date: metadata.date,
|
||||||
|
basePath: "blog",
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
<JsonLd
|
||||||
|
data={breadcrumbSchema([
|
||||||
|
{ name: "Home", path: "/" },
|
||||||
|
{ name: "Writing", path: "/blog" },
|
||||||
|
{ name: metadata.title, path: `/blog/${slug}` },
|
||||||
|
])}
|
||||||
|
/>
|
||||||
|
<Container width="narrow">
|
||||||
|
<Link
|
||||||
|
href="/blog"
|
||||||
|
className="text-sm text-muted hover:text-ink"
|
||||||
|
>
|
||||||
|
← All writing
|
||||||
|
</Link>
|
||||||
|
<Eyebrow className="mt-8">Writing</Eyebrow>
|
||||||
|
<h1 className="mt-4 font-serif text-4xl font-semibold leading-tight tracking-tight text-ink sm:text-5xl">
|
||||||
|
{metadata.title}
|
||||||
|
</h1>
|
||||||
|
<div className="mt-5 flex flex-wrap items-baseline gap-x-4 gap-y-1 text-sm text-muted">
|
||||||
|
<time>{formatDate(metadata.date)}</time>
|
||||||
|
<span>·</span>
|
||||||
|
<span>{metadata.readingMinutes} min read</span>
|
||||||
|
{metadata.tags?.length ? (
|
||||||
|
<>
|
||||||
|
<span>·</span>
|
||||||
|
<span>{metadata.tags.join(", ")}</span>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="prose prose-lg mt-12 max-w-none text-ink-soft prose-headings:font-serif prose-headings:text-ink prose-headings:font-semibold prose-h2:mt-12 prose-h2:text-2xl prose-h3:text-xl prose-strong:text-ink prose-blockquote:border-accent prose-blockquote:text-ink prose-a:text-accent prose-a:no-underline hover:prose-a:underline">
|
||||||
|
<Content />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr className="my-16 border-line/70" />
|
||||||
|
<div className="flex flex-col items-start gap-6 rounded-2xl border border-line/80 bg-cream-soft p-8 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div className="max-w-md">
|
||||||
|
<h3 className="font-serif text-xl font-semibold text-ink">
|
||||||
|
Need a senior PHP developer in your corner?
|
||||||
|
</h3>
|
||||||
|
<p className="mt-2 text-ink-soft">
|
||||||
|
Five Devs is me, Chris. Twenty minutes, no pitch deck.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button href="/contact" variant="primary">
|
||||||
|
Book a call
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Container>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
52
src/app/blog/page.tsx
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { Container } from "@/components/container";
|
||||||
|
import { Eyebrow } from "@/components/section-heading";
|
||||||
|
import { posts, formatDate } from "@/lib/posts";
|
||||||
|
import type { Metadata } from "next";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "Writing",
|
||||||
|
description:
|
||||||
|
"Notes on PHP, e-commerce integrations, freelancing, and the unglamorous parts of running a small software business.",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function BlogIndex() {
|
||||||
|
return (
|
||||||
|
<section className="py-20 sm:py-28">
|
||||||
|
<Container width="narrow">
|
||||||
|
<Eyebrow>Writing</Eyebrow>
|
||||||
|
<h1 className="mt-6 font-serif text-4xl font-semibold leading-tight tracking-tight text-ink sm:text-6xl">
|
||||||
|
Field notes from the glue layer.
|
||||||
|
</h1>
|
||||||
|
<p className="mt-7 max-w-xl text-lg leading-relaxed text-ink-soft">
|
||||||
|
Mostly about PHP, e-commerce, 3PL integrations, and the
|
||||||
|
decisions that keep small software businesses from getting
|
||||||
|
stuck.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<ul className="mt-16 divide-y divide-line/70 border-y border-line/70">
|
||||||
|
{posts.map((p) => (
|
||||||
|
<li key={p.slug}>
|
||||||
|
<Link
|
||||||
|
href={`/blog/${p.slug}`}
|
||||||
|
className="group block py-9 transition-colors hover:bg-cream-soft"
|
||||||
|
>
|
||||||
|
<div className="flex flex-wrap items-baseline gap-x-4 gap-y-1 text-sm text-muted">
|
||||||
|
<time>{formatDate(p.metadata.date)}</time>
|
||||||
|
<span>·</span>
|
||||||
|
<span>{p.metadata.readingMinutes} min read</span>
|
||||||
|
</div>
|
||||||
|
<h2 className="mt-2 font-serif text-2xl font-semibold leading-tight text-ink group-hover:text-accent">
|
||||||
|
{p.metadata.title}
|
||||||
|
</h2>
|
||||||
|
<p className="mt-3 text-ink-soft">
|
||||||
|
{p.metadata.description}
|
||||||
|
</p>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</Container>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
95
src/app/contact/page.tsx
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
import { Button } from "@/components/button";
|
||||||
|
import { Container } from "@/components/container";
|
||||||
|
import { ContactForm } from "@/components/contact-form";
|
||||||
|
import { Eyebrow } from "@/components/section-heading";
|
||||||
|
import type { Metadata } from "next";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "Contact",
|
||||||
|
description:
|
||||||
|
"Three ways to reach Five Devs: book a 20-minute intro call, send a quick note, or email Chris directly.",
|
||||||
|
};
|
||||||
|
|
||||||
|
const calLink = "https://cal.com/cgsmith/intro";
|
||||||
|
|
||||||
|
export default function ContactPage() {
|
||||||
|
return (
|
||||||
|
<section className="py-20 sm:py-28">
|
||||||
|
<Container>
|
||||||
|
<div className="grid gap-16 lg:grid-cols-[1.1fr_1fr]">
|
||||||
|
{/* Left: pitch */}
|
||||||
|
<div>
|
||||||
|
<Eyebrow>Get in touch</Eyebrow>
|
||||||
|
<h1 className="mt-6 font-serif text-4xl font-semibold leading-tight tracking-tight text-ink sm:text-5xl">
|
||||||
|
Tell me what’s on the back burner.
|
||||||
|
</h1>
|
||||||
|
<p className="mt-7 max-w-md text-lg leading-relaxed text-ink-soft">
|
||||||
|
The fastest way to find out if I can help is a 20-minute
|
||||||
|
call. No pitch deck, no SDR email sequence — just a
|
||||||
|
real conversation about what you’re trying to do.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="mt-10 space-y-6">
|
||||||
|
<div className="rounded-2xl border border-ink bg-ink p-7 text-cream">
|
||||||
|
<p className="font-mono text-xs uppercase tracking-[0.18em] text-cream/60">
|
||||||
|
Recommended
|
||||||
|
</p>
|
||||||
|
<h2 className="mt-2 font-serif text-2xl font-semibold">
|
||||||
|
Book a 20-min intro call
|
||||||
|
</h2>
|
||||||
|
<p className="mt-2 text-cream/80">
|
||||||
|
Pick a time that works for you. I’ll come ready
|
||||||
|
with questions.
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
href={calLink}
|
||||||
|
variant="secondary"
|
||||||
|
className="mt-5 border-cream/30 text-cream hover:border-cream hover:bg-cream/10"
|
||||||
|
>
|
||||||
|
Open my calendar →
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-2xl border border-line/80 bg-cream-soft p-7">
|
||||||
|
<h2 className="font-serif text-xl font-semibold text-ink">
|
||||||
|
Prefer email?
|
||||||
|
</h2>
|
||||||
|
<p className="mt-2 text-ink-soft">
|
||||||
|
No problem.{" "}
|
||||||
|
<a
|
||||||
|
href="mailto:chris@fivedevs.com"
|
||||||
|
className="font-medium text-ink underline underline-offset-4"
|
||||||
|
>
|
||||||
|
chris@fivedevs.com
|
||||||
|
</a>{" "}
|
||||||
|
goes straight to me.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-sm text-muted">
|
||||||
|
<p>
|
||||||
|
<span className="font-medium text-ink">Mailing address:</span>{" "}
|
||||||
|
Five Devs, LLC · 1887 Whitney Mesa Dr. PMB 7325
|
||||||
|
· Henderson, NV 89014 · USA
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right: form */}
|
||||||
|
<div className="rounded-2xl border border-line/80 bg-cream-soft p-8 sm:p-10">
|
||||||
|
<h2 className="font-serif text-2xl font-semibold text-ink">
|
||||||
|
Or send a quick note
|
||||||
|
</h2>
|
||||||
|
<p className="mt-2 text-sm text-muted">
|
||||||
|
I read every message and reply within one business day.
|
||||||
|
</p>
|
||||||
|
<div className="mt-8">
|
||||||
|
<ContactForm />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Container>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
BIN
src/app/favicon.ico
Normal file
|
After Width: | Height: | Size: 25 KiB |
141
src/app/globals.css
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
@plugin "@tailwindcss/typography";
|
||||||
|
|
||||||
|
@theme {
|
||||||
|
--color-cream: #f6f1e8;
|
||||||
|
--color-cream-soft: #faf6ee;
|
||||||
|
--color-ink: #1a1815;
|
||||||
|
--color-ink-soft: #3a352e;
|
||||||
|
--color-muted: #76706a;
|
||||||
|
--color-line: #e7e2d8;
|
||||||
|
--color-accent: #b45309;
|
||||||
|
--color-accent-soft: #fef3c7;
|
||||||
|
|
||||||
|
--font-serif: "Fraunces", ui-serif, Georgia, "Times New Roman", serif;
|
||||||
|
--font-sans: "Inter", ui-sans-serif, system-ui, -apple-system, "Segoe UI",
|
||||||
|
Helvetica, Arial, sans-serif;
|
||||||
|
--font-mono: ui-monospace, "JetBrains Mono", "SF Mono", Menlo, Consolas,
|
||||||
|
monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
background: var(--color-cream);
|
||||||
|
color: var(--color-ink);
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
text-rendering: optimizeLegibility;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
font-feature-settings: "ss01", "cv11";
|
||||||
|
}
|
||||||
|
|
||||||
|
::selection {
|
||||||
|
background: var(--color-accent);
|
||||||
|
color: var(--color-cream-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hero ambient glow — a soft warm radial that gently drifts. */
|
||||||
|
.hero-glow {
|
||||||
|
position: absolute;
|
||||||
|
pointer-events: none;
|
||||||
|
inset: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
z-index: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-glow::before,
|
||||||
|
.hero-glow::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
border-radius: 9999px;
|
||||||
|
filter: blur(90px);
|
||||||
|
opacity: 0.55;
|
||||||
|
will-change: transform;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-glow::before {
|
||||||
|
width: 520px;
|
||||||
|
height: 520px;
|
||||||
|
top: -120px;
|
||||||
|
right: -80px;
|
||||||
|
background: radial-gradient(
|
||||||
|
closest-side,
|
||||||
|
color-mix(in oklab, var(--color-accent) 45%, transparent),
|
||||||
|
transparent 70%
|
||||||
|
);
|
||||||
|
animation: hero-drift-a 18s ease-in-out infinite alternate;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-glow::after {
|
||||||
|
width: 420px;
|
||||||
|
height: 420px;
|
||||||
|
top: 60px;
|
||||||
|
left: -120px;
|
||||||
|
background: radial-gradient(
|
||||||
|
closest-side,
|
||||||
|
color-mix(in oklab, #d97706 35%, transparent),
|
||||||
|
transparent 70%
|
||||||
|
);
|
||||||
|
opacity: 0.35;
|
||||||
|
animation: hero-drift-b 22s ease-in-out infinite alternate;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes hero-drift-a {
|
||||||
|
0% {
|
||||||
|
transform: translate3d(0, 0, 0) scale(1);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: translate3d(-40px, 30px, 0) scale(1.05);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes hero-drift-b {
|
||||||
|
0% {
|
||||||
|
transform: translate3d(0, 0, 0) scale(1);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: translate3d(30px, -20px, 0) scale(1.08);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Drawn-in underline beneath accent words. */
|
||||||
|
.accent-highlight {
|
||||||
|
position: relative;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.accent-highlight::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0.05em;
|
||||||
|
height: 0.16em;
|
||||||
|
background: color-mix(in oklab, var(--color-accent) 35%, transparent);
|
||||||
|
border-radius: 2px;
|
||||||
|
transform-origin: left center;
|
||||||
|
transform: scaleX(0);
|
||||||
|
animation: highlight-grow 0.9s cubic-bezier(0.65, 0, 0.35, 1) 0.35s forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
.accent-highlight:nth-of-type(2)::after {
|
||||||
|
animation-delay: 0.6s;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes highlight-grow {
|
||||||
|
to {
|
||||||
|
transform: scaleX(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.hero-glow::before,
|
||||||
|
.hero-glow::after {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
.accent-highlight::after {
|
||||||
|
animation: none;
|
||||||
|
transform: scaleX(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
62
src/app/layout.tsx
Normal 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
@@ -0,0 +1,140 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { Button } from "@/components/button";
|
||||||
|
import { ClientLogos } from "@/components/client-logos";
|
||||||
|
import { Container } from "@/components/container";
|
||||||
|
import { Guarantees } from "@/components/guarantees";
|
||||||
|
import { JsonLd } from "@/components/jsonld";
|
||||||
|
import { PricingTeaser } from "@/components/pricing-teaser";
|
||||||
|
import { Eyebrow, SectionHeading } from "@/components/section-heading";
|
||||||
|
import { Testimonials } from "@/components/testimonials";
|
||||||
|
import { organizationSchema } from "@/lib/jsonld";
|
||||||
|
|
||||||
|
const services = [
|
||||||
|
{
|
||||||
|
title: "E-commerce + 3PL integrations",
|
||||||
|
body:
|
||||||
|
"Order import, inventory sync, label generation, EDI bridges, the parts of your stack nobody wants to touch. Built right, kept boring.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Legacy PHP rescue",
|
||||||
|
body:
|
||||||
|
"Old Magento, custom CodeIgniter, Symfony 4 stuck on EOL — I read the code, write tests, and ship the upgrade without a six-month rewrite project.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "On-call ops + tech support",
|
||||||
|
body:
|
||||||
|
"Quiet, fast help when production breaks at 4pm on a Tuesday. Returned phone calls, root-caused bugs, post-mortems your team can use.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Greenfield internal apps",
|
||||||
|
body:
|
||||||
|
"From idea to running tool in weeks: Laravel, Filament, Postgres, deployed where your team already operates.",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function HomePage() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<JsonLd data={organizationSchema()} />
|
||||||
|
{/* Hero */}
|
||||||
|
<section className="relative overflow-hidden pt-20 pb-24 sm:pt-28 sm:pb-32">
|
||||||
|
<div className="hero-glow" aria-hidden="true" />
|
||||||
|
<Container className="relative z-10">
|
||||||
|
<div className="max-w-3xl">
|
||||||
|
<Eyebrow>Five Devs · PHP since 2010</Eyebrow>
|
||||||
|
<h1 className="mt-6 font-serif text-4xl font-semibold leading-[1.05] tracking-tight text-ink sm:text-6xl">
|
||||||
|
The PHP help your{" "}
|
||||||
|
<span className="accent-highlight text-accent">e-commerce</span>{" "}
|
||||||
|
and{" "}
|
||||||
|
<span className="accent-highlight text-accent">3PL</span> stack
|
||||||
|
actually needs.
|
||||||
|
</h1>
|
||||||
|
<p className="mt-7 max-w-xl text-lg leading-relaxed text-ink-soft">
|
||||||
|
I’m Chris Smith. I’ve spent fifteen years building
|
||||||
|
the unglamorous, mission-critical glue between online stores,
|
||||||
|
warehouses, and back offices — and quietly keeping it
|
||||||
|
running for companies that depend on it.
|
||||||
|
</p>
|
||||||
|
<div className="mt-9 flex flex-wrap items-center gap-4">
|
||||||
|
<Button href="/contact" variant="primary">
|
||||||
|
Book an intro call →
|
||||||
|
</Button>
|
||||||
|
<Button href="/work" variant="secondary">
|
||||||
|
See the work
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Container>
|
||||||
|
|
||||||
|
{/* Logos strip */}
|
||||||
|
<Container className="mt-20 sm:mt-28">
|
||||||
|
<ClientLogos />
|
||||||
|
</Container>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Services */}
|
||||||
|
<section className="py-24 sm:py-32">
|
||||||
|
<Container>
|
||||||
|
<SectionHeading
|
||||||
|
eyebrow="What I do"
|
||||||
|
title="Four things, done well, on a senior level."
|
||||||
|
subtitle="No agency overhead. No bait-and-switch to junior devs. You hire me, you get me — and the years that come with it."
|
||||||
|
/>
|
||||||
|
<ul className="mt-14 grid gap-x-10 gap-y-12 sm:grid-cols-2">
|
||||||
|
{services.map((s, i) => (
|
||||||
|
<li key={s.title} className="space-y-3">
|
||||||
|
<p className="font-mono text-sm text-accent">
|
||||||
|
0{i + 1}
|
||||||
|
</p>
|
||||||
|
<h3 className="font-serif text-xl font-semibold text-ink">
|
||||||
|
{s.title}
|
||||||
|
</h3>
|
||||||
|
<p className="leading-relaxed text-ink-soft">{s.body}</p>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<div className="mt-12">
|
||||||
|
<Link
|
||||||
|
href="/services"
|
||||||
|
className="text-sm font-medium text-ink underline-offset-4 hover:underline"
|
||||||
|
>
|
||||||
|
See engagement options →
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</Container>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<PricingTeaser />
|
||||||
|
|
||||||
|
<Testimonials />
|
||||||
|
|
||||||
|
<Guarantees />
|
||||||
|
|
||||||
|
{/* CTA */}
|
||||||
|
<section className="py-24 sm:py-32">
|
||||||
|
<Container width="narrow" className="text-center">
|
||||||
|
<Eyebrow>Let’s talk</Eyebrow>
|
||||||
|
<h2 className="mt-6 font-serif text-3xl font-semibold tracking-tight text-ink sm:text-5xl">
|
||||||
|
Got a PHP project that’s been on the back burner?
|
||||||
|
</h2>
|
||||||
|
<p className="mx-auto mt-6 max-w-xl text-lg leading-relaxed text-ink-soft">
|
||||||
|
Twenty minutes, no pitch deck. Tell me what’s broken or
|
||||||
|
stuck and I’ll tell you whether I can help — and what
|
||||||
|
it would take.
|
||||||
|
</p>
|
||||||
|
<div className="mt-9 flex flex-wrap items-center justify-center gap-4">
|
||||||
|
<Button href="/contact" variant="primary">
|
||||||
|
Book a call
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
href="mailto:chris@fivedevs.com"
|
||||||
|
variant="ghost"
|
||||||
|
>
|
||||||
|
chris@fivedevs.com
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Container>
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
16
src/app/robots.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import type { MetadataRoute } from "next";
|
||||||
|
import { SITE_URL } from "@/lib/site";
|
||||||
|
|
||||||
|
export default function robots(): MetadataRoute.Robots {
|
||||||
|
return {
|
||||||
|
rules: [
|
||||||
|
{
|
||||||
|
userAgent: "*",
|
||||||
|
allow: "/",
|
||||||
|
disallow: ["/thank-you"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
sitemap: `${SITE_URL}/sitemap.xml`,
|
||||||
|
host: SITE_URL,
|
||||||
|
};
|
||||||
|
}
|
||||||
373
src/app/services/page.tsx
Normal file
@@ -0,0 +1,373 @@
|
|||||||
|
import { Button } from "@/components/button";
|
||||||
|
import { Container } from "@/components/container";
|
||||||
|
import { GuaranteesMini } from "@/components/guarantees";
|
||||||
|
import { JsonLd } from "@/components/jsonld";
|
||||||
|
import { Eyebrow, SectionHeading } from "@/components/section-heading";
|
||||||
|
import { serviceSchema } from "@/lib/jsonld";
|
||||||
|
import {
|
||||||
|
blocks,
|
||||||
|
formatPrice,
|
||||||
|
retainer,
|
||||||
|
saleActive,
|
||||||
|
saleLabel,
|
||||||
|
type CheckoutOption,
|
||||||
|
} from "@/lib/pricing";
|
||||||
|
import { SITE_URL } from "@/lib/site";
|
||||||
|
import type { Metadata } from "next";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "Services & Engagements",
|
||||||
|
description:
|
||||||
|
"Three ways to work with Five Devs: a one-shot project, a monthly retainer for ongoing help, or a block of senior engineering time you can spend as you go.",
|
||||||
|
};
|
||||||
|
|
||||||
|
function PriceLine({
|
||||||
|
option,
|
||||||
|
perSuffix,
|
||||||
|
invert,
|
||||||
|
size = "lg",
|
||||||
|
}: {
|
||||||
|
option: CheckoutOption;
|
||||||
|
perSuffix?: string;
|
||||||
|
invert?: boolean;
|
||||||
|
size?: "lg" | "md";
|
||||||
|
}) {
|
||||||
|
const muted = invert ? "text-cream/60" : "text-muted";
|
||||||
|
const strong = invert ? "text-cream" : "text-ink";
|
||||||
|
const showSale = option.salePrice !== undefined;
|
||||||
|
const priceClass =
|
||||||
|
size === "lg"
|
||||||
|
? "text-3xl font-semibold tracking-tight"
|
||||||
|
: "text-2xl font-semibold tracking-tight";
|
||||||
|
const strikeClass = size === "lg" ? "text-lg line-through" : "text-base line-through";
|
||||||
|
return (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex items-baseline gap-3">
|
||||||
|
{showSale ? (
|
||||||
|
<>
|
||||||
|
<span className={`${priceClass} ${strong}`}>
|
||||||
|
{formatPrice(option.salePrice!)}
|
||||||
|
{perSuffix ? (
|
||||||
|
<span className={`text-base font-normal ${muted}`}>
|
||||||
|
{perSuffix}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
<span className={`${strikeClass} ${muted}`}>
|
||||||
|
{formatPrice(option.regularPrice)}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span className={`${priceClass} ${strong}`}>
|
||||||
|
{formatPrice(option.regularPrice)}
|
||||||
|
{perSuffix ? (
|
||||||
|
<span className={`text-base font-normal ${muted}`}>
|
||||||
|
{perSuffix}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className={`text-sm ${muted}`}>
|
||||||
|
{showSale && option.saleEffectiveHourly !== undefined
|
||||||
|
? `${formatPrice(option.saleEffectiveHourly)}/hr effective`
|
||||||
|
: `${formatPrice(option.effectiveHourly)}/hr effective`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function BlockRow({ option }: { option: CheckoutOption }) {
|
||||||
|
const wired = !!option.buyHref;
|
||||||
|
const inner = (
|
||||||
|
<div className="flex flex-col gap-2 py-5 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div className="flex items-baseline gap-4">
|
||||||
|
<p className="font-medium text-ink">{option.label}</p>
|
||||||
|
{wired ? (
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
className="font-mono text-base text-muted opacity-0 transition-all group-hover:translate-x-1 group-hover:text-accent group-hover:opacity-100"
|
||||||
|
>
|
||||||
|
→
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<PriceLine option={option} size="md" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!wired) return <li className="px-1">{inner}</li>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
href={option.buyHref}
|
||||||
|
className="group block -mx-3 rounded-lg px-3 transition-colors hover:bg-cream"
|
||||||
|
aria-label={`Buy ${option.label}`}
|
||||||
|
>
|
||||||
|
{inner}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SaleBadge({ invert }: { invert?: boolean }) {
|
||||||
|
if (!saleActive) return null;
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={`inline-flex items-center rounded-full px-3 py-1 text-xs font-medium uppercase tracking-wider ${
|
||||||
|
invert
|
||||||
|
? "bg-cream/10 text-cream ring-1 ring-cream/30"
|
||||||
|
: "bg-accent/10 text-accent ring-1 ring-accent/30"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{saleLabel}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const servicesUrl = `${SITE_URL}/services`;
|
||||||
|
|
||||||
|
const serviceSchemas = [
|
||||||
|
serviceSchema({
|
||||||
|
name: "Monthly PHP development retainer",
|
||||||
|
description:
|
||||||
|
"Reserved senior PHP development hours each month. Ideal for teams without a senior PHP developer in-house.",
|
||||||
|
url: servicesUrl,
|
||||||
|
price: retainer.salePrice ?? retainer.regularPrice,
|
||||||
|
priceType: "Subscription",
|
||||||
|
}),
|
||||||
|
...blocks.map((b) =>
|
||||||
|
serviceSchema({
|
||||||
|
name: `${b.label} of senior PHP development time`,
|
||||||
|
description: `Block of ${b.label.replace("-hour block", " hours")} of senior PHP development time, usable within 6 months.`,
|
||||||
|
url: servicesUrl,
|
||||||
|
price: b.salePrice ?? b.regularPrice,
|
||||||
|
priceType: "OneTime",
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
serviceSchema({
|
||||||
|
name: "Project sprint",
|
||||||
|
description:
|
||||||
|
"Fixed-scope, fixed-price PHP development engagement quoted after a paid discovery sprint.",
|
||||||
|
url: servicesUrl,
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function ServicesPage() {
|
||||||
|
const retainerWired = !!retainer.buyHref;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{serviceSchemas.map((schema, i) => (
|
||||||
|
<JsonLd key={i} data={schema} />
|
||||||
|
))}
|
||||||
|
<section className="py-20 sm:py-28">
|
||||||
|
<Container>
|
||||||
|
<div className="max-w-2xl">
|
||||||
|
<Eyebrow>Services</Eyebrow>
|
||||||
|
<h1 className="mt-6 font-serif text-4xl font-semibold leading-tight tracking-tight text-ink sm:text-6xl">
|
||||||
|
Three ways to work together.
|
||||||
|
</h1>
|
||||||
|
<p className="mt-7 text-lg leading-relaxed text-ink-soft">
|
||||||
|
Most freelance engagements fall into one of three shapes.
|
||||||
|
Pick the one that matches how your work actually arrives.
|
||||||
|
Block and retainer engagements are{" "}
|
||||||
|
<span className="font-medium text-ink">self-serve</span>{" "}
|
||||||
|
— check out below, or book a call first if
|
||||||
|
you’d rather talk.
|
||||||
|
</p>
|
||||||
|
{saleActive ? (
|
||||||
|
<div className="mt-8 flex flex-wrap items-center gap-3">
|
||||||
|
<SaleBadge />
|
||||||
|
<p className="text-sm text-ink-soft">
|
||||||
|
Half off retainer and block engagements while the
|
||||||
|
redesign is fresh. First clients lock it in.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</Container>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="pb-24 sm:pb-32">
|
||||||
|
<Container>
|
||||||
|
<ul className="grid gap-6 lg:grid-cols-3">
|
||||||
|
{/* Project sprint — no checkout */}
|
||||||
|
<li className="flex flex-col rounded-2xl border border-line/80 bg-cream-soft p-8">
|
||||||
|
<h2 className="font-serif text-2xl font-semibold text-ink">
|
||||||
|
Project sprint
|
||||||
|
</h2>
|
||||||
|
<p className="mt-3 text-lg leading-snug text-ink-soft">
|
||||||
|
A defined piece of work, scoped, quoted, shipped.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="mt-7 space-y-1">
|
||||||
|
<p className="text-3xl font-semibold tracking-tight text-ink">
|
||||||
|
Custom quote
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-muted">
|
||||||
|
Fixed-price after a paid discovery
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul className="mt-7 space-y-3 text-sm leading-relaxed text-ink-soft">
|
||||||
|
{[
|
||||||
|
"Two weeks to two months",
|
||||||
|
"Daily progress in shared Slack or GitHub",
|
||||||
|
"Documented handoff at the end",
|
||||||
|
].map((b) => (
|
||||||
|
<li key={b} className="flex gap-3">
|
||||||
|
<span className="mt-2 inline-block h-1 w-1 flex-none rounded-full bg-accent" />
|
||||||
|
<span>{b}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<p className="mt-7 text-sm italic text-muted">
|
||||||
|
Best when you know exactly what you need built or fixed.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="mt-auto pt-7">
|
||||||
|
<Button href="/contact" variant="primary">
|
||||||
|
Discuss a project →
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
{/* Monthly retainer — featured, with checkout */}
|
||||||
|
<li className="flex flex-col rounded-2xl border border-ink bg-ink p-8 text-cream">
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<h2 className="font-serif text-2xl font-semibold text-cream">
|
||||||
|
Monthly retainer
|
||||||
|
</h2>
|
||||||
|
<SaleBadge invert />
|
||||||
|
</div>
|
||||||
|
<p className="mt-3 text-lg leading-snug text-cream/80">
|
||||||
|
Quiet, predictable senior help on the long tail.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="mt-7">
|
||||||
|
<PriceLine option={retainer} perSuffix="/mo" invert />
|
||||||
|
<p className="mt-2 text-sm text-cream/70">
|
||||||
|
{retainer.hoursLabel}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul className="mt-7 space-y-3 text-sm leading-relaxed text-cream/85">
|
||||||
|
{[
|
||||||
|
"Reserved hours each month",
|
||||||
|
"Priority on incidents and requests",
|
||||||
|
"Quarterly roadmap & tech-health check-ins",
|
||||||
|
"Pause or cancel with 30 days notice",
|
||||||
|
].map((b) => (
|
||||||
|
<li key={b} className="flex gap-3">
|
||||||
|
<span className="mt-2 inline-block h-1 w-1 flex-none rounded-full bg-cream/70" />
|
||||||
|
<span>{b}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<p className="mt-7 text-sm italic text-cream/70">
|
||||||
|
Best for teams without a senior PHP developer in-house.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="mt-auto space-y-3 pt-7">
|
||||||
|
<Button
|
||||||
|
href={retainer.buyHref ?? "/contact"}
|
||||||
|
variant="secondary"
|
||||||
|
className="w-full justify-center border-cream/30 text-cream hover:border-cream hover:bg-cream/10"
|
||||||
|
>
|
||||||
|
{retainerWired
|
||||||
|
? "Subscribe — $2,500/mo →"
|
||||||
|
: "Talk first →"}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
href="/contact"
|
||||||
|
variant="ghost"
|
||||||
|
className="w-full justify-center text-cream/70 hover:text-cream"
|
||||||
|
>
|
||||||
|
Or book a call first
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
{/* Block of time — selectable rows */}
|
||||||
|
<li className="flex flex-col rounded-2xl border border-line/80 bg-cream-soft p-8">
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<h2 className="font-serif text-2xl font-semibold text-ink">
|
||||||
|
Block of time
|
||||||
|
</h2>
|
||||||
|
<SaleBadge />
|
||||||
|
</div>
|
||||||
|
<p className="mt-3 text-lg leading-snug text-ink-soft">
|
||||||
|
Buy a block, spend it as needs come up.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<ul className="mt-7 divide-y divide-line/80 border-y border-line/80">
|
||||||
|
{blocks.map((b) => (
|
||||||
|
<BlockRow key={b.id} option={b} />
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<p className="mt-7 text-sm italic text-muted">
|
||||||
|
Best for work that comes in bursts — integrations,
|
||||||
|
migrations, audits.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="mt-auto pt-7">
|
||||||
|
<Button
|
||||||
|
href="/contact"
|
||||||
|
variant="secondary"
|
||||||
|
className="w-full justify-center"
|
||||||
|
>
|
||||||
|
Or book a call first
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<p className="mt-10 max-w-2xl text-sm text-muted">
|
||||||
|
All purchases trigger a confirmation email and a link to
|
||||||
|
book your kickoff call. You’ll hear from me within
|
||||||
|
one business day.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="mt-12">
|
||||||
|
<GuaranteesMini />
|
||||||
|
</div>
|
||||||
|
</Container>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="border-t border-line/70 bg-cream-soft py-24 sm:py-32">
|
||||||
|
<Container width="narrow">
|
||||||
|
<SectionHeading
|
||||||
|
eyebrow="A note on rates"
|
||||||
|
title="Senior, transparent, and worth it."
|
||||||
|
subtitle="Rates anchor in the senior end of the market. Retainer and block rates are lower per hour because I can plan around them."
|
||||||
|
/>
|
||||||
|
<div className="mt-12 space-y-6 text-ink-soft">
|
||||||
|
<p>
|
||||||
|
Project sprints are quoted as a fixed price after a short
|
||||||
|
paid discovery so we both know what we’re
|
||||||
|
committing to. Retainer and block prices are listed above
|
||||||
|
and you can check out directly — no email
|
||||||
|
ping-pong.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
I do not subcontract or hand work off to junior
|
||||||
|
developers without telling you. If a project genuinely
|
||||||
|
needs a second pair of hands, I’ll tell you who and
|
||||||
|
why before they touch your code.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="mt-12 flex flex-wrap items-center gap-4">
|
||||||
|
<Button href="/contact" variant="primary">
|
||||||
|
Book a call
|
||||||
|
</Button>
|
||||||
|
<Button href="mailto:chris@fivedevs.com" variant="ghost">
|
||||||
|
chris@fivedevs.com
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Container>
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
33
src/app/sitemap.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import type { MetadataRoute } from "next";
|
||||||
|
import { SITE_URL } from "@/lib/site";
|
||||||
|
import { posts } from "@/lib/posts";
|
||||||
|
import { caseStudies } from "@/lib/case-studies";
|
||||||
|
|
||||||
|
export default function sitemap(): MetadataRoute.Sitemap {
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
const staticRoutes: MetadataRoute.Sitemap = [
|
||||||
|
{ url: `${SITE_URL}/`, lastModified: now, priority: 1.0, changeFrequency: "monthly" },
|
||||||
|
{ url: `${SITE_URL}/services`, lastModified: now, priority: 0.9, changeFrequency: "monthly" },
|
||||||
|
{ url: `${SITE_URL}/work`, lastModified: now, priority: 0.8, changeFrequency: "monthly" },
|
||||||
|
{ url: `${SITE_URL}/about`, lastModified: now, priority: 0.7, changeFrequency: "yearly" },
|
||||||
|
{ url: `${SITE_URL}/contact`, lastModified: now, priority: 0.7, changeFrequency: "yearly" },
|
||||||
|
{ url: `${SITE_URL}/blog`, lastModified: now, priority: 0.7, changeFrequency: "weekly" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const blogRoutes: MetadataRoute.Sitemap = posts.map((p) => ({
|
||||||
|
url: `${SITE_URL}/blog/${p.slug}`,
|
||||||
|
lastModified: new Date(p.metadata.date),
|
||||||
|
priority: 0.6,
|
||||||
|
changeFrequency: "yearly",
|
||||||
|
}));
|
||||||
|
|
||||||
|
const workRoutes: MetadataRoute.Sitemap = caseStudies.map((c) => ({
|
||||||
|
url: `${SITE_URL}/work/${c.slug}`,
|
||||||
|
lastModified: now,
|
||||||
|
priority: 0.6,
|
||||||
|
changeFrequency: "yearly",
|
||||||
|
}));
|
||||||
|
|
||||||
|
return [...staticRoutes, ...blogRoutes, ...workRoutes];
|
||||||
|
}
|
||||||
107
src/app/thank-you/page.tsx
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
import { Button } from "@/components/button";
|
||||||
|
import { Container } from "@/components/container";
|
||||||
|
import { Eyebrow } from "@/components/section-heading";
|
||||||
|
import type { Metadata } from "next";
|
||||||
|
|
||||||
|
const calLink = "https://cal.com/cgsmith/kickoff";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "Thanks — let's get started",
|
||||||
|
description:
|
||||||
|
"Your purchase is confirmed. Book your kickoff call and Chris will be in touch within one business day.",
|
||||||
|
robots: { index: false, follow: false },
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function ThankYouPage() {
|
||||||
|
return (
|
||||||
|
<section className="py-24 sm:py-32">
|
||||||
|
<Container width="narrow">
|
||||||
|
<Eyebrow>Confirmed</Eyebrow>
|
||||||
|
<h1 className="mt-6 font-serif text-4xl font-semibold leading-tight tracking-tight text-ink sm:text-5xl">
|
||||||
|
You’re in. Thank you.
|
||||||
|
</h1>
|
||||||
|
<p className="mt-7 text-lg leading-relaxed text-ink-soft">
|
||||||
|
Stripe has the receipt on the way. Here’s what happens
|
||||||
|
next — in this order.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<ol className="mt-12 space-y-8">
|
||||||
|
<li className="flex gap-5">
|
||||||
|
<span className="flex h-9 w-9 flex-none items-center justify-center rounded-full bg-accent/10 font-mono text-sm font-medium text-accent">
|
||||||
|
1
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<h2 className="font-serif text-xl font-semibold text-ink">
|
||||||
|
Book your kickoff call
|
||||||
|
</h2>
|
||||||
|
<p className="mt-2 text-ink-soft">
|
||||||
|
Twenty minutes. Bring access details, repos, and the
|
||||||
|
short list of what you want to tackle first. Pick any
|
||||||
|
slot that works.
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
href={calLink}
|
||||||
|
variant="primary"
|
||||||
|
className="mt-5"
|
||||||
|
>
|
||||||
|
Open my calendar →
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<li className="flex gap-5">
|
||||||
|
<span className="flex h-9 w-9 flex-none items-center justify-center rounded-full bg-accent/10 font-mono text-sm font-medium text-accent">
|
||||||
|
2
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<h2 className="font-serif text-xl font-semibold text-ink">
|
||||||
|
Watch your inbox
|
||||||
|
</h2>
|
||||||
|
<p className="mt-2 text-ink-soft">
|
||||||
|
I’ll send a short onboarding note within one
|
||||||
|
business day — what to expect, how I track time,
|
||||||
|
and a one-page mutual NDA if you’d like one. If
|
||||||
|
you don’t see anything by tomorrow afternoon,
|
||||||
|
check spam, then{" "}
|
||||||
|
<a
|
||||||
|
href="mailto:chris@fivedevs.com"
|
||||||
|
className="text-accent underline-offset-4 hover:underline"
|
||||||
|
>
|
||||||
|
chris@fivedevs.com
|
||||||
|
</a>
|
||||||
|
.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<li className="flex gap-5">
|
||||||
|
<span className="flex h-9 w-9 flex-none items-center justify-center rounded-full bg-accent/10 font-mono text-sm font-medium text-accent">
|
||||||
|
3
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<h2 className="font-serif text-xl font-semibold text-ink">
|
||||||
|
We get to work
|
||||||
|
</h2>
|
||||||
|
<p className="mt-2 text-ink-soft">
|
||||||
|
After the kickoff, work starts the same week. You
|
||||||
|
get weekly updates by default; daily if your retainer
|
||||||
|
or block is in active mode.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<hr className="my-16 border-line/70" />
|
||||||
|
<p className="text-sm text-muted">
|
||||||
|
Need to reach me before the kickoff?{" "}
|
||||||
|
<a
|
||||||
|
href="mailto:chris@fivedevs.com"
|
||||||
|
className="text-ink underline underline-offset-4"
|
||||||
|
>
|
||||||
|
chris@fivedevs.com
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
</Container>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
207
src/app/work/[slug]/page.tsx
Normal file
@@ -0,0 +1,207 @@
|
|||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { Button } from "@/components/button";
|
||||||
|
import { Container } from "@/components/container";
|
||||||
|
import { JsonLd } from "@/components/jsonld";
|
||||||
|
import { Eyebrow } from "@/components/section-heading";
|
||||||
|
import { articleSchema, breadcrumbSchema } from "@/lib/jsonld";
|
||||||
|
import { caseStudies } from "@/lib/case-studies";
|
||||||
|
import type { Metadata } from "next";
|
||||||
|
|
||||||
|
type Params = Promise<{ slug: string }>;
|
||||||
|
|
||||||
|
const bodies: Record<string, React.ReactNode> = {
|
||||||
|
"pritikin-foods": (
|
||||||
|
<>
|
||||||
|
<h2>The shape of the problem</h2>
|
||||||
|
<p>
|
||||||
|
Pritikin Foods sells direct-to-consumer prepared meals nationwide.
|
||||||
|
Every order from the e-commerce site has to be parsed, validated,
|
||||||
|
priced for shipping, and handed off to their 3PL fulfillment
|
||||||
|
partner — on time, with the right items, to the right
|
||||||
|
address, in the right shipping window.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
That sounds simple. It isn’t. There’s SKU
|
||||||
|
normalization, address validation, transit-time math against meal
|
||||||
|
perishability, special-handling rules, batch hand-offs, and a
|
||||||
|
whole tail of reconciliation when something doesn’t match
|
||||||
|
what the warehouse actually shipped.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>What I built and what I maintain</h2>
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
The order-parsing pipeline that translates e-commerce orders
|
||||||
|
into the format the 3PL accepts.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
The shipping and label flow: rate calculation, label
|
||||||
|
generation, and carrier-side error handling.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Reconciliation tooling so customer service can answer
|
||||||
|
“where is my order?” in seconds, not minutes.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
The boring, on-call work of keeping it all running as carriers,
|
||||||
|
rates, and partners change.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2>Why it’s been a multi-year relationship</h2>
|
||||||
|
<p>
|
||||||
|
This work isn’t glamorous, but it’s the difference
|
||||||
|
between Pritikin shipping orders today and not. Emil
|
||||||
|
Boschert’s words say it best:
|
||||||
|
</p>
|
||||||
|
<blockquote>
|
||||||
|
“They are vital for our e-commerce site and our 3PL.”
|
||||||
|
</blockquote>
|
||||||
|
<p>
|
||||||
|
That’s the bar I aim for on every long engagement —
|
||||||
|
becoming the kind of help a small team can stop worrying about.
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
americold: (
|
||||||
|
<>
|
||||||
|
<h2>The shape of the problem</h2>
|
||||||
|
<p>
|
||||||
|
Americold is one of the largest temperature-controlled warehousing
|
||||||
|
and logistics networks in the world. At their scale, even
|
||||||
|
“small” pieces of operational tooling touch a lot of
|
||||||
|
people, a lot of pallets, and a lot of carriers.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>What the engagement looked like</h2>
|
||||||
|
<p>
|
||||||
|
Senior-level PHP work on customer-facing logistics tooling.
|
||||||
|
Solution-oriented, customer-focused, delivered consistently
|
||||||
|
— in the words of Americold’s Timothy Dorcas:
|
||||||
|
</p>
|
||||||
|
<blockquote>
|
||||||
|
“They are solution oriented, customer focused, and
|
||||||
|
consistently deliver at a high level. Highly recommended.”
|
||||||
|
</blockquote>
|
||||||
|
|
||||||
|
<h2>Why this kind of work fits Five Devs</h2>
|
||||||
|
<p>
|
||||||
|
Enterprise logistics teams don’t need another agency. They
|
||||||
|
need a senior developer who can read the existing code, talk to
|
||||||
|
the operations people, ship the change, and write down what they
|
||||||
|
learned — without three layers of project management
|
||||||
|
between them and the work.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
That’s the role I’ve played for Americold and others.
|
||||||
|
I show up, get up to speed quickly on the existing stack, and
|
||||||
|
keep the work moving.
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function generateStaticParams() {
|
||||||
|
return caseStudies.map((c) => ({ slug: c.slug }));
|
||||||
|
}
|
||||||
|
|
||||||
|
export const dynamicParams = false;
|
||||||
|
|
||||||
|
export async function generateMetadata({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Params;
|
||||||
|
}): Promise<Metadata> {
|
||||||
|
const { slug } = await params;
|
||||||
|
const study = caseStudies.find((c) => c.slug === slug);
|
||||||
|
if (!study) return {};
|
||||||
|
return {
|
||||||
|
title: `${study.client} — Case Study`,
|
||||||
|
description: study.summary,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function CaseStudyPage({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Params;
|
||||||
|
}) {
|
||||||
|
const { slug } = await params;
|
||||||
|
const study = caseStudies.find((c) => c.slug === slug);
|
||||||
|
if (!study) notFound();
|
||||||
|
const body = bodies[slug];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article className="py-20 sm:py-28">
|
||||||
|
<JsonLd
|
||||||
|
data={articleSchema({
|
||||||
|
title: `${study.client} — Case Study`,
|
||||||
|
description: study.summary,
|
||||||
|
slug,
|
||||||
|
date: "2026-01-01",
|
||||||
|
basePath: "work",
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
<JsonLd
|
||||||
|
data={breadcrumbSchema([
|
||||||
|
{ name: "Home", path: "/" },
|
||||||
|
{ name: "Work", path: "/work" },
|
||||||
|
{ name: study.client, path: `/work/${slug}` },
|
||||||
|
])}
|
||||||
|
/>
|
||||||
|
<Container width="narrow">
|
||||||
|
<Link
|
||||||
|
href="/work"
|
||||||
|
className="text-sm text-muted hover:text-ink"
|
||||||
|
>
|
||||||
|
← All work
|
||||||
|
</Link>
|
||||||
|
<Eyebrow className="mt-8">Case study</Eyebrow>
|
||||||
|
<h1 className="mt-4 font-serif text-4xl font-semibold leading-tight tracking-tight text-ink sm:text-5xl">
|
||||||
|
{study.client}
|
||||||
|
</h1>
|
||||||
|
<p
|
||||||
|
className="mt-3 text-sm text-muted"
|
||||||
|
dangerouslySetInnerHTML={{ __html: study.industry }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<dl className="mt-10 grid grid-cols-2 gap-6 border-y border-line/70 py-6 text-sm sm:grid-cols-3">
|
||||||
|
<div>
|
||||||
|
<dt className="text-xs uppercase tracking-wider text-muted">
|
||||||
|
Role
|
||||||
|
</dt>
|
||||||
|
<dd className="mt-1 text-ink">{study.role ?? "Developer"}</dd>
|
||||||
|
</div>
|
||||||
|
{study.yearsRunning ? (
|
||||||
|
<div>
|
||||||
|
<dt className="text-xs uppercase tracking-wider text-muted">
|
||||||
|
Duration
|
||||||
|
</dt>
|
||||||
|
<dd className="mt-1 text-ink">{study.yearsRunning}</dd>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<div>
|
||||||
|
<dt className="text-xs uppercase tracking-wider text-muted">
|
||||||
|
Outcome
|
||||||
|
</dt>
|
||||||
|
<dd className="mt-1 text-ink">{study.outcome}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
<div className="prose prose-lg mt-12 max-w-none text-ink-soft prose-headings:font-serif prose-headings:text-ink prose-headings:font-semibold prose-h2:mt-12 prose-h2:text-2xl prose-strong:text-ink prose-blockquote:border-accent prose-blockquote:text-ink">
|
||||||
|
{body ?? <p>{study.summary}</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-16 flex flex-wrap items-center gap-4">
|
||||||
|
<Button href="/contact" variant="primary">
|
||||||
|
Talk about your project
|
||||||
|
</Button>
|
||||||
|
<Button href="/work" variant="secondary">
|
||||||
|
More work
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Container>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
65
src/app/work/page.tsx
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { Container } from "@/components/container";
|
||||||
|
import { Eyebrow } from "@/components/section-heading";
|
||||||
|
import { caseStudies } from "@/lib/case-studies";
|
||||||
|
import type { Metadata } from "next";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "Work",
|
||||||
|
description:
|
||||||
|
"Selected client engagements: e-commerce + 3PL integration work, ongoing ops support, and senior-level PHP development for teams that need it to just work.",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function WorkPage() {
|
||||||
|
return (
|
||||||
|
<section className="py-20 sm:py-28">
|
||||||
|
<Container>
|
||||||
|
<Eyebrow>Selected work</Eyebrow>
|
||||||
|
<h1 className="mt-6 max-w-3xl font-serif text-4xl font-semibold leading-tight tracking-tight text-ink sm:text-6xl">
|
||||||
|
Companies that quietly depend on Five Devs.
|
||||||
|
</h1>
|
||||||
|
<p className="mt-7 max-w-xl text-lg leading-relaxed text-ink-soft">
|
||||||
|
A few of the engagements I’m most proud of. These are the
|
||||||
|
relationships that lasted — some of them for years.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<ul className="mt-16 divide-y divide-line/70 border-y border-line/70">
|
||||||
|
{caseStudies.map((c) => (
|
||||||
|
<li key={c.slug}>
|
||||||
|
<Link
|
||||||
|
href={`/work/${c.slug}`}
|
||||||
|
className="group grid gap-6 py-10 transition-colors hover:bg-cream-soft sm:grid-cols-[1fr_2fr] sm:gap-10"
|
||||||
|
>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p
|
||||||
|
className="text-sm text-muted"
|
||||||
|
dangerouslySetInnerHTML={{ __html: c.industry }}
|
||||||
|
/>
|
||||||
|
<h2 className="font-serif text-2xl font-semibold text-ink">
|
||||||
|
{c.client}
|
||||||
|
</h2>
|
||||||
|
{c.yearsRunning ? (
|
||||||
|
<p className="font-mono text-xs uppercase tracking-wider text-accent">
|
||||||
|
{c.yearsRunning}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-3 text-ink-soft">
|
||||||
|
<p className="text-lg leading-relaxed">{c.summary}</p>
|
||||||
|
<p className="text-sm font-medium text-ink underline-offset-4 group-hover:underline">
|
||||||
|
Read the case study →
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<p className="mt-12 max-w-xl text-sm text-muted">
|
||||||
|
Several long-running engagements aren’t listed here under
|
||||||
|
NDA. Happy to share more on a call.
|
||||||
|
</p>
|
||||||
|
</Container>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
42
src/components/button.tsx
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import type { AnchorHTMLAttributes, ReactNode } from "react";
|
||||||
|
import { cn } from "@/lib/cn";
|
||||||
|
|
||||||
|
type Variant = "primary" | "secondary" | "ghost";
|
||||||
|
|
||||||
|
const base =
|
||||||
|
"inline-flex items-center justify-center gap-2 rounded-full px-5 py-2.5 text-sm font-medium tracking-tight transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent";
|
||||||
|
|
||||||
|
const variants: Record<Variant, string> = {
|
||||||
|
primary: "bg-ink text-cream hover:bg-ink-soft",
|
||||||
|
secondary:
|
||||||
|
"bg-transparent text-ink border border-ink/20 hover:border-ink hover:bg-ink/[0.03]",
|
||||||
|
ghost: "text-ink hover:text-accent",
|
||||||
|
};
|
||||||
|
|
||||||
|
type Props = AnchorHTMLAttributes<HTMLAnchorElement> & {
|
||||||
|
href: string;
|
||||||
|
variant?: Variant;
|
||||||
|
children: ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function Button({
|
||||||
|
href,
|
||||||
|
variant = "primary",
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: Props) {
|
||||||
|
const isExternal = href.startsWith("http") || href.startsWith("mailto:");
|
||||||
|
const Comp: typeof Link | "a" = isExternal ? "a" : Link;
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
href={href}
|
||||||
|
className={cn(base, variants[variant], className)}
|
||||||
|
{...(isExternal ? { rel: "noopener noreferrer" } : {})}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Comp>
|
||||||
|
);
|
||||||
|
}
|
||||||
67
src/components/client-logos.tsx
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
/**
|
||||||
|
* Renders the "Trusted by" strip on the home page.
|
||||||
|
*
|
||||||
|
* Currently uses styled text wordmarks. To swap in real logos later:
|
||||||
|
* 1. Drop SVG/PNG into /public/logos/<slug>.svg
|
||||||
|
* 2. Add a `logo` field to the matching entry below
|
||||||
|
* 3. Update the JSX to render <img> when `logo` is present
|
||||||
|
*
|
||||||
|
* Get explicit permission from each client before publishing real
|
||||||
|
* marks — see STRATEGY.md.
|
||||||
|
*/
|
||||||
|
|
||||||
|
type Client = {
|
||||||
|
name: string;
|
||||||
|
/** Optional URL the wordmark links to. Omit for non-linked. */
|
||||||
|
href?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const clients: Client[] = [
|
||||||
|
{ name: "Americold", href: "https://www.americold.com" },
|
||||||
|
{ name: "Butcher Box", href: "https://www.butcherbox.com" },
|
||||||
|
{ name: "Perfect Bar", href: "https://www.perfectsnacks.com" },
|
||||||
|
{ name: "Pritikin Foods" },
|
||||||
|
{ name: "Potawatomi Business Development" },
|
||||||
|
{ name: "Fortune Fulfillment" },
|
||||||
|
{ name: "Vanderose Farms" },
|
||||||
|
{ name: "PERC Engage", href: "https://percengage.com" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function ClientLogos() {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-medium uppercase tracking-[0.18em] text-muted">
|
||||||
|
Trusted by teams at
|
||||||
|
</p>
|
||||||
|
<ul className="mt-7 grid grid-cols-2 items-center gap-x-10 gap-y-6 sm:grid-cols-4">
|
||||||
|
{clients.map((c) => {
|
||||||
|
const wordmark = (
|
||||||
|
<span className="block font-serif text-lg leading-tight tracking-tight text-ink-soft transition-colors group-hover:text-ink sm:text-xl">
|
||||||
|
{c.name}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
key={c.name}
|
||||||
|
className="group flex min-h-[44px] items-center justify-center text-center"
|
||||||
|
>
|
||||||
|
{c.href ? (
|
||||||
|
<a
|
||||||
|
href={c.href}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="block"
|
||||||
|
aria-label={c.name}
|
||||||
|
>
|
||||||
|
{wordmark}
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
wordmark
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
103
src/components/contact-form.tsx
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import type { FormEvent } from "react";
|
||||||
|
|
||||||
|
const recipient = "chris@fivedevs.com";
|
||||||
|
|
||||||
|
export function ContactForm() {
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
const [company, setCompany] = useState("");
|
||||||
|
const [message, setMessage] = useState("");
|
||||||
|
|
||||||
|
function handleSubmit(e: FormEvent<HTMLFormElement>) {
|
||||||
|
e.preventDefault();
|
||||||
|
const subject = `New project inquiry from ${name || "site visitor"}`;
|
||||||
|
const lines = [
|
||||||
|
`Name: ${name}`,
|
||||||
|
`Reply-to: ${email}`,
|
||||||
|
company ? `Company: ${company}` : null,
|
||||||
|
"",
|
||||||
|
message,
|
||||||
|
].filter(Boolean);
|
||||||
|
const body = lines.join("\n");
|
||||||
|
const href =
|
||||||
|
`mailto:${recipient}` +
|
||||||
|
`?subject=${encodeURIComponent(subject)}` +
|
||||||
|
`&body=${encodeURIComponent(body)}`;
|
||||||
|
window.location.href = href;
|
||||||
|
}
|
||||||
|
|
||||||
|
const inputBase =
|
||||||
|
"w-full rounded-lg border border-line bg-cream-soft px-4 py-3 text-base text-ink placeholder:text-muted focus:border-ink focus:outline-none";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-5">
|
||||||
|
<div className="grid gap-5 sm:grid-cols-2">
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-1.5 block text-sm font-medium text-ink-soft">
|
||||||
|
Your name
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
required
|
||||||
|
type="text"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
className={inputBase}
|
||||||
|
placeholder="Jane Doe"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-1.5 block text-sm font-medium text-ink-soft">
|
||||||
|
Email
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
required
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
className={inputBase}
|
||||||
|
placeholder="jane@company.com"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-1.5 block text-sm font-medium text-ink-soft">
|
||||||
|
Company <span className="text-muted">(optional)</span>
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={company}
|
||||||
|
onChange={(e) => setCompany(e.target.value)}
|
||||||
|
className={inputBase}
|
||||||
|
placeholder="Acme Co."
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-1.5 block text-sm font-medium text-ink-soft">
|
||||||
|
What’s the project?
|
||||||
|
</span>
|
||||||
|
<textarea
|
||||||
|
required
|
||||||
|
rows={6}
|
||||||
|
value={message}
|
||||||
|
onChange={(e) => setMessage(e.target.value)}
|
||||||
|
className={`${inputBase} resize-y`}
|
||||||
|
placeholder="A few sentences on what's broken or what you'd like to build."
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div className="flex flex-wrap items-center gap-4 pt-2">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="inline-flex items-center justify-center gap-2 rounded-full bg-ink px-6 py-3 text-sm font-medium text-cream transition-colors hover:bg-ink-soft"
|
||||||
|
>
|
||||||
|
Send message →
|
||||||
|
</button>
|
||||||
|
<p className="text-sm text-muted">
|
||||||
|
Opens in your email client.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
27
src/components/container.tsx
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { cn } from "@/lib/cn";
|
||||||
|
import type { HTMLAttributes } from "react";
|
||||||
|
|
||||||
|
type Width = "narrow" | "default" | "wide";
|
||||||
|
|
||||||
|
const widthClass: Record<Width, string> = {
|
||||||
|
narrow: "max-w-2xl",
|
||||||
|
default: "max-w-5xl",
|
||||||
|
wide: "max-w-6xl",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function Container({
|
||||||
|
width = "default",
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: HTMLAttributes<HTMLDivElement> & { width?: Width }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
{...props}
|
||||||
|
className={cn(
|
||||||
|
"mx-auto w-full px-6 sm:px-8",
|
||||||
|
widthClass[width],
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
120
src/components/guarantees.tsx
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
import { Container } from "./container";
|
||||||
|
import { Eyebrow, SectionHeading } from "./section-heading";
|
||||||
|
import { cn } from "@/lib/cn";
|
||||||
|
|
||||||
|
export const guarantees = [
|
||||||
|
{
|
||||||
|
title: "30-day money back",
|
||||||
|
body:
|
||||||
|
"If your first month on a retainer or block doesn't fit, you get it back. No questions, no exit interview. Risk is on me.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "30-day bug-fix window",
|
||||||
|
body:
|
||||||
|
"Bugs in code I ship are fixed at no charge for 30 days after handoff. If it broke under my watch, I own it.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "One business day response",
|
||||||
|
body:
|
||||||
|
"Every retainer and block client gets a real reply inside one business day. Not a ticket number, not an out-of-office.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "You own everything",
|
||||||
|
body:
|
||||||
|
"Code, docs, infra, accounts. Full IP transfer in writing. No subcontracting, no vendor lock-in, no surprises.",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
className?: string;
|
||||||
|
/** Use 'compact' for narrower, in-context placement (e.g. inside another section). */
|
||||||
|
variant?: "default" | "compact";
|
||||||
|
/** Hide the section heading; useful when wrapped in a custom heading. */
|
||||||
|
hideHeading?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function Guarantees({
|
||||||
|
className,
|
||||||
|
variant = "default",
|
||||||
|
hideHeading = false,
|
||||||
|
}: Props) {
|
||||||
|
const isCompact = variant === "compact";
|
||||||
|
const heading = (
|
||||||
|
<SectionHeading
|
||||||
|
eyebrow="Promises in writing"
|
||||||
|
title="Four guarantees on every engagement."
|
||||||
|
subtitle="The senior-freelance market is full of vague promises. Here are mine, written down so we both have them."
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
className={cn(
|
||||||
|
isCompact ? "py-16" : "border-y border-line/70 bg-cream-soft py-24 sm:py-32",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Container>
|
||||||
|
{!hideHeading ? heading : null}
|
||||||
|
<ul
|
||||||
|
className={cn(
|
||||||
|
"grid gap-6",
|
||||||
|
isCompact
|
||||||
|
? "mt-10 sm:grid-cols-2"
|
||||||
|
: "mt-14 sm:grid-cols-2 lg:grid-cols-4",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{guarantees.map((g, i) => (
|
||||||
|
<li
|
||||||
|
key={g.title}
|
||||||
|
className={cn(
|
||||||
|
"rounded-2xl border border-line/80 p-6",
|
||||||
|
isCompact ? "bg-cream" : "bg-cream",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-baseline gap-3">
|
||||||
|
<span className="font-mono text-sm text-accent">0{i + 1}</span>
|
||||||
|
<h3 className="font-serif text-lg font-semibold text-ink">
|
||||||
|
{g.title}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<p className="mt-3 text-sm leading-relaxed text-ink-soft">
|
||||||
|
{g.body}
|
||||||
|
</p>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</Container>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inline mini-list of guarantee titles only — for tight spots
|
||||||
|
* (e.g. inside the Services pricing area) where the full Guarantees
|
||||||
|
* section would be too heavy.
|
||||||
|
*/
|
||||||
|
export function GuaranteesMini() {
|
||||||
|
return (
|
||||||
|
<div className="rounded-2xl border border-line/80 bg-cream p-6">
|
||||||
|
<div className="flex flex-wrap items-baseline gap-3">
|
||||||
|
<Eyebrow>Every engagement includes</Eyebrow>
|
||||||
|
</div>
|
||||||
|
<ul className="mt-4 grid gap-x-8 gap-y-2 text-sm text-ink sm:grid-cols-2">
|
||||||
|
{guarantees.map((g) => (
|
||||||
|
<li key={g.title} className="flex items-start gap-2">
|
||||||
|
<span aria-hidden="true" className="mt-1 text-accent">
|
||||||
|
✓
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
<span className="font-medium">{g.title}.</span>{" "}
|
||||||
|
<span className="text-ink-soft">
|
||||||
|
{g.body.split(".")[0]}.
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
40
src/components/how-i-work.tsx
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
export const principles = [
|
||||||
|
{
|
||||||
|
title: "Senior, end-to-end",
|
||||||
|
body:
|
||||||
|
"You hire me, you get me. I read the code, talk to the people, ship the change, and write down what I learned.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Boring tech, on purpose",
|
||||||
|
body:
|
||||||
|
"Postgres, queues, plain PHP, small dependencies. Fewer moving parts means fewer 3am pages.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Small PRs, fast feedback",
|
||||||
|
body:
|
||||||
|
"You'll see what I'm doing every day, not at the end of a three-week dark period. Reviewing my work should take minutes, not hours.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Documented as I go",
|
||||||
|
body:
|
||||||
|
"Every change ships with a short note explaining why. Your team can pick the work up cleanly if you ever bring it in-house.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Honest about scope",
|
||||||
|
body:
|
||||||
|
"If I'm not the right person for the work, I'll say so — and where I can, point you at someone who is.",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function HowIWork() {
|
||||||
|
return (
|
||||||
|
<ul className="mt-6 space-y-5">
|
||||||
|
{principles.map((p) => (
|
||||||
|
<li key={p.title}>
|
||||||
|
<strong className="text-ink">{p.title}.</strong>{" "}
|
||||||
|
<span className="text-ink-soft">{p.body}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
}
|
||||||
12
src/components/jsonld.tsx
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
/**
|
||||||
|
* Emits a JSON-LD <script> tag. Pass any schema.org-shaped object.
|
||||||
|
* Safe for Server Components.
|
||||||
|
*/
|
||||||
|
export function JsonLd({ data }: { data: object }) {
|
||||||
|
return (
|
||||||
|
<script
|
||||||
|
type="application/ld+json"
|
||||||
|
dangerouslySetInnerHTML={{ __html: JSON.stringify(data) }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
126
src/components/pricing-teaser.tsx
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
import { Container } from "./container";
|
||||||
|
import { Eyebrow } from "./section-heading";
|
||||||
|
import { Button } from "./button";
|
||||||
|
import {
|
||||||
|
blocks,
|
||||||
|
formatPrice,
|
||||||
|
retainer,
|
||||||
|
saleActive,
|
||||||
|
saleLabel,
|
||||||
|
type CheckoutOption,
|
||||||
|
} from "@/lib/pricing";
|
||||||
|
|
||||||
|
function priceText(option: CheckoutOption): string {
|
||||||
|
return option.salePrice !== undefined
|
||||||
|
? formatPrice(option.salePrice)
|
||||||
|
: formatPrice(option.regularPrice);
|
||||||
|
}
|
||||||
|
|
||||||
|
function strikeText(option: CheckoutOption): string | null {
|
||||||
|
return option.salePrice !== undefined
|
||||||
|
? formatPrice(option.regularPrice)
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PricingTeaser() {
|
||||||
|
const smallestBlock = blocks[0];
|
||||||
|
const items: Array<{
|
||||||
|
option: CheckoutOption;
|
||||||
|
title: string;
|
||||||
|
sub: string;
|
||||||
|
perSuffix?: string;
|
||||||
|
buyLabel: string;
|
||||||
|
}> = [
|
||||||
|
{
|
||||||
|
option: smallestBlock,
|
||||||
|
title: smallestBlock.label,
|
||||||
|
sub: "First time working together? Start small.",
|
||||||
|
buyLabel: "Buy 5 hours",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
option: retainer,
|
||||||
|
title: retainer.label,
|
||||||
|
sub: retainer.hoursLabel,
|
||||||
|
perSuffix: "/mo",
|
||||||
|
buyLabel: "Subscribe",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="py-24 sm:py-32">
|
||||||
|
<Container>
|
||||||
|
<div className="flex flex-wrap items-baseline justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<Eyebrow>Quick start</Eyebrow>
|
||||||
|
<h2 className="mt-4 font-serif text-3xl font-semibold tracking-tight text-ink sm:text-4xl">
|
||||||
|
Self-serve, two doors in.
|
||||||
|
</h2>
|
||||||
|
<p className="mt-3 max-w-xl text-ink-soft">
|
||||||
|
No discovery call required if you’re ready to go.
|
||||||
|
{saleActive ? (
|
||||||
|
<>
|
||||||
|
{" "}
|
||||||
|
<span className="font-medium text-accent">
|
||||||
|
{saleLabel}
|
||||||
|
</span>{" "}
|
||||||
|
is live.
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<a
|
||||||
|
href="/services"
|
||||||
|
className="text-sm font-medium text-ink underline-offset-4 hover:underline"
|
||||||
|
>
|
||||||
|
All engagement options →
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul className="mt-10 grid gap-6 sm:grid-cols-2">
|
||||||
|
{items.map(({ option, title, sub, perSuffix, buyLabel }) => {
|
||||||
|
const wired = !!option.buyHref;
|
||||||
|
const strike = strikeText(option);
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
key={option.id}
|
||||||
|
className="flex flex-col rounded-2xl border border-line/80 bg-cream-soft p-7"
|
||||||
|
>
|
||||||
|
<div className="flex flex-wrap items-baseline justify-between gap-3">
|
||||||
|
<h3 className="font-serif text-xl font-semibold text-ink">
|
||||||
|
{title}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-sm text-muted">{sub}</p>
|
||||||
|
|
||||||
|
<div className="mt-6 flex items-baseline gap-3">
|
||||||
|
<span className="text-3xl font-semibold tracking-tight text-ink">
|
||||||
|
{priceText(option)}
|
||||||
|
{perSuffix ? (
|
||||||
|
<span className="text-base font-normal text-muted">
|
||||||
|
{perSuffix}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
{strike ? (
|
||||||
|
<span className="text-lg text-muted line-through">
|
||||||
|
{strike}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-7">
|
||||||
|
<Button
|
||||||
|
href={option.buyHref ?? "/contact"}
|
||||||
|
variant="primary"
|
||||||
|
>
|
||||||
|
{wired ? `${buyLabel} →` : "Talk first →"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</Container>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
50
src/components/section-heading.tsx
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import { cn } from "@/lib/cn";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
export function Eyebrow({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
children: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<p
|
||||||
|
className={cn(
|
||||||
|
"text-xs font-medium uppercase tracking-[0.18em] text-accent",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SectionHeading({
|
||||||
|
eyebrow,
|
||||||
|
title,
|
||||||
|
subtitle,
|
||||||
|
align = "left",
|
||||||
|
}: {
|
||||||
|
eyebrow?: string;
|
||||||
|
title: ReactNode;
|
||||||
|
subtitle?: ReactNode;
|
||||||
|
align?: "left" | "center";
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"max-w-2xl space-y-4",
|
||||||
|
align === "center" && "mx-auto text-center",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{eyebrow ? <Eyebrow>{eyebrow}</Eyebrow> : null}
|
||||||
|
<h2 className="font-serif text-3xl font-semibold tracking-tight text-ink sm:text-4xl">
|
||||||
|
{title}
|
||||||
|
</h2>
|
||||||
|
{subtitle ? (
|
||||||
|
<p className="text-lg leading-relaxed text-ink-soft">{subtitle}</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
52
src/components/site-footer.tsx
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { Container } from "./container";
|
||||||
|
|
||||||
|
export function SiteFooter() {
|
||||||
|
const year = new Date().getFullYear();
|
||||||
|
return (
|
||||||
|
<footer className="mt-32 border-t border-line/70 bg-cream-soft py-12 text-sm text-muted">
|
||||||
|
<Container className="flex flex-col gap-6 sm:flex-row sm:items-end sm:justify-between">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Link
|
||||||
|
href="/"
|
||||||
|
className="font-serif text-lg font-semibold text-ink"
|
||||||
|
>
|
||||||
|
Five Devs<span className="text-accent">.</span>
|
||||||
|
</Link>
|
||||||
|
<p className="max-w-md">
|
||||||
|
Senior PHP help for the unglamorous, mission-critical glue
|
||||||
|
between your store, your warehouse, and your books.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2 sm:items-end">
|
||||||
|
<div className="flex gap-5">
|
||||||
|
<a
|
||||||
|
href="https://github.com/cgsmith"
|
||||||
|
className="hover:text-ink"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
GitHub
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="https://www.linkedin.com/in/phpguy"
|
||||||
|
className="hover:text-ink"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
LinkedIn
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="https://twitter.com/cgsmith105"
|
||||||
|
className="hover:text-ink"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
Twitter
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<p>
|
||||||
|
© {year} Five Devs, LLC · Henderson, NV
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Container>
|
||||||
|
</footer>
|
||||||
|
);
|
||||||
|
}
|
||||||
43
src/components/site-header.tsx
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { Container } from "./container";
|
||||||
|
import { Button } from "./button";
|
||||||
|
|
||||||
|
const nav = [
|
||||||
|
{ href: "/services", label: "Services" },
|
||||||
|
{ href: "/work", label: "Work" },
|
||||||
|
{ href: "/blog", label: "Writing" },
|
||||||
|
{ href: "/about", label: "About" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function SiteHeader() {
|
||||||
|
return (
|
||||||
|
<header className="sticky top-0 z-30 border-b border-line/60 bg-cream/80 backdrop-blur supports-[backdrop-filter]:bg-cream/70">
|
||||||
|
<Container className="flex h-16 items-center justify-between">
|
||||||
|
<Link
|
||||||
|
href="/"
|
||||||
|
className="font-serif text-xl font-semibold tracking-tight text-ink"
|
||||||
|
>
|
||||||
|
Five Devs
|
||||||
|
<span className="text-accent">.</span>
|
||||||
|
</Link>
|
||||||
|
<nav className="hidden items-center gap-7 sm:flex">
|
||||||
|
{nav.map((item) => (
|
||||||
|
<Link
|
||||||
|
key={item.href}
|
||||||
|
href={item.href}
|
||||||
|
className="text-sm text-ink-soft transition-colors hover:text-ink"
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
<Button href="/contact" variant="primary" className="hidden sm:inline-flex">
|
||||||
|
Book a call
|
||||||
|
</Button>
|
||||||
|
<Button href="/contact" variant="primary" className="sm:hidden">
|
||||||
|
Contact
|
||||||
|
</Button>
|
||||||
|
</Container>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
59
src/components/testimonials.tsx
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import { Container } from "./container";
|
||||||
|
import { SectionHeading } from "./section-heading";
|
||||||
|
|
||||||
|
export const testimonials = [
|
||||||
|
{
|
||||||
|
name: "Emil Boschert",
|
||||||
|
role: "Pritikin Foods",
|
||||||
|
quote:
|
||||||
|
"I have worked together with Chris and Five Devs for several years now on our shipping and order parsing process. They are vital for our e-commerce site and our 3PL.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Timothy Dorcas",
|
||||||
|
role: "Americold",
|
||||||
|
quote:
|
||||||
|
"I cannot say enough good things about the team at Five Devs. They are solution oriented, customer focused, and consistently deliver at a high level. Highly recommended.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Katie Allen",
|
||||||
|
role: "Fortune Fulfillment",
|
||||||
|
quote:
|
||||||
|
"I have been working with Five Devs on multiple customer accounts for several years. Chris makes the set-up process flow so much easier!",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Tom Deppe",
|
||||||
|
role: "HD Financial",
|
||||||
|
quote:
|
||||||
|
"Five Devs is an integral part of my business. Their tech support keeps us up and running. Promptly returns phone calls and is a pleasure to work with.",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function Testimonials() {
|
||||||
|
return (
|
||||||
|
<section className="border-y border-line/70 bg-cream-soft py-24 sm:py-32">
|
||||||
|
<Container>
|
||||||
|
<SectionHeading
|
||||||
|
eyebrow="What clients say"
|
||||||
|
title="Trusted for years, not weeks."
|
||||||
|
subtitle="Five Devs is the kind of help that gets quietly embedded in how a business runs — and stays there."
|
||||||
|
/>
|
||||||
|
<ul className="mt-14 grid gap-6 sm:grid-cols-2">
|
||||||
|
{testimonials.map((t) => (
|
||||||
|
<li
|
||||||
|
key={t.name}
|
||||||
|
className="rounded-2xl border border-line/80 bg-cream p-7 shadow-[0_1px_0_rgba(0,0,0,0.02)]"
|
||||||
|
>
|
||||||
|
<p className="font-serif text-lg leading-relaxed text-ink">
|
||||||
|
“{t.quote}”
|
||||||
|
</p>
|
||||||
|
<p className="mt-5 text-sm text-muted">
|
||||||
|
<span className="font-medium text-ink">{t.name}</span>{" "}
|
||||||
|
· {t.role}
|
||||||
|
</p>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</Container>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
81
src/content/blog/how-to-hire-a-php-dev.mdx
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
export const metadata = {
|
||||||
|
title: "How to hire a senior PHP developer (without getting burned)",
|
||||||
|
description:
|
||||||
|
"A practical checklist for non-technical founders and ops leads who need to bring in a PHP freelancer for ongoing or project work.",
|
||||||
|
date: "2026-04-22",
|
||||||
|
readingMinutes: 7,
|
||||||
|
tags: ["hiring", "freelancing"],
|
||||||
|
};
|
||||||
|
|
||||||
|
If you're reading this, you probably have a PHP application that's important to your business and you're thinking about bringing in outside help — either for a one-time project or for ongoing maintenance.
|
||||||
|
|
||||||
|
Hiring well at the senior contractor level is its own skill. Here's the checklist I'd want a non-technical founder to use when evaluating someone like me.
|
||||||
|
|
||||||
|
## Step 1: Know what kind of help you actually need
|
||||||
|
|
||||||
|
There are roughly three:
|
||||||
|
|
||||||
|
- **Project work.** A defined deliverable: an integration, a rewrite, a migration. You want a fixed scope, a fixed price (after some discovery), and a clear handoff at the end.
|
||||||
|
- **Ongoing help.** A monthly retainer for the long tail: small features, bug fixes, the occasional production issue. You want predictability and someone who's already in the codebase.
|
||||||
|
- **A block of time.** Senior hours you can spend over a few months as needs come up. You want flexibility without re-negotiating every two weeks.
|
||||||
|
|
||||||
|
If a freelancer can only sell you one of these, that's information.
|
||||||
|
|
||||||
|
## Step 2: Look for a portfolio of relationships, not projects
|
||||||
|
|
||||||
|
Anyone can list five "projects" on a portfolio page. The harder thing to fake is a list of clients who have kept paying that person for two, three, five years.
|
||||||
|
|
||||||
|
When you talk to a candidate, ask:
|
||||||
|
|
||||||
|
- "Who's your longest-running client and what do you do for them?"
|
||||||
|
- "Tell me about a time you walked into a codebase you'd never seen and shipped something in the first week."
|
||||||
|
- "What's a project you turned down? Why?"
|
||||||
|
|
||||||
|
The third one matters most. Senior freelancers say no.
|
||||||
|
|
||||||
|
## Step 3: Get a paid trial, not a free one
|
||||||
|
|
||||||
|
If you're considering a multi-month engagement, run a small paid trial first. Two to four hours, scoped tightly: read this code, write a small fix, write a one-page review of what you found.
|
||||||
|
|
||||||
|
Pay for it. You learn three things in a paid trial that you can never learn from an unpaid one:
|
||||||
|
|
||||||
|
1. How they communicate when their professional reputation is on the line.
|
||||||
|
2. What their commit messages and PR descriptions look like.
|
||||||
|
3. How they ask questions — the good ones ask a lot, fast, and early.
|
||||||
|
|
||||||
|
A senior dev who won't take a paid trial is signaling something.
|
||||||
|
|
||||||
|
## Step 4: Beware the agency middle layer
|
||||||
|
|
||||||
|
Some "senior PHP freelancers" are actually a sales person who sells you on a senior, then quietly hands the work to a junior. This is the single biggest source of bad freelance experiences I hear about.
|
||||||
|
|
||||||
|
The check is simple: ask who specifically will be writing the code. Get it in writing. If the answer changes mid-project, that's your sign.
|
||||||
|
|
||||||
|
## Step 5: Watch how they handle the unknown
|
||||||
|
|
||||||
|
Halfway through your trial or first project, ask them about something that isn't in the spec — a weird edge case, a "what would you do if X" question.
|
||||||
|
|
||||||
|
You're looking for one of two answers:
|
||||||
|
|
||||||
|
- "Here's what I'd do, here's why, and here's what I'd want to confirm with you first." (Good.)
|
||||||
|
- A confident, immediate answer that ignores the ambiguity. (Bad.)
|
||||||
|
|
||||||
|
Senior contractors live in the gap between "what was specified" and "what the business actually needs." If they can't navigate that gap with you in real time, they won't navigate it well in production either.
|
||||||
|
|
||||||
|
## Step 6: Decide on the boring stuff up front
|
||||||
|
|
||||||
|
Before signing anything, agree in writing on:
|
||||||
|
|
||||||
|
- Hourly or fixed rate, and what triggers a re-quote
|
||||||
|
- Who owns the code (you should, period)
|
||||||
|
- How communication happens (email, Slack, GitHub issues — pick one)
|
||||||
|
- What "done" means for each piece of work
|
||||||
|
- How either side ends the engagement cleanly
|
||||||
|
|
||||||
|
Most freelance disasters trace back to one of these being assumed instead of written down.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
If you're at the stage of having this conversation, you're already ahead of most people. The biggest mistake isn't picking the "wrong" senior dev — it's not running the process at all and ending up with whoever your cousin recommended.
|
||||||
|
|
||||||
|
If you'd like to put me through this checklist, [book a 20-minute call](/contact). I won't be offended by the questions.
|
||||||
44
src/content/blog/the-glue-job.mdx
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
export const metadata = {
|
||||||
|
title: "Why I take 'glue work' seriously (and you should too)",
|
||||||
|
description:
|
||||||
|
"The unsexy code that lives between your store, your warehouse, and your books is doing more for the business than the cool new feature ever will.",
|
||||||
|
date: "2026-04-30",
|
||||||
|
readingMinutes: 5,
|
||||||
|
tags: ["e-commerce", "philosophy"],
|
||||||
|
};
|
||||||
|
|
||||||
|
There's a kind of code I love writing that almost nobody brags about.
|
||||||
|
|
||||||
|
It's the script that takes a CSV from a 1996-era ERP, normalizes the SKUs, decides whether each line is a real shipment or a return, validates the address against USPS, and hands the result to a 3PL's API in the format that 3PL expects this Tuesday. It's the cron that watches a folder for EDI 940s, parses them, and writes a row in your warehouse system. It's the one-page admin tool that lets a customer service rep look up an order without opening five tabs.
|
||||||
|
|
||||||
|
Glue work, integration work, ops tooling — whatever you call it, this is the code that keeps small businesses shipping.
|
||||||
|
|
||||||
|
## It looks dumb. It isn't.
|
||||||
|
|
||||||
|
The first time someone sees an integration like this, the reaction is usually some version of "wait, that's it?" A few hundred lines of PHP, a queue, a Postgres table, a few well-placed log lines. No clever framework. No machine learning. Sometimes not even a class.
|
||||||
|
|
||||||
|
But here's what that "few hundred lines" is actually doing:
|
||||||
|
|
||||||
|
- Encoding **how the business actually works**, including the exceptions nobody wrote down
|
||||||
|
- Surviving every change a vendor pushes in the next three years
|
||||||
|
- Logging enough that when something breaks at 4pm, you can tell *what* in under a minute
|
||||||
|
|
||||||
|
That's a lot of value packed into something that looks like a script.
|
||||||
|
|
||||||
|
## The skill is in the boring parts
|
||||||
|
|
||||||
|
Anyone can write the happy path. The skill is in:
|
||||||
|
|
||||||
|
1. **Reading the messy reality.** Real input data is never clean. Half the bugs in glue code come from someone shipping a SKU that "shouldn't exist" or an order with a comma in the customer name. Senior glue work assumes your data is messy and proves it before going to production.
|
||||||
|
2. **Logging like an adult.** A glue script that runs unattended needs to leave a trail you can read six months later when a CFO asks why a single order shipped twice. Structured logs, idempotency keys, retry semantics that won't double-charge a customer.
|
||||||
|
3. **Writing tests against contracts, not just code.** When the 3PL changes their API on a Sunday night, the test suite that catches it is the one that ran against a recorded fixture, not the one that mocked the function call.
|
||||||
|
|
||||||
|
None of that is glamorous. All of it is the difference between glue that lasts five years and glue you have to rip out in three months.
|
||||||
|
|
||||||
|
## Why I keep doing this work
|
||||||
|
|
||||||
|
I get to look at a small business and see, very concretely, where money is leaking out — usually because someone is doing in three hours of manual work what a 200-line PHP script could do in 200 milliseconds.
|
||||||
|
|
||||||
|
When I write that script, I get to give those three hours back to a real person. Repeat that across a year and the math is easy.
|
||||||
|
|
||||||
|
That's why glue work is worth taking seriously. The alternative is human beings spending their working lives moving CSVs around.
|
||||||
48
src/content/blog/three-pl-integration-checklist.mdx
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
export const metadata = {
|
||||||
|
title: "A pre-launch checklist for connecting your store to a 3PL",
|
||||||
|
description:
|
||||||
|
"Twelve things I check before flipping the switch on a new e-commerce-to-3PL integration. Skip them and you'll find out what they were the hard way.",
|
||||||
|
date: "2026-04-12",
|
||||||
|
readingMinutes: 6,
|
||||||
|
tags: ["e-commerce", "3pl", "integration"],
|
||||||
|
};
|
||||||
|
|
||||||
|
You signed a contract with a fulfillment partner. They've sent over their integration docs. Your store software has a "connect" button. You're three days from go-live.
|
||||||
|
|
||||||
|
Here's the checklist I run through before flipping that switch on a real production integration. Skip these and you'll learn them anyway — usually on a Friday afternoon.
|
||||||
|
|
||||||
|
## The setup checks
|
||||||
|
|
||||||
|
**1. Confirm SKU canonicalization.** The SKU in your store, the SKU in your warehouse system, and the SKU on the actual product label have to match exactly. Different leading zeros count as different SKUs. Different cases count as different SKUs. Run an audit before launch and fix the mismatches.
|
||||||
|
|
||||||
|
**2. Decide the source of truth for inventory.** It's the warehouse, every time. Your store should pull from there, not the other way around. Pick a sync interval and write down what happens when sync fails.
|
||||||
|
|
||||||
|
**3. Confirm address validation.** USPS, UPS, and FedEx will all happily accept addresses that none of them can deliver to. Validate before the order leaves your system, not after the warehouse has already picked it.
|
||||||
|
|
||||||
|
**4. Map every shipping method end-to-end.** Your "Standard Shipping" needs to map to a specific carrier, a specific service code, and a specific package type at the warehouse. "Standard" means nothing to a 3PL.
|
||||||
|
|
||||||
|
## The error-handling checks
|
||||||
|
|
||||||
|
**5. Define what "failed order" means.** Address invalid? Out of stock? SKU mismatch? Each one needs a different recovery path. Write them down. Build them in.
|
||||||
|
|
||||||
|
**6. Build retry that doesn't double-ship.** Idempotency keys on every outbound order. Test what happens when you retry an order the warehouse has already started picking.
|
||||||
|
|
||||||
|
**7. Decide who gets paged when things break.** "Both of us" is not an answer. One person, one channel, one escalation path.
|
||||||
|
|
||||||
|
**8. Log enough to reconstruct any order.** Six months from now, customer service is going to ask why order #4218 shipped twice. You need to be able to answer in two minutes, not two hours.
|
||||||
|
|
||||||
|
## The financial checks
|
||||||
|
|
||||||
|
**9. Reconcile shipped vs. ordered, daily.** Pull what was actually shipped from the 3PL, compare to what your store thinks shipped. Differences mean either a billing problem or a customer-experience problem. Catch them in 24 hours, not 30 days.
|
||||||
|
|
||||||
|
**10. Confirm carrier billing.** Whose UPS account is being charged for each shipment? You'd be amazed how often this is wrong.
|
||||||
|
|
||||||
|
**11. Track returns the same way as shipments.** Returns are shipments going the other way. They need the same SKU mapping, the same logging, the same reconciliation. They almost never get it.
|
||||||
|
|
||||||
|
## The post-launch check
|
||||||
|
|
||||||
|
**12. Schedule a 30-day post-launch review.** Not a meeting — a written review. What broke? What was harder than expected? What's still on the to-do list? You'll be tempted to skip this one. Don't.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
If you're staring at this list and realizing you're three days from launch and you've checked maybe four of them, that's exactly the kind of project I help with. [Get in touch](/contact) and we'll figure out what's worth doing before launch and what can wait.
|
||||||
37
src/lib/case-studies.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
export type CaseStudy = {
|
||||||
|
slug: string;
|
||||||
|
client: string;
|
||||||
|
industry: string;
|
||||||
|
summary: string;
|
||||||
|
outcome: string;
|
||||||
|
yearsRunning?: string;
|
||||||
|
role?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const caseStudies: CaseStudy[] = [
|
||||||
|
{
|
||||||
|
slug: "pritikin-foods",
|
||||||
|
client: "Pritikin Foods",
|
||||||
|
industry: "DTC food · e-commerce + 3PL",
|
||||||
|
summary:
|
||||||
|
"Reliable shipping and order parsing pipeline between Pritikin's e-commerce site and their fulfillment partner.",
|
||||||
|
outcome:
|
||||||
|
"Multi-year partnership keeping the order-to-shipment loop quietly running.",
|
||||||
|
yearsRunning: "Several years",
|
||||||
|
role: "Embedded developer",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: "americold",
|
||||||
|
client: "Americold",
|
||||||
|
industry: "Cold-chain 3PL · enterprise logistics",
|
||||||
|
summary:
|
||||||
|
"Solution-oriented engineering on operational tooling for one of the largest temperature-controlled warehousing networks in the US.",
|
||||||
|
outcome:
|
||||||
|
"Consistent senior-level delivery on customer-facing logistics tooling.",
|
||||||
|
role: "Contract developer",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function getCaseStudy(slug: string): CaseStudy | undefined {
|
||||||
|
return caseStudies.find((c) => c.slug === slug);
|
||||||
|
}
|
||||||
5
src/lib/cn.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
export function cn(
|
||||||
|
...classes: Array<string | false | null | undefined>
|
||||||
|
): string {
|
||||||
|
return classes.filter(Boolean).join(" ");
|
||||||
|
}
|
||||||
174
src/lib/jsonld.ts
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
import {
|
||||||
|
ADDRESS,
|
||||||
|
ORG_LEGAL_NAME,
|
||||||
|
PERSON_EMAIL,
|
||||||
|
PERSON_NAME,
|
||||||
|
SITE_NAME,
|
||||||
|
SITE_URL,
|
||||||
|
SOCIAL,
|
||||||
|
} from "./site";
|
||||||
|
|
||||||
|
const orgRef = {
|
||||||
|
"@type": "Organization",
|
||||||
|
name: ORG_LEGAL_NAME,
|
||||||
|
url: SITE_URL,
|
||||||
|
};
|
||||||
|
|
||||||
|
const personRef = {
|
||||||
|
"@type": "Person",
|
||||||
|
name: PERSON_NAME,
|
||||||
|
url: `${SITE_URL}/about`,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function organizationSchema() {
|
||||||
|
return {
|
||||||
|
"@context": "https://schema.org",
|
||||||
|
"@type": "Organization",
|
||||||
|
"@id": `${SITE_URL}#organization`,
|
||||||
|
name: ORG_LEGAL_NAME,
|
||||||
|
alternateName: SITE_NAME,
|
||||||
|
url: SITE_URL,
|
||||||
|
logo: `${SITE_URL}/og-image.png`,
|
||||||
|
description:
|
||||||
|
"Senior PHP help for the unglamorous, mission-critical glue between your store, your warehouse, and your books. Fifteen years of e-commerce and 3PL/logistics integration work.",
|
||||||
|
founder: {
|
||||||
|
...personRef,
|
||||||
|
jobTitle: "Senior PHP Developer",
|
||||||
|
},
|
||||||
|
address: {
|
||||||
|
"@type": "PostalAddress",
|
||||||
|
...ADDRESS,
|
||||||
|
},
|
||||||
|
contactPoint: {
|
||||||
|
"@type": "ContactPoint",
|
||||||
|
email: PERSON_EMAIL,
|
||||||
|
contactType: "customer service",
|
||||||
|
availableLanguage: ["English", "German"],
|
||||||
|
},
|
||||||
|
sameAs: [SOCIAL.linkedin, SOCIAL.github, SOCIAL.twitter],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function personSchema() {
|
||||||
|
return {
|
||||||
|
"@context": "https://schema.org",
|
||||||
|
"@type": "Person",
|
||||||
|
"@id": `${SITE_URL}/about#person`,
|
||||||
|
name: PERSON_NAME,
|
||||||
|
jobTitle: "Senior PHP Developer",
|
||||||
|
worksFor: orgRef,
|
||||||
|
url: `${SITE_URL}/about`,
|
||||||
|
email: PERSON_EMAIL,
|
||||||
|
sameAs: [SOCIAL.linkedin, SOCIAL.github, SOCIAL.twitter],
|
||||||
|
knowsAbout: [
|
||||||
|
"PHP",
|
||||||
|
"Laravel",
|
||||||
|
"Symfony",
|
||||||
|
"E-commerce",
|
||||||
|
"Third-party logistics (3PL)",
|
||||||
|
"Order management systems",
|
||||||
|
"EDI integrations",
|
||||||
|
"Shipping and fulfillment integrations",
|
||||||
|
"Inventory synchronization",
|
||||||
|
"Magento",
|
||||||
|
"MySQL",
|
||||||
|
"PostgreSQL",
|
||||||
|
],
|
||||||
|
knowsLanguage: ["English", "German"],
|
||||||
|
address: {
|
||||||
|
"@type": "PostalAddress",
|
||||||
|
addressLocality: ADDRESS.addressLocality,
|
||||||
|
addressRegion: ADDRESS.addressRegion,
|
||||||
|
addressCountry: ADDRESS.addressCountry,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
type ServiceInput = {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
url: string;
|
||||||
|
/** Total / starting price in USD. Omit for "custom quote" services. */
|
||||||
|
price?: number;
|
||||||
|
/** "OneTime" | "Subscription" */
|
||||||
|
priceType?: "OneTime" | "Subscription";
|
||||||
|
};
|
||||||
|
|
||||||
|
export function serviceSchema(s: ServiceInput) {
|
||||||
|
const offer: Record<string, unknown> = {
|
||||||
|
"@type": "Offer",
|
||||||
|
url: s.url,
|
||||||
|
availability: "https://schema.org/InStock",
|
||||||
|
};
|
||||||
|
if (s.price !== undefined) {
|
||||||
|
offer.price = s.price.toString();
|
||||||
|
offer.priceCurrency = "USD";
|
||||||
|
if (s.priceType === "Subscription") {
|
||||||
|
offer.priceSpecification = {
|
||||||
|
"@type": "UnitPriceSpecification",
|
||||||
|
price: s.price,
|
||||||
|
priceCurrency: "USD",
|
||||||
|
unitText: "MONTH",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"@context": "https://schema.org",
|
||||||
|
"@type": "Service",
|
||||||
|
name: s.name,
|
||||||
|
description: s.description,
|
||||||
|
provider: orgRef,
|
||||||
|
serviceType: "Software development",
|
||||||
|
areaServed: { "@type": "Country", name: "United States" },
|
||||||
|
url: s.url,
|
||||||
|
offers: offer,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
type ArticleInput = {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
slug: string;
|
||||||
|
date: string;
|
||||||
|
basePath: "blog" | "work";
|
||||||
|
};
|
||||||
|
|
||||||
|
export function articleSchema(a: ArticleInput) {
|
||||||
|
const url = `${SITE_URL}/${a.basePath}/${a.slug}`;
|
||||||
|
return {
|
||||||
|
"@context": "https://schema.org",
|
||||||
|
"@type": "Article",
|
||||||
|
headline: a.title,
|
||||||
|
description: a.description,
|
||||||
|
author: personRef,
|
||||||
|
publisher: {
|
||||||
|
...orgRef,
|
||||||
|
logo: {
|
||||||
|
"@type": "ImageObject",
|
||||||
|
url: `${SITE_URL}/og-image.png`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
datePublished: a.date,
|
||||||
|
dateModified: a.date,
|
||||||
|
mainEntityOfPage: {
|
||||||
|
"@type": "WebPage",
|
||||||
|
"@id": url,
|
||||||
|
},
|
||||||
|
url,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function breadcrumbSchema(
|
||||||
|
items: Array<{ name: string; path: string }>,
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
"@context": "https://schema.org",
|
||||||
|
"@type": "BreadcrumbList",
|
||||||
|
itemListElement: items.map((item, i) => ({
|
||||||
|
"@type": "ListItem",
|
||||||
|
position: i + 1,
|
||||||
|
name: item.name,
|
||||||
|
item: `${SITE_URL}${item.path}`,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
58
src/lib/posts.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import type { ComponentType } from "react";
|
||||||
|
import * as glueJob from "@/content/blog/the-glue-job.mdx";
|
||||||
|
import * as hireDev from "@/content/blog/how-to-hire-a-php-dev.mdx";
|
||||||
|
import * as threePl from "@/content/blog/three-pl-integration-checklist.mdx";
|
||||||
|
|
||||||
|
type PostMetadata = {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
date: string;
|
||||||
|
readingMinutes: number;
|
||||||
|
tags?: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type MdxModule = {
|
||||||
|
metadata: PostMetadata;
|
||||||
|
default: ComponentType;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Post = {
|
||||||
|
slug: string;
|
||||||
|
metadata: PostMetadata;
|
||||||
|
Content: ComponentType;
|
||||||
|
};
|
||||||
|
|
||||||
|
const registry: Record<string, MdxModule> = {
|
||||||
|
"the-glue-job": glueJob as MdxModule,
|
||||||
|
"how-to-hire-a-php-dev": hireDev as MdxModule,
|
||||||
|
"three-pl-integration-checklist": threePl as MdxModule,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const posts: Post[] = Object.entries(registry)
|
||||||
|
.map(([slug, mod]) => ({
|
||||||
|
slug,
|
||||||
|
metadata: mod.metadata,
|
||||||
|
Content: mod.default,
|
||||||
|
}))
|
||||||
|
.sort(
|
||||||
|
(a, b) =>
|
||||||
|
new Date(b.metadata.date).getTime() -
|
||||||
|
new Date(a.metadata.date).getTime(),
|
||||||
|
);
|
||||||
|
|
||||||
|
export function getPost(slug: string): Post | undefined {
|
||||||
|
return posts.find((p) => p.slug === slug);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAllSlugs(): string[] {
|
||||||
|
return posts.map((p) => p.slug);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDate(iso: string): string {
|
||||||
|
const d = new Date(iso);
|
||||||
|
return d.toLocaleDateString("en-US", {
|
||||||
|
year: "numeric",
|
||||||
|
month: "long",
|
||||||
|
day: "numeric",
|
||||||
|
});
|
||||||
|
}
|
||||||
110
src/lib/pricing.ts
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
/**
|
||||||
|
* Pricing & checkout config for self-serve engagements.
|
||||||
|
*
|
||||||
|
* Stripe URLs are read from NEXT_PUBLIC_STRIPE_* env vars at build
|
||||||
|
* time. When unset, the Buy button falls back to /contact so the page
|
||||||
|
* still reads sensibly.
|
||||||
|
*
|
||||||
|
* To run a sale (e.g. soft launch), set `salePrice` and `saleLabel`
|
||||||
|
* on a tier and point its env-var URL at a Stripe Payment Link
|
||||||
|
* configured at the sale price. To end the sale: clear `salePrice` /
|
||||||
|
* `saleLabel` and swap the env-var URL back to the regular Payment
|
||||||
|
* Link.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type CheckoutOption = {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
hoursLabel: string;
|
||||||
|
regularPrice: number;
|
||||||
|
salePrice?: number;
|
||||||
|
saleLabel?: string;
|
||||||
|
effectiveHourly: number;
|
||||||
|
saleEffectiveHourly?: number;
|
||||||
|
/** Resolved at module load. Falls back to undefined when unset. */
|
||||||
|
buyHref?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const sale = {
|
||||||
|
active: true,
|
||||||
|
label: "Website redesign launch · 50% off",
|
||||||
|
};
|
||||||
|
|
||||||
|
function maybeSale(regular: number): number | undefined {
|
||||||
|
return sale.active ? Math.round(regular / 2) : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function maybeSaleLabel(): string | undefined {
|
||||||
|
return sale.active ? sale.label : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function effective(price: number, hours: number): number {
|
||||||
|
return Math.round(price / hours);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const retainer: CheckoutOption = {
|
||||||
|
id: "retainer",
|
||||||
|
label: "Monthly retainer",
|
||||||
|
hoursLabel: "32 reserved hours / month",
|
||||||
|
regularPrice: 5000,
|
||||||
|
salePrice: maybeSale(5000),
|
||||||
|
saleLabel: maybeSaleLabel(),
|
||||||
|
effectiveHourly: effective(5000, 32),
|
||||||
|
saleEffectiveHourly: sale.active
|
||||||
|
? effective(Math.round(5000 / 2), 32)
|
||||||
|
: undefined,
|
||||||
|
buyHref: process.env.NEXT_PUBLIC_STRIPE_RETAINER,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const blocks: CheckoutOption[] = [
|
||||||
|
{
|
||||||
|
id: "block-5",
|
||||||
|
label: "5-hour block",
|
||||||
|
hoursLabel: "5 hours · use within 6 months",
|
||||||
|
regularPrice: 750,
|
||||||
|
salePrice: maybeSale(750),
|
||||||
|
saleLabel: maybeSaleLabel(),
|
||||||
|
effectiveHourly: effective(750, 5),
|
||||||
|
saleEffectiveHourly: sale.active
|
||||||
|
? effective(Math.round(750 / 2), 5)
|
||||||
|
: undefined,
|
||||||
|
buyHref: process.env.NEXT_PUBLIC_STRIPE_BLOCK_5,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "block-10",
|
||||||
|
label: "10-hour block",
|
||||||
|
hoursLabel: "10 hours · use within 6 months",
|
||||||
|
regularPrice: 1400,
|
||||||
|
salePrice: maybeSale(1400),
|
||||||
|
saleLabel: maybeSaleLabel(),
|
||||||
|
effectiveHourly: effective(1400, 10),
|
||||||
|
saleEffectiveHourly: sale.active
|
||||||
|
? effective(Math.round(1400 / 2), 10)
|
||||||
|
: undefined,
|
||||||
|
buyHref: process.env.NEXT_PUBLIC_STRIPE_BLOCK_10,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "block-20",
|
||||||
|
label: "20-hour block",
|
||||||
|
hoursLabel: "20 hours · use within 6 months",
|
||||||
|
regularPrice: 2600,
|
||||||
|
salePrice: maybeSale(2600),
|
||||||
|
saleLabel: maybeSaleLabel(),
|
||||||
|
effectiveHourly: effective(2600, 20),
|
||||||
|
saleEffectiveHourly: sale.active
|
||||||
|
? effective(Math.round(2600 / 2), 20)
|
||||||
|
: undefined,
|
||||||
|
buyHref: process.env.NEXT_PUBLIC_STRIPE_BLOCK_20,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const saleActive = sale.active;
|
||||||
|
export const saleLabel = sale.label;
|
||||||
|
|
||||||
|
export function formatPrice(amount: number): string {
|
||||||
|
return amount.toLocaleString("en-US", {
|
||||||
|
style: "currency",
|
||||||
|
currency: "USD",
|
||||||
|
maximumFractionDigits: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
19
src/lib/site.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
export const SITE_URL = "https://fivedevs.com";
|
||||||
|
export const SITE_NAME = "Five Devs";
|
||||||
|
export const ORG_LEGAL_NAME = "Five Devs, LLC";
|
||||||
|
export const PERSON_NAME = "Chris Smith";
|
||||||
|
export const PERSON_EMAIL = "chris@fivedevs.com";
|
||||||
|
|
||||||
|
export const SOCIAL = {
|
||||||
|
linkedin: "https://www.linkedin.com/in/phpguy",
|
||||||
|
github: "https://github.com/cgsmith",
|
||||||
|
twitter: "https://twitter.com/cgsmith105",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ADDRESS = {
|
||||||
|
streetAddress: "1887 Whitney Mesa Dr. PMB 7325",
|
||||||
|
addressLocality: "Henderson",
|
||||||
|
addressRegion: "NV",
|
||||||
|
postalCode: "89014",
|
||||||
|
addressCountry: "US",
|
||||||
|
};
|
||||||
@@ -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",
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
};
|
|
||||||
29
theme.toml
@@ -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",
|
|
||||||
]
|
|
||||||
@@ -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,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
})();
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||