From 5d622618ec1cd227b5c9e0cb548eaebf83a53dfc Mon Sep 17 00:00:00 2001 From: Si Hyeong Lee Date: Thu, 6 Aug 2026 17:53:07 -0700 Subject: [PATCH 01/11] docs: spec the /hire page Measured, not assumed. GA4 over Jul 9 - Aug 5 shows 88 sessions and 15 engaged, with an outbound-event distribution (code_click 2, contact_click 1, demo_click 1, play_store_click 1) that matches the instrumentation check clicked by hand on Aug 5 - so the honest read is that no real visitor opened a demo, a repo, or the mail client all month. The audience exists, just elsewhere: LinkedIn carries 1,159 followers to the portfolio's 66 monthly users, and referral from it was at most one session in 28 days. Meanwhile the three channels advertise three different offers - LinkedIn says full-time, the GitHub README says select freelance, the site says nothing at all. So this page is not a conversion surface for site traffic. It is the destination LinkedIn does not currently have, and the place the three channels can finally agree on. States both offers side by side and concretely, because a merged "open to opportunities" weakens both. Numbers come from projects.json rather than prose so they cannot drift, and the spec records one pre-existing bug to fix along the way: LinkedIn links classify as demo_click today, which would pollute the demo metric on a page where LinkedIn is a primary call to action. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LaydoxAZ4eaqqPNKrmWLbt --- .../specs/2026-08-06-hire-page-design.md | 176 ++++++++++++++++++ 1 file changed, 176 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-06-hire-page-design.md diff --git a/docs/superpowers/specs/2026-08-06-hire-page-design.md b/docs/superpowers/specs/2026-08-06-hire-page-design.md new file mode 100644 index 0000000..2b710be --- /dev/null +++ b/docs/superpowers/specs/2026-08-06-hire-page-design.md @@ -0,0 +1,176 @@ +# `/hire` — 함께 일하기 페이지 설계 + +2026-08-06 + +## 왜 만드는가 + +측정된 사실 세 가지에서 출발한다. + +**1. 연락 경로가 죽어 있다.** GA4 28일(7/9–8/5) 실측: 세션 88건 중 참여 15건. +아웃바운드 이벤트는 `code_click` 2 · `contact_click` 1 · `demo_click` 1 · +`play_store_click` 1 — 이 분포는 8월 5일 계측 검증 때 직접 누른 클릭 패턴과 +정확히 일치한다. 단정할 수는 없으나 **실사용자가 데모·저장소·이메일 중 무엇도 +누르지 않았다고 보는 편이 사실에 가깝다.** + +**2. 관객은 이미 있는데 다른 곳에 있다.** + +| 채널 | 관객 | 상태 | +|---|---|---| +| LinkedIn | 팔로워 1,159 · 1촌 500+ | 마지막 글 2개월 전, 7일 게시물 노출 1건 | +| GitHub | 팔로워 13 | 포트폴리오 핵심이벤트의 86%를 혼자 만듦 | +| 포트폴리오 | 월 66명(94% 첫 방문) | — | +| 검색 | 월 2세션 | — | + +GA4 소스 상위 5개(direct 61 · github 22 · google 2 · 기타 1 · 1)가 88 중 87을 +채운다. **LinkedIn → 포트폴리오 유입은 28일간 최대 1세션.** 팔로워 1,159명이 +아무데도 흐르지 않는다. + +**3. 세 채널이 서로 다른 제안을 한다.** LinkedIn은 `#OPENTOWORK` + "구직 중 · +CTO 및 소프트웨어 엔지니어 역할"(정규직), GitHub README는 "Open to select +freelance"(외주), 포트폴리오는 푸터 `EMAIL` 한 줄(제안 없음). 읽는 사람이 +취업을 원하는지 외주를 원하는지 판단할 수 없다. + +따라서 이 페이지의 목적은 **포트폴리오 트래픽 전환이 아니다.** 월 10~15명을 +위해 만드는 게 아니라, **LinkedIn 관객을 보낼 목적지가 없어서** 만든다. 그래서 +공유 가능한 전용 URL이 결과물이고, 만들자마자 쓸모가 생긴다. + +## 결정 사항 + +사용자 확인을 거친 값이다. 추정하지 않는다. + +| 항목 | 값 | +|---|---| +| 경로 | `/hire` (KO), `/en/hire` (EN) | +| 제안 | 정규직 **과** 프로젝트 의뢰 — 둘 다, 각각 구체적으로 | +| 근무 형태 | 원격 우선 · 전 세계 | +| 가용 상태 | 지금 바로 시작 가능 | +| 요율 | 비공개 | +| `/about` | 손대지 않는다 (전기와 제안은 다른 문서다) | + +"둘 다"를 뭉뚱그리면 둘 다 약해진다. **두 제안을 나란히, 각각 구체적으로** 놓아 +읽는 사람이 자기 쪽만 보고 판단하게 한다. 이것이 이 페이지의 핵심 장치다. + +## 페이지 구조 + +``` +1. 헤드 한 줄 제안 + 상태 배지(원격 우선 · 전 세계 · 지금 가능) +2. 두 갈래 정규직 | 프로젝트 의뢰 — 나란히 +3. 증거 projects.json에서 파생한 숫자 + 대표 사례 3건 +4. 스택 about.json의 skills 34개 재사용 +5. 연락 mailto + LinkedIn +``` + +### 2. 두 갈래에 들어가는 내용 + +문구의 출처를 명시한다. **모든 문장은 본인이 이미 공개한 텍스트에서 오거나, +위 "결정 사항" 표에서 온다. 그 밖의 사실은 쓰지 않는다.** + +| 칸 | 정규직 | 프로젝트 의뢰 | +|---|---|---| +| 찾는 것 | CTO · 시니어 풀스택 · AI 통합
*출처: LinkedIn 구직 설정* | Claude API 파이프라인 · 커스텀 MCP 서버 · RAG · Next.js/NestJS/Python 제품
*출처: LinkedIn 소개글, GitHub README* | +| 형태 | 원격 우선 · 전 세계
*출처: 결정 사항* | 원격 우선 · 전 세계
*출처: 결정 사항* | +| 근거 | CTO 5년+ · 아키텍처에서 결제·인프라까지
*출처: LinkedIn 소개글* | 짧은 사이클 — "in days, not months"
*출처: LinkedIn 소개글* | +| 시작 | 이메일 | 이메일 — 문제를 한 문단으로 | + +### 3. 증거 — 파생 숫자만 쓴다 + +숫자를 본문에 적지 않는다. `projects.json`에서 계산한다. About 페이지가 +`{count}`로 프로젝트 수를 뽑는 것과 같은 방식이라 **데이터가 바뀌면 페이지도 +따라오고, 낡을 수 없다.** + +- **공개 데모·스토어가 있는 제품** = `website || playStore`인 프로젝트 수 (현재 19) +- **Play Store 앱** = `playStore`가 있는 프로젝트 수 (현재 6) +- **총 빌드** = 전체 프로젝트 수 (현재 39) + +"출시 31건"(`active` + `launched`) 같은 더 큰 숫자를 쓰지 않는 이유: 클릭해서 +확인할 수 있는 것만 세야 "보여달라"는 요청에 버틴다. 19는 본인이 이미 공개적으로 +말하는 "15+ shipped products"와 모순되지 않으면서 그보다 강하다. + +대표 사례 3건은 **서로 다른 역량**을 증명하도록 고른다. + +| 사례 | 증명하는 것 | +|---|---| +| `rentrights` | 공개 데이터 · PostGIS · 시빅테크, 저장소 공개라 검증 가능 | +| `healframe` | AI 안전 파이프라인 + 웹/안드로이드 동시 출하 | +| `argus-fusion` | 실시간 다중 피드 수집 + 3D 시각화 | + +슬러그는 코드에 고정하되, **누락되면 테스트가 실패**하게 한다. 프로젝트가 +삭제됐을 때 페이지에 빈칸이 생기는 대신 CI가 먼저 잡는다. + +숫자를 계산하는 `getHireStats()`와 대표 사례 슬러그 상수는 +**`src/lib/projects.ts`**에 둔다. `hasIndexablePage`·`sortProjectsFeaturedFirst`와 +같은 자리이고, 페이지 컴포넌트가 아니라 순수 함수라 직접 테스트할 수 있다. + +## 기술 설계 + +### 라우트 + +`src/app/[locale]/hire/page.tsx`. `/about`의 구조를 그대로 따른다 — +`generateMetadata` + `setRequestLocale` + `PageTransition`, `revalidate = 3600`. + +### i18n + +`messages/{ko,en}.json`에 `hire` 네임스페이스 신설. 본문 텍스트는 전부 여기에 +둔다(컴포넌트에 한국어/영어 문자열을 박지 않는다). `nav.hire`도 함께 추가. + +### 내비게이션 + +`Header.tsx`의 `navLinks`에 `{ href: '/hire', key: 'hire' }` 추가. 7개가 되어 +데스크톱 내비가 빡빡해지므로, 구현 중 좁은 뷰포트에서 줄바꿈이 생기면 +`/graveyard`를 모바일 전용으로 내리는 것까지가 이 작업의 범위다. + +### 메타데이터 · 구조화 데이터 + +`/about`과 동일한 패턴: canonical + `alternates.languages`(ko/en/x-default), +OG 이미지는 기존 `/api/og`, `generatePersonJsonLd` + `generateBreadcrumbJsonLd`. +새 스키마 타입은 도입하지 않는다. + +### 사이트맵 + +`src/app/sitemap.ts`의 `staticPages`에 `'/hire'` 추가, priority `0.7` +(`/projects`와 동급). 얇은 페이지가 아니므로 `hasIndexablePage` 필터와 무관하다. + +### 계측 — 기존 버그 하나를 같이 고친다 + +`mailto:`는 `classifyOutboundLink`가 이미 `contact_click`으로 분류하므로 +**이메일 링크에는 새 코드가 필요 없다.** + +그런데 **LinkedIn 링크는 `demo_click`으로 분류된다.** `classifyOutboundLink`가 +Play Store와 코드 호스트만 특수 처리하고 나머지 전부를 `demo_click`으로 떨어뜨리기 +때문이다. 푸터에 LinkedIn이 이미 전 페이지에 깔려 있으므로 **이건 이 페이지가 +만드는 문제가 아니라 이미 존재하는 오분류다.** 지금까지 `demo_click`이 28일에 +1건이라 드러나지 않았을 뿐이고, LinkedIn이 주요 CTA가 되는 이 페이지에서는 +데모 지표를 그대로 오염시킨다. + +**고치는 방법: LinkedIn을 `contact_click`으로 분류한다.** 새 이벤트 이름을 +만들지 않는 이유가 있다 — GA4는 이미 처리한 이벤트만 별표를 달 수 있고 그 +테이블은 약 24시간 지연되므로, 새 이름은 하루를 더 기다려야 핵심 이벤트가 된다 +(`portfolio-ga4-key-events` 참조). `contact_click`은 이미 핵심 이벤트로 지정돼 +있고, **채용 페이지에서 LinkedIn을 누르는 것은 의미상으로도 연락 행동이다.** +`link_domain`이 `linkedin.com`으로 남으므로 GA4에서 이메일과 분리해 볼 수 있다. + +### 테스트 + +`src/lib/__tests__/`에 추가한다. + +- `getHireStats()` — 파생 숫자 3개가 `projects.json` 실데이터와 일치 +- 대표 사례 3건의 슬러그가 `projects.json`에 **존재**한다 (누락 시 실패) +- `classifyOutboundLink('https://www.linkedin.com/in/…')` → `contact_click`, + `link_domain === 'linkedin.com'` (`www.` 정규화 포함) +- 기존 `demo_click` 케이스가 깨지지 않는다 + +## 범위 밖 + +- **LinkedIn·GitHub 프로필 수정.** 문안은 작성해 드리되 적용은 본인이 한다. + 두 곳 모두 이 저장소 밖이고, 공개 프로필 변경은 본인 결정이다. +- **디스코드(원래 계획 B).** 보류 결정됨. +- **요율표.** 비공개로 결정됨. +- **LinkedIn 게시 재개.** 별도 작업이며 이 페이지와 독립적이다. + +## 완료 기준 + +1. `/hire`와 `/en/hire`가 렌더되고 hreflang이 서로를 가리킨다 +2. 숫자가 `projects.json`에서 파생되어 하드코딩된 값이 본문에 없다 +3. LinkedIn 클릭이 `contact_click` + `link_domain=linkedin.com`으로 보고된다 +4. 사이트맵에 두 URL이 올라간다 +5. 타입체크 · 린트 · 테스트 · CI 통과 From 963357a6b543db93e238591960c58832cf455f3d Mon Sep 17 00:00:00 2001 From: Si Hyeong Lee Date: Thu, 6 Aug 2026 18:03:29 -0700 Subject: [PATCH 02/11] docs: plan the /hire implementation Four tasks, each with its own test cycle and its own commit: fix the LinkedIn misclassification, derive the counts, build the page, wire it into the nav and sitemap. The analytics fix leads because it is a pre-existing bug, not new work. classifyOutboundLink drops every unrecognised host into demo_click, so the LinkedIn link the footer puts on every page has always been counted as a demo. Shipping a hire page where LinkedIn is a primary call to action without fixing that would corrupt the metric while we watch it. Self-review caught three typing problems that would have stopped an implementer: getHireStats now takes a Pick so a fixture needs two fields rather than a forged Project, HIRE_CASE_STUDIES is documented as the readonly tuple `as const` actually produces, and ReactNode is imported rather than reached for through a React namespace the file never imports. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LaydoxAZ4eaqqPNKrmWLbt --- .../superpowers/plans/2026-08-06-hire-page.md | 840 ++++++++++++++++++ 1 file changed, 840 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-06-hire-page.md diff --git a/docs/superpowers/plans/2026-08-06-hire-page.md b/docs/superpowers/plans/2026-08-06-hire-page.md new file mode 100644 index 0000000..20a672b --- /dev/null +++ b/docs/superpowers/plans/2026-08-06-hire-page.md @@ -0,0 +1,840 @@ +# /hire Page Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Ship `/hire` — a bilingual page stating both offers (full-time and project work) concretely and side by side, so LinkedIn's 1,159 followers finally have a destination. + +**Architecture:** A static-ish route mirroring `/about` (`generateMetadata` + `setRequestLocale` + `PageTransition`, `revalidate = 3600`). All prose lives in `messages/{ko,en}.json`; all numbers derive from `content/projects.json` through a pure helper so they cannot drift. Case studies reuse the existing `ProjectCard`, which already honours the Private rule. One pre-existing analytics bug is fixed first. + +**Tech Stack:** Next.js 16 App Router, React 19, TypeScript, Tailwind v4, next-intl v4, Vitest. + +## Global Constraints + +- **This repo is public.** Never write a host, IP, or username beyond what already ships in source. +- **`content/projects.json` is the single source of technical fact. Invention is forbidden.** Every sentence on this page must trace to `projects.json`, the user's own LinkedIn About text, their GitHub README, or the spec's decision table. +- **Numbers are never hardcoded in prose.** They come from `getHireStats()`. +- **The Private rule:** a private project's repo URL is never rendered. `ProjectCard` already implements this — do not re-implement it. +- Decided values, verbatim: work mode **원격 우선 · 전 세계** / **Remote-first, worldwide**; availability **지금 바로 시작 가능** / **available now**; rates **not published**. +- Do not touch `llms.txt` / `llms-full.txt`. +- Do not modify `/about`. +- Commit messages end with the two trailers used across this repo (`Co-Authored-By:` and `Claude-Session:`). + +## File Structure + +| File | Responsibility | +|---|---| +| `src/lib/analytics.ts` | Add a contact-host set; LinkedIn stops being a demo click | +| `src/lib/__tests__/analytics.test.ts` | Cover the new classification and guard the old ones | +| `src/lib/projects.ts` | Add `getHireStats()` + `HIRE_CASE_STUDIES` | +| `src/lib/__tests__/projects.test.ts` | Cover both, incl. slug existence | +| `messages/ko.json`, `messages/en.json` | All page prose + `nav.hire` | +| `src/app/[locale]/hire/page.tsx` | The page | +| `src/components/layout/Header.tsx` | Nav entry | +| `src/app/sitemap.ts` | `/hire` in `staticPages` | +| `src/lib/__tests__/sitemap.test.ts` | Assert both locale URLs are listed | + +--- + +### Task 1: Stop counting LinkedIn as a demo click + +`classifyOutboundLink` special-cases Play Store and code hosts and drops everything else into `demo_click`. The footer links LinkedIn on every page, so LinkedIn clicks have always been landing in the demo metric. It has not shown up because `demo_click` saw one event in 28 days — but LinkedIn becomes a primary call to action on `/hire`, so fix it before the page ships. + +`contact_click` is reused rather than a new event name: GA4 can only star events it has already processed and that table lags ~24h, so a new name would not be a key event for another day. Clicking LinkedIn on a hire page is a contact action, and `link_domain` keeps the two separable in reports. + +**Files:** +- Modify: `src/lib/analytics.ts:30-31` (host sets), `src/lib/analytics.ts:81-83` (dispatch) +- Test: `src/lib/__tests__/analytics.test.ts` + +**Interfaces:** +- Consumes: nothing from earlier tasks. +- Produces: `classifyOutboundLink(href: string, siteHost: string): OutboundEvent | null` — unchanged signature. LinkedIn hosts now return `{ name: 'contact_click', params: { link_url, link_domain: 'linkedin.com' } }`. + +- [ ] **Step 1: Write the failing test** + +Append inside the existing `describe('classifyOutboundLink', …)` block in `src/lib/__tests__/analytics.test.ts`: + +```ts + // Regression: LinkedIn fell through to demo_click, so every footer click on + // it inflated the demo metric. On a hire page it is a contact action. + it('classifies LinkedIn as a contact click, not a demo click', () => { + const hit = classifyOutboundLink('https://www.linkedin.com/in/sihyeonglee/', SITE) + expect(hit?.name).toBe('contact_click') + expect(hit?.params.link_domain).toBe('linkedin.com') + }) + + it('classifies LinkedIn without the www subdomain too', () => { + expect(classifyOutboundLink('https://linkedin.com/in/sihyeonglee/', SITE)?.name).toBe( + 'contact_click', + ) + }) + + it('keeps mailto and LinkedIn separable by domain', () => { + const mail = classifyOutboundLink('mailto:someone@example.com', SITE) + const linked = classifyOutboundLink('https://www.linkedin.com/in/sihyeonglee/', SITE) + expect(mail?.name).toBe(linked?.name) + expect(mail?.params.link_domain).not.toBe(linked?.params.link_domain) + }) +``` + +- [ ] **Step 2: Run the test and verify it fails** + +Run: `npx vitest run src/lib/__tests__/analytics.test.ts` +Expected: FAIL — the first two report `'demo_click'` where `'contact_click'` was expected. + +- [ ] **Step 3: Implement** + +In `src/lib/analytics.ts`, add a third host set beside the existing two: + +```ts +const PLAY_STORE_HOSTS = new Set(['play.google.com']) +const CODE_HOSTS = new Set(['github.com', 'gitlab.com', 'bitbucket.org']) +/** Profiles that are a way to reach a person, not a product to try. Without + * this they fall through to demo_click and quietly inflate that metric — + * which they did for as long as the footer has linked LinkedIn. */ +const CONTACT_HOSTS = new Set(['linkedin.com']) +``` + +Then add one line to the dispatch at the end of `classifyOutboundLink`, before the `demo_click` fallback: + +```ts + if (PLAY_STORE_HOSTS.has(host)) return { name: 'play_store_click', params } + if (CODE_HOSTS.has(host)) return { name: 'code_click', params } + if (CONTACT_HOSTS.has(host)) return { name: 'contact_click', params } + return { name: 'demo_click', params } +``` + +`host` is already `normalizeHost(url.hostname)`, so `www.linkedin.com` matches without extra work. + +- [ ] **Step 4: Run the tests and verify they pass** + +Run: `npx vitest run src/lib/__tests__/analytics.test.ts` +Expected: PASS, all cases — including the pre-existing `'classifies any other external host as a demo click'`. + +- [ ] **Step 5: Commit** + +```bash +git add src/lib/analytics.ts src/lib/__tests__/analytics.test.ts +git commit -m "fix(analytics): count LinkedIn as contact, not a demo click + +classifyOutboundLink special-cased Play Store and code hosts and let +everything else fall through to demo_click, so the LinkedIn link the +footer puts on every page has been landing in the demo metric all along. +It never showed because demo_click saw one event in 28 days — but it +becomes a primary call to action on the hire page, where it would quietly +corrupt the number that tells us whether demos get opened. + +Reuses contact_click rather than adding an event name: GA4 can only star +events it has already processed and that table lags about a day, so a new +name would sit outside key events until tomorrow. Clicking through to a +profile is a contact action, and link_domain keeps it separable from mail. + +Co-Authored-By: Claude Opus 5 (1M context) +Claude-Session: https://claude.ai/code/session_01LaydoxAZ4eaqqPNKrmWLbt" +``` + +--- + +### Task 2: Derive the hire numbers from the ledger + +The page claims what has shipped. Those counts must come from `projects.json`, the way `/about` derives its project count, so they cannot go stale. Counting only projects with a public destination (rather than every `launched` + `active` row) means every number survives a "show me". + +**Files:** +- Modify: `src/lib/projects.ts` (append) +- Test: `src/lib/__tests__/projects.test.ts` (append) + +**Interfaces:** +- Consumes: `Project` from `@/types/content`. +- Produces: + - `interface HireStats { shipped: number; playStore: number; total: number }` + - `getHireStats(projects: Pick[]): HireStats` — takes a + `Pick` for the same reason `hasIndexablePage` does: it lets a test build a + fixture from two fields instead of forging a whole `Project`, and a full + `Project[]` is still assignable at the call site. + - `HIRE_CASE_STUDIES` — `as const`, so its type is the readonly tuple + `readonly ['rentrights', 'healframe', 'argus-fusion']` + +- [ ] **Step 1: Write the failing test** + +Append to `src/lib/__tests__/projects.test.ts`. Add `getHireStats` and `HIRE_CASE_STUDIES` to the existing import from `'../projects'`, and add these imports at the top of the file: + +```ts +import projectsData from '../../../content/projects.json' +``` + +Then append: + +```ts +describe('getHireStats', () => { + // No cast: getHireStats takes a Pick, so a fixture needs only the two fields + // it reads. Forging a whole Project here would be noise. + const sample = [ + { website: 'https://a.example.com' }, + { playStore: 'https://play.google.com/store/apps/details?id=b' }, + { website: 'https://c.example.com', playStore: 'https://play.google.com/store/apps/details?id=c' }, + {}, + {}, + ] + + it('counts only what a visitor can open right now', () => { + // The last two rows stand for real work with no public destination — a + // private build, or one that only has a repo. Nothing on them can be + // clicked, so they must not be claimed as shipped product. + expect(getHireStats(sample).shipped).toBe(3) + }) + + it('counts store listings separately', () => { + expect(getHireStats(sample).playStore).toBe(2) + }) + + it('reports the whole ledger as the total', () => { + expect(getHireStats(sample).total).toBe(5) + }) + + it('never claims more shipped than the ledger holds', () => { + const stats = getHireStats(projectsData.projects as Project[]) + expect(stats.shipped).toBeLessThanOrEqual(stats.total) + expect(stats.playStore).toBeLessThanOrEqual(stats.shipped) + expect(stats.shipped).toBeGreaterThan(0) + }) +}) + +describe('HIRE_CASE_STUDIES', () => { + // The page renders these by slug. If a project is renamed or dropped, fail + // here rather than shipping a hire page with a hole in it. + it.each(HIRE_CASE_STUDIES)('%s exists in projects.json', (slug) => { + const found = (projectsData.projects as Project[]).find((p) => p.slug === slug) + expect(found, `${slug} is missing from projects.json`).toBeDefined() + }) + + it('each case study has something to link to', () => { + for (const slug of HIRE_CASE_STUDIES) { + const p = (projectsData.projects as Project[]).find((x) => x.slug === slug)! + expect(Boolean(p.website || p.playStore)).toBe(true) + } + }) +}) +``` + +- [ ] **Step 2: Run the test and verify it fails** + +Run: `npx vitest run src/lib/__tests__/projects.test.ts` +Expected: FAIL — `getHireStats` and `HIRE_CASE_STUDIES` are not exported. + +- [ ] **Step 3: Implement** + +Append to `src/lib/projects.ts`: + +```ts +export interface HireStats { + /** Products with a destination a visitor can open right now. */ + shipped: number + /** Android apps with a Play Store listing. */ + playStore: number + /** Everything in the ledger, shipped or not. */ + total: number +} + +/** + * The counts the hire page claims. + * + * `shipped` deliberately counts a public destination rather than the `launched` + * and `active` statuses, which together are a larger and more flattering + * number. A hire page invites "show me", and only a row with a website or a + * store listing survives that. Deriving these from the ledger — the way the + * about page derives its project count — means the page cannot drift out of + * date behind the data. + */ +export function getHireStats( + projects: Pick[], +): HireStats { + return { + shipped: projects.filter((p) => p.website || p.playStore).length, + playStore: projects.filter((p) => p.playStore).length, + total: projects.length, + } +} + +/** + * Three projects that prove three different things: public-data GIS with an + * open repo, an AI safety pipeline shipped to web and Android, and real-time + * multi-feed aggregation. Chosen for spread, not for being the biggest. + * A slug that stops resolving fails the test suite rather than the page. + */ +export const HIRE_CASE_STUDIES = ['rentrights', 'healframe', 'argus-fusion'] as const +``` + +- [ ] **Step 4: Run the tests and verify they pass** + +Run: `npx vitest run src/lib/__tests__/projects.test.ts` +Expected: PASS. The ledger-backed case should confirm `shipped` is 19, `playStore` 6, `total` 39 at time of writing; the assertions are relational so they stay true as the ledger grows. + +- [ ] **Step 5: Commit** + +```bash +git add src/lib/projects.ts src/lib/__tests__/projects.test.ts +git commit -m "feat(hire): derive the shipped counts from the ledger + +The hire page claims what has shipped, so the claim has to come from +projects.json rather than prose — the same reason the about page reads its +project count from the data instead of spelling it out. + +shipped counts a public destination, not the launched and active statuses. +Those together are a bigger number, but a hire page invites 'show me' and +only a row with a website or a store listing survives that. + +The three case studies are pinned by slug and covered by a test, so a +renamed project fails CI instead of leaving a hole in the page. + +Co-Authored-By: Claude Opus 5 (1M context) +Claude-Session: https://claude.ai/code/session_01LaydoxAZ4eaqqPNKrmWLbt" +``` + +--- + +### Task 3: The page and its prose + +**Files:** +- Create: `src/app/[locale]/hire/page.tsx` +- Modify: `messages/ko.json`, `messages/en.json` (add a `hire` namespace and `nav.hire`) + +**Interfaces:** +- Consumes: `getHireStats`, `HIRE_CASE_STUDIES` (Task 2); `ProjectCard` from `@/components/projects/ProjectCard`; `generatePersonJsonLd`, `generateBreadcrumbJsonLd`, `safeJsonLd` from `@/lib/seo`; `skillCategories` and `Skill` from `@/types/content`. +- Produces: routes `/hire` and `/en/hire`; message key `nav.hire` used by Task 4. + +- [ ] **Step 1: Add the Korean messages** + +In `messages/ko.json`, add `"hire": "함께 일하기"` to the existing `nav` object, then add this top-level namespace: + +```json + "hire": { + "title": "함께 일하기", + "description": "정규직과 프로젝트 단위 의뢰를 모두 받습니다.", + "metaTitle": "함께 일하기 — 이시형 · 풀스택 & AI 통합 개발자", + "metaDescription": "정규직과 프로젝트 단위 의뢰를 모두 받고 있습니다. 원격 우선, 전 세계, 지금 바로 시작 가능. 공개 데모가 있는 제품 {shipped}건, Play Store 앱 {playStore}건.", + "badgeRemote": "원격 우선 · 전 세계", + "badgeNow": "지금 바로 시작 가능", + "fulltime": { + "heading": "정규직", + "rolesLabel": "찾는 역할", + "roles": "CTO · 시니어 풀스택 · AI 통합 엔지니어", + "modeLabel": "근무 형태", + "mode": "원격 우선 · 전 세계", + "bringLabel": "가져오는 것", + "bring": "CTO 5년 이상, 그리고 1인 창업자로서 아키텍처부터 결제·인프라까지 직접 소유해 온 경험." + }, + "contract": { + "heading": "프로젝트 의뢰", + "workLabel": "받는 일", + "work": "Claude API 파이프라인 · 커스텀 MCP 서버 · RAG 시스템 · Next.js / NestJS / Python 제품", + "modeLabel": "규모 · 기간", + "mode": "원격 우선 · 전 세계. 개월이 아니라 일 단위로 끊는 짧은 사이클.", + "startLabel": "시작하는 법", + "start": "이메일로 문제를 한 문단만 보내주세요. 가능 여부와 접근 방식을 회신드립니다." + }, + "evidence": { + "heading": "증거", + "shipped": "공개 데모·스토어가 있는 제품", + "playStore": "Play Store 앱", + "total": "총 빌드", + "caseHeading": "대표 사례" + }, + "stack": "기술 스택", + "contactHeading": "연락" + }, +``` + +- [ ] **Step 2: Add the English messages** + +In `messages/en.json`, add `"hire": "Work with me"` to `nav`, then: + +```json + "hire": { + "title": "Work with me", + "description": "Open to full-time roles and to project work.", + "metaTitle": "Work with me — Si Hyeong Lee, full-stack & AI integration", + "metaDescription": "Open to full-time roles and to project work. Remote-first, worldwide, available now. {shipped} products with a public demo or store listing, {playStore} on Google Play.", + "badgeRemote": "Remote-first · worldwide", + "badgeNow": "Available now", + "fulltime": { + "heading": "Full-time", + "rolesLabel": "Roles", + "roles": "CTO · senior full-stack · AI integration engineer", + "modeLabel": "Working style", + "mode": "Remote-first, worldwide", + "bringLabel": "What I bring", + "bring": "5+ years as a CTO, and a solo founder's habit of owning everything from architecture through payments to infrastructure." + }, + "contract": { + "heading": "Project work", + "workLabel": "What I take on", + "work": "Claude API pipelines · custom MCP servers · RAG systems · Next.js / NestJS / Python products", + "modeLabel": "Shape", + "mode": "Remote-first, worldwide. Short cycles measured in days, not months.", + "startLabel": "How to start", + "start": "Email me one paragraph describing the problem. I'll reply with whether I can help and how I'd approach it." + }, + "evidence": { + "heading": "Evidence", + "shipped": "products with a public demo or store listing", + "playStore": "apps on Google Play", + "total": "builds in the ledger", + "caseHeading": "Selected work" + }, + "stack": "Tech Stack", + "contactHeading": "Get in touch" + }, +``` + +- [ ] **Step 3: Create the page** + +Create `src/app/[locale]/hire/page.tsx`: + +```tsx +import type { ReactNode } from 'react' +import { useTranslations, useLocale } from 'next-intl' +import { setRequestLocale, getTranslations } from 'next-intl/server' +import type { Metadata } from 'next' +import { PageTransition } from '@/components/ui/PageTransition' +import { ProjectCard } from '@/components/projects/ProjectCard' +import { SITE_URL, CONTACT_EMAIL, LINKEDIN_URL } from '@/lib/constants' +import { getHireStats, HIRE_CASE_STUDIES } from '@/lib/projects' +import { generatePersonJsonLd, generateBreadcrumbJsonLd, safeJsonLd } from '@/lib/seo' +import aboutData from '../../../../content/about.json' +import projectsData from '../../../../content/projects.json' +import type { Project, Skill } from '@/types/content' +import { skillCategories } from '@/types/content' + +// Same cadence as /about: the copy is static and the counts only move when +// projects.json does. +export const revalidate = 3600 + +export async function generateMetadata({ + params, +}: { + params: Promise<{ locale: string }> +}): Promise { + const { locale } = await params + const t = await getTranslations({ locale, namespace: 'hire' }) + const localePath = locale === 'ko' ? '' : `/${locale}` + const pageUrl = `${SITE_URL}${localePath}/hire` + const stats = getHireStats(projectsData.projects as Project[]) + const description = t('metaDescription', stats) + + return { + title: t('metaTitle'), + description, + openGraph: { + url: pageUrl, + title: t('metaTitle'), + description, + locale: locale === 'ko' ? 'ko_KR' : 'en_US', + alternateLocale: locale === 'ko' ? ['en_US'] : ['ko_KR'], + type: 'profile', + images: [ + { + url: `${SITE_URL}/api/og?title=${encodeURIComponent(t('title'))}&description=${encodeURIComponent(t('description'))}`, + width: 1200, + height: 630, + alt: t('title'), + }, + ], + }, + alternates: { + canonical: pageUrl, + languages: { + ko: `${SITE_URL}/hire`, + en: `${SITE_URL}/en/hire`, + 'x-default': `${SITE_URL}/hire`, + }, + }, + } +} + +export default async function HirePage({ + params, +}: { + params: Promise<{ locale: string }> +}) { + const { locale } = await params + setRequestLocale(locale) + return +} + +/** One labelled line inside an offer column. */ +function OfferRow({ label, children }: { label: string; children: ReactNode }) { + return ( +
+
+ {label} +
+
{children}
+
+ ) +} + +function HireContent() { + const t = useTranslations('hire') + const locale = useLocale() + const projects = projectsData.projects as Project[] + const stats = getHireStats(projects) + const skills = aboutData.skills as Skill[] + + const caseStudies = HIRE_CASE_STUDIES.map((slug) => + projects.find((p) => p.slug === slug), + ).filter((p): p is Project => Boolean(p)) + + const groupedSkills = { + frontend: skills.filter((s) => s.category === 'frontend'), + backend: skills.filter((s) => s.category === 'backend'), + tools: skills.filter((s) => s.category === 'tools'), + infra: skills.filter((s) => s.category === 'infra'), + } + + const personJsonLd = generatePersonJsonLd(locale) + const breadcrumbJsonLd = generateBreadcrumbJsonLd([ + { name: locale === 'ko' ? '홈' : 'Home', url: `${SITE_URL}${locale === 'ko' ? '' : '/en'}` }, + { name: t('title'), url: `${SITE_URL}${locale === 'ko' ? '' : '/en'}/hire` }, + ]) + + const figures = [ + { value: stats.shipped, label: t('evidence.shipped') }, + { value: stats.playStore, label: t('evidence.playStore') }, + { value: stats.total, label: t('evidence.total') }, + ] + + return ( + +