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..2ef49c6 --- /dev/null +++ b/docs/superpowers/plans/2026-08-06-hire-page.md @@ -0,0 +1,959 @@ +# /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 change `/about`'s content, copy, or the set of sections it shows.** + Extracting a shared presentational component out of it — so `/hire` can reuse + it instead of copying it — is allowed and expected (Task 3). The rendered + `/about` page must look identical before and after. +- 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 | +| `src/components/ui/SkillGrid.tsx` | The stack, grouped — shared by `/about` and `/hire` | +| `src/app/[locale]/about/page.tsx` | Rewired to the shared component; output unchanged | +| `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: Extract the stack grid so two pages can share it + +`/about` renders the skills grouped into four categories. `/hire` needs the same +grid — a hiring manager scans it for keywords. Copying the block would mean a +new category could land on one page and silently not the other, so pull it into +a component before the second caller exists. + +**`/about` must render identically before and after.** This is a refactor with +no visible output change; the only reason it is its own task is that a reviewer +should be able to judge the extraction separately from the new page. + +**Files:** +- Create: `src/components/ui/SkillGrid.tsx` +- Modify: `src/app/[locale]/about/page.tsx` (replace the grouping and its markup) + +**Interfaces:** +- Consumes: `Skill` and `skillCategories` from `@/types/content`. +- Produces: `SkillGrid({ skills }: { skills: Skill[] })` — a server component + that groups by category and renders the chips. Reads the locale itself via + `useLocale()`, so callers pass only the skill list. + +- [ ] **Step 1: Capture the current `/about` markup as the baseline** + +Run: `npm run build && npm run start &` then: + +```bash +curl -s http://localhost:3000/about > /tmp/about-before.html +grep -c 'rounded-lg bg-\[var(--bg-elevated)\] text-sm' /tmp/about-before.html +``` + +Record that count — it is the number of skill chips (expect 34). Kill the server +by port: `npx --yes kill-port 3000`. + +- [ ] **Step 2: Create the component** + +Create `src/components/ui/SkillGrid.tsx`: + +```tsx +import { useLocale } from 'next-intl' +import type { Skill } from '@/types/content' +import { skillCategories } from '@/types/content' + +/** + * The stack, grouped by category. + * + * Two pages need this grid: /about tells the story behind the work, /hire + * proves the range. It lives here rather than inside either page because a + * copied grouping is one where a newly added category lands on one page and + * quietly not the other. + */ +export function SkillGrid({ skills }: { skills: Skill[] }) { + const locale = useLocale() + const grouped = { + 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'), + } + + return ( +
+ {(Object.keys(grouped) as Array).map((category) => ( +
+

+ {skillCategories[category][locale as 'ko' | 'en']} +

+
+ {grouped[category].map((skill) => ( + + {skill.name} + + ))} +
+
+ ))} +
+ ) +} +``` + +- [ ] **Step 3: Rewire `/about`** + +In `src/app/[locale]/about/page.tsx`: + +- Add `import { SkillGrid } from '@/components/ui/SkillGrid'` +- Delete the `groupedSkills` const (lines 70-75) +- Replace the whole `
` inside the Skills + `
` with `` +- Remove `skillCategories` from the `@/types/content` import if nothing else in + the file uses it — keep the `Skill` type import, which `skills` still needs + +The section heading (`

{t('skills')}

`) stays exactly where it is. + +- [ ] **Step 4: Verify `/about` is byte-identical** + +Run: `npm run build && npm run start &` then: + +```bash +curl -s http://localhost:3000/about > /tmp/about-after.html +diff <(grep -o 'rounded-lg bg-\[var(--bg-elevated)\] text-sm[^"]*' /tmp/about-before.html) \ + <(grep -o 'rounded-lg bg-\[var(--bg-elevated)\] text-sm[^"]*' /tmp/about-after.html) \ + && echo "SKILL CHIPS IDENTICAL" +``` + +Expected: `SKILL CHIPS IDENTICAL`, and the chip count matches Step 1. +A whole-file diff will differ (Next embeds build-specific ids), which is why the +comparison targets the chips. Kill the server by port afterwards. + +- [ ] **Step 5: Type-check, lint, and run the suite** + +Run: `npx tsc --noEmit && npm run lint && npx vitest run` +Expected: all green, test count unchanged at 134 — this task adds no tests +because it adds no behaviour. The `/about` output comparison in Step 4 is the +verification. + +- [ ] **Step 6: Commit** + +```bash +git add src/components/ui/SkillGrid.tsx "src/app/[locale]/about/page.tsx" +git commit -m "refactor(about): extract the stack grid into a shared component + +The hire page needs the same category-grouped chips the about page has, +and copying twenty lines of grouping means a category added later lands on +one page and quietly not the other. Pull it out before the second caller +exists rather than after. + +Pure refactor: /about renders the same chips in the same order, verified +by diffing the rendered chip markup before and after. No tests added +because no behaviour changed. + +Co-Authored-By: Claude Opus 5 (1M context) +Claude-Session: https://claude.ai/code/session_01LaydoxAZ4eaqqPNKrmWLbt" +``` + +--- + +### Task 4: 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); `SkillGrid` (Task 3); `ProjectCard` from `@/components/projects/ProjectCard`; `generatePersonJsonLd`, `generateBreadcrumbJsonLd`, `safeJsonLd` from `@/lib/seo`; `Skill` from `@/types/content`. +- Produces: routes `/hire` and `/en/hire`; message key `nav.hire` used by Task 5. + +- [ ] **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 { SkillGrid } from '@/components/ui/SkillGrid' +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' + +// 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 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 ( + +