o2o-site-AEO/solution/frontend/src/pages/PricingPage.tsx
Mina Choi 533de126fb [feat] solution/frontend,nginx: 랜딩·요금·사례를 프리렌더 — 크롤러가 빈 종이를 받던 것
실측(2026-09-07): `curl /` 가 3,021바이트에 본문 0자·`<a>` 0개였다. 같은 호스트의
발행본은 48,072바이트다. 구글은 JS 를 실행하지만 **렌더링 큐가 따로** 돌고 신규
도메인은 뒤로 밀린다 — 그동안 색인에는 "제목만 있고 내용 없는 페이지"로 들어가 있다.
서치콘솔이 "URL이 Google에 등록되어 있음"이라고 답하면서도 브랜드명 검색에조차 안
걸리던 이유다.

스크립트를 새로 짜지 않았다. react-router 7.17 에 프리렌더가 내장돼 있고
`ssr: false` 와 함께 쓰면 런타임 Node 서버 없이 지정한 경로만 HTML 로 굽는다 —
나머지는 지금까지처럼 SPA 폴백이다. 배포 구조가 그대로다.

- react-router.config.ts: `ssr:false` + `prerender: ['/', '/pricing', '/showcase']`.
  로그인 뒤에만 의미가 있는 화면은 굽지 않는다(구울 내용이 사용자별이다)
- src/root.tsx · src/routes.ts: 예전 index.html + app/router.tsx 가 하던 일.
  가드는 페이지마다 감싸지 않고 RequireAuthLayout 레이아웃 라우트 하나로 모았다
- 랜딩·요금·사례에 meta export: 제목을 브랜드가 아니라 **검색어**로 시작하게 바꿨다.
  예전 제목("Web4Ai · AI 웹 빌더")에는 사람이 치는 말이 한 단어도 없었다.
  랜딩에 Organization JSON-LD 추가 — 발행본에는 있는데 정작 랜딩엔 없었다
- src/lib/site.ts: 발행 호스트의 단일 출처. 모듈 최상위의 `window.location` 폴백을
  전부 걷었다 — 서버 번들은 라우트를 한 파일로 묶어서 프리렌더 대상이 아닌 화면의
  최상위 코드도 빌드 때 실행된다(실측: BuilderPage 에서 빌드가 죽었다)
- LoginPage: homePath 기본값 `/` → `/sites`. 예전엔 router.tsx 가 넘기던 값이라
  라우트 모듈로 옮기면서 그대로 두면 로그인 후 랜딩으로 갔다
- nginx: SPA 폴백을 `/index.html` → `/__spa-fallback.html`. 프리렌더 뒤로
  `/index.html` 은 **랜딩이 구워진 파일**이라, 그리로 넘기면 `/builder` 에 랜딩
  HTML 이 내려가고 클라이언트가 다른 주소로 하이드레이트한다
- nginx/Dockerfile: 산출물이 `dist` → `build/client`. 경로가 어긋나면 COPY 가
  조용히 빈 디렉토리를 만들고 컨테이너는 정상으로 뜬다
- site/seo/robots.ts: `/builder` `/login` `/signup` `/sites` `/account` Disallow.
  이 경로들은 빈 SPA 폴백을 받는다 — 긁히면 호스트 전체에 저품질 신호가 쌓인다

검증: tsc·eslint·react-router build 통과.
랜딩 3,021B → 21,799B, 본문 1,278자, 링크 6개.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019fteiJNvAEbTnUKq8fSqoj
2026-09-07 11:30:27 +09:00

127 lines
5.6 KiB
TypeScript

import {Link} from 'react-router';
import {Check} from 'lucide-react';
import {MarketingShell, Section} from '@/components/layout/MarketingShell';
import {Button} from '@/components/ui/button';
import type {MetaFunction} from 'react-router';
import {ORIGIN} from '@/lib/site';
export const meta: MetaFunction = () => [
{title: '요금 · 가게 홈페이지 제작 비용 | Web4Ai'},
{
name: 'description',
content: 'Web4Ai 로 가게 홈페이지를 만들고 발행하는 데 드는 비용입니다. 발행 후에도 AI 인용 상태를 계속 확인합니다.',
},
{name: 'robots', content: 'index, follow'},
{tagName: 'link', rel: 'canonical', href: `${ORIGIN}/pricing`},
{property: 'og:title', content: '요금 · 가게 홈페이지 제작 비용'},
{property: 'og:url', content: `${ORIGIN}/pricing`},
];
/**
* 요금 — 플랜 하나다.
*
* ★ 플랜을 여럿 늘어놓지 않는다. 이건 셀프서비스 구독이 아니라 **월 단위로 사람이 붙는
* 서비스**라 고를 것이 가격대가 아니라 "할 것이냐"뿐이다. 비교표를 만들면 없는 선택지를
* 지어내게 된다.
*/
const INCLUDED = ['AI 노출 진단 리포트', '최적화 가이드', '영상 콘텐츠 2종', '페이지 제작 1회'];
const TERMS: {label: string; value: string}[] = [
{label: '무엇을 넣나', value: '운영 중인 홈페이지'},
{label: '무엇이 나오나', value: '진단 리포트 + 최적화 가이드 + 페이지'},
{label: '월 산출물', value: '리포트 1회 · 영상 2종 · 페이지 1회'},
{label: '확인 절차', value: '리포트 리뷰 미팅'},
];
export function PricingPage() {
return (
<MarketingShell>
<Section>
<header className="mb-12 text-center">
<h1 className="text-3xl font-bold tracking-tight text-balance sm:text-4xl">요금</h1>
<p className="mx-auto mt-3 max-w-lg text-sm text-muted-foreground">
매달 진단하고, 고치고, 결과를 같이 봅니다.
</p>
</header>
<div className="mx-auto max-w-2xl rounded-2xl border border-border bg-card p-7 sm:p-9">
<div className="flex flex-wrap items-baseline justify-between gap-3">
<h2 className="text-2xl font-bold tracking-tight">Web4AI</h2>
<p className="text-2xl font-bold tracking-tight">
70만원
<span className="ml-1 text-sm font-medium text-muted-foreground">/ 월</span>
</p>
</div>
<p className="mt-7 text-base font-semibold tracking-tight">AI가 우리를 찾아내게 만듭니다</p>
<p className="mt-2 text-sm leading-relaxed text-muted-foreground">
ChatGPT·제미나이·퍼플렉시티 답변에 우리 정보가 인용되도록 홈페이지를 진단하고 고칩니다.
</p>
<ul className="mt-6 flex flex-wrap gap-2">
{INCLUDED.map((item) => (
<li
key={item}
className="inline-flex items-center gap-1.5 rounded-full border border-border px-3.5 py-1.5 text-[13px]"
>
<Check className="size-3.5 text-success" aria-hidden />
{item}
</li>
))}
</ul>
<dl className="mt-8 grid gap-3 border-t border-border pt-7 text-sm sm:grid-cols-[7rem_1fr]">
{TERMS.map(({label, value}) => (
<div key={label} className="grid gap-1 sm:col-span-2 sm:grid-cols-subgrid">
<dt className="text-muted-foreground">{label}</dt>
<dd className="m-0">{value}</dd>
</div>
))}
</dl>
<Link to="/builder?new=1" className="mt-8 block">
<Button variant="primary" size="lg" className="w-full">
먼저 만들어 보기
</Button>
</Link>
<p className="mt-3 text-center text-xs text-muted-foreground">
가입하지 않아도 끝까지 만들어 볼 수 있습니다.
</p>
</div>
</Section>
<Section muted>
<h2 className="mb-8 text-xl font-bold tracking-tight">자주 묻는 질문</h2>
<dl className="grid gap-6 sm:grid-cols-2">
{[
{
q: '네이버 플레이스랑 뭐가 다른가요?',
a: '네이버는 AI 크롤러를 막습니다. 그래서 네이버에 올린 정보는 ChatGPT 같은 AI가 읽지 못합니다. 이 홈페이지는 AI가 읽을 수 있게 만듭니다.',
},
{
q: '홈페이지가 이미 있는데도 필요한가요?',
a: '있는 홈페이지가 AI에게 읽히는지가 관건입니다. 화면에는 보이는데 크롤러에게는 빈 페이지인 경우가 많습니다. 진단이 그걸 먼저 봅니다.',
},
{
q: '무엇을 준비해야 하나요?',
a: '지금 운영 중인 홈페이지 주소 하나면 시작합니다. 없으면 여기서 만드는 것부터 합니다.',
},
{
q: '결과는 어떻게 확인하나요?',
a: '매달 진단 리포트를 드리고, 리뷰 미팅에서 무엇이 달라졌는지 같이 봅니다.',
},
].map(({q, a}) => (
<div key={q}>
<dt className="text-sm font-semibold">{q}</dt>
<dd className="mt-1.5 text-sm leading-relaxed text-muted-foreground">{a}</dd>
</div>
))}
</dl>
</Section>
</MarketingShell>
);
}
// 라우트 모듈은 default export 를 요구한다(routes.ts 가 이 파일을 가리킨다).
export default PricingPage;