[feat] solution/frontend,docs: 랜딩 · 요금 · 쇼케이스 — 로그인 전 화면이 없었다

`/` 가 곧장 위저드로 튀어서 이 제품이 무엇을 파는 물건인지 말할 자리가 없었다.
처음 온 사람이 업종 선택 화면부터 만난다.

- router: `/` 는 비로그인 랜딩 · 로그인 /sites. /pricing · /showcase 추가
- MarketingShell: 사이드바 없는 문서형 껍데기. AppShell(작업 화면)과 나눴다
- 랜딩 상단은 상호명 한 칸. 문구는 SEO·AEO 축으로만 쓴다 —
  '쉽게·빠르게'로 말하면 홈페이지 빌더와 같은 자리에서 비교당한다(PRODUCT 1절)
- ShowcaseGrid: 발행 썸네일을 그대로 건다. 예시 데이터로 채우지 않고, 없으면 섹션을 감춘다.
  ★ 생성 클라이언트를 안 쓴다 — 토큰 길목을 지나면 비로그인에서 못 부른다
- 요금은 플랜 하나(70만원/월) + 월 산출물. 비교표를 만들지 않는다

tsc·eslint·vite build 통과
This commit is contained in:
Mina Choi 2026-09-03 10:21:16 +09:00
parent a61f9724ea
commit af391b4c56
9 changed files with 710 additions and 8 deletions

View File

@ -5,6 +5,24 @@
---
## 2026-09-03 — 랜딩 · 요금 · 쇼케이스 — 로그인 전 화면이 생겼다
**왜**
`/` 가 곧장 위저드로 튀어서, 이 제품이 무엇을 파는 물건인지 말할 자리가 한 곳도 없었다.
처음 온 사람이 업종 선택 화면부터 만난다.
**한 일**
- `/` 는 비로그인이면 랜딩, 로그인이면 `/sites`. `/pricing` · `/showcase` 신설
- `MarketingShell` — 사이드바 없는 문서형 껍데기. `AppShell` 은 작업 화면이라 나눴다
(b07ade2 가 온보딩에서 사이드바를 뺀 것과 같은 판단)
- 랜딩 상단은 **상호명 한 칸**이다. 업종 칩은 "누구를 위한 서비스인가"를 말하는 용도이고
고르지 않아도 된다 — 업종은 검색 결과가 정한다
- 쇼케이스는 발행 썸네일을 그대로 건다. **예시 데이터로 채우지 않는다** — 이 섹션이 파는 건
"진짜로 나갔다"는 사실 하나라, 가짜를 걸면 그 자리에서 가치가 0 이다. 없으면 섹션을 감춘다
- 요금은 플랜 하나(70만원/월). 비교표를 만들지 않는다 — 고를 것이 가격대가 아니다
**검증** — tsc·eslint·vite build 통과.
## 2026-09-03 — 상호명 검색을 로그인 앞으로 · 업종은 LLM 없이 정한다
**왜**

View File

@ -3,8 +3,11 @@ import {Loader2} from 'lucide-react';
import {AccountPage} from '@/pages/AccountPage';
import {BuilderPage} from '@/pages/BuilderPage';
import {DevShowcasePage} from '@/pages/DevShowcasePage';
import {LandingPage} from '@/pages/LandingPage';
import {LoginPage} from '@/pages/LoginPage';
import {NotFoundPage} from '@/pages/NotFoundPage';
import {PricingPage} from '@/pages/PricingPage';
import {ShowcasePage} from '@/pages/ShowcasePage';
import {SignupPage} from '@/pages/SignupPage';
import {SitesPage} from '@/pages/SitesPage';
import {RequireAuth} from '@/components/layout/RequireAuth';
@ -12,9 +15,11 @@ import {useAuthStore} from '@/stores/auth';
/**
* "새로 만들기"
* "내 것 고치기". ( ).
* "내 것 고치기". **** .
*
* .
* .
* .
* .
*/
function Home() {
const isRestoring = useAuthStore((s) => s.isRestoring);
@ -27,7 +32,8 @@ function Home() {
</div>
);
}
return <Navigate to={user ? '/sites' : '/builder?new=1'} replace />;
if (user) return <Navigate to="/sites" replace />;
return <LandingPage />;
}
export const router = createBrowserRouter([
@ -35,12 +41,13 @@ export const router = createBrowserRouter([
// 로그인 화면의 [회원가입] 이 여기로 온다. 이 줄이 없으면 링크는 있고 목적지만 404 다.
{path: '/signup', element: <SignupPage />},
// ★ 비로그인의 첫 화면은 업종 선택(위저드 1단계)이다.
// `?new=1` 을 붙이는 이유: 위저드 상태는 새로고침을 넘기려고 저장돼 있어서(stores/builder persist),
// 그냥 /builder 로 보내면 지난번에 만들다 만 **에디터**가 복원돼 뜬다. 처음 들어오는 사람에게는
// 그게 "왜 자꾸 빌더로 튀냐"로 보인다. 그래서 진입 경로에서 한 번 비우고 시작한다.
// 비로그인 = 랜딩, 로그인 = 내 사이트. Home 이 그걸 가른다.
{path: '/', element: <Home />},
// 로그인 전 화면. ★ 랜딩과 같은 껍데기(MarketingShell)를 쓴다 — 사이드바 없는 문서형이다.
{path: '/pricing', element: <PricingPage />},
{path: '/showcase', element: <ShowcasePage />},
// 로그인한 사장님의 홈. 만든 사이트를 열고 고치는 자리다.
{
path: '/sites',

View File

@ -36,7 +36,9 @@ export function AppShell({children, nav = OWNER_NAV}: {children: ReactNode; nav?
return (
<div className="flex h-screen w-screen overflow-hidden bg-background text-foreground">
<aside className="hidden w-56 shrink-0 flex-col border-r border-sidebar-border bg-sidebar md:flex">
<Link to={nav[0]?.to ?? '/'} className="flex items-center gap-2 px-4 py-4">
{/* ★ 로고는 언제나 홈(/). ,
. */}
<Link to="/" className="flex items-center gap-2 px-4 py-4">
<img src="/brand/web4ai-wordmark.svg" alt="Web4Ai" className="h-7 w-auto" />
</Link>

View File

@ -0,0 +1,117 @@
import type {ReactNode} from 'react';
import {Link, NavLink} from 'react-router';
import {cn} from '@/lib/utils';
import {useAuthStore} from '@/stores/auth';
/**
* (··) .
*
* AppShell 이유: 저쪽은 ** **.
* (b07ade2
* ).
*
* . "못 찾는 메뉴" .
*/
const NAV = [
{to: '/showcase', label: '이렇게 나옵니다'},
{to: '/pricing', label: '요금'},
];
export function MarketingShell({children}: {children: ReactNode}) {
const user = useAuthStore((s) => s.user);
return (
<div className="min-h-screen bg-background text-foreground">
<header className="sticky top-0 z-20 border-b border-border bg-background/85 backdrop-blur">
<div className="mx-auto flex h-14 w-full max-w-6xl items-center gap-7 px-5">
<Link to="/" className="flex items-center">
<img src="/brand/web4ai-wordmark.svg" alt="Web4Ai" className="h-6 w-auto" />
</Link>
<nav className="hidden items-center gap-6 sm:flex">
{NAV.map(({to, label}) => (
<NavLink
key={to}
to={to}
className={({isActive}) =>
cn(
'text-[13px] transition-colors',
isActive ? 'text-foreground' : 'text-muted-foreground hover:text-foreground',
)
}
>
{label}
</NavLink>
))}
</nav>
<div className="ml-auto flex items-center gap-3">
{/* ★ 이미 사이트를 가진 사장님에게 [로그인] 을 다시 보여주지 않는다 — 갈 곳은 내 사이트다. */}
{user ? (
<Link
to="/sites"
className="rounded-md bg-primary px-3.5 py-1.5 text-[13px] font-medium text-primary-foreground transition-colors hover:bg-primary/90"
>
</Link>
) : (
<>
<Link to="/login" className="text-[13px] text-muted-foreground transition-colors hover:text-foreground">
</Link>
<Link
to="/builder?new=1"
className="rounded-md bg-primary px-3.5 py-1.5 text-[13px] font-medium text-primary-foreground transition-colors hover:bg-primary/90"
>
</Link>
</>
)}
</div>
</div>
</header>
<main>{children}</main>
<footer className="mt-24 border-t border-border">
<div className="mx-auto flex w-full max-w-6xl flex-col gap-3 px-5 py-10 text-xs text-muted-foreground sm:flex-row sm:items-center sm:justify-between">
<img src="/brand/web4ai-wordmark.svg" alt="Web4Ai" className="h-5 w-auto opacity-60" />
<div className="flex gap-5">
{NAV.map(({to, label}) => (
<Link key={to} to={to} className="transition-colors hover:text-foreground">
{label}
</Link>
))}
</div>
</div>
</footer>
</div>
);
}
/** 랜딩의 한 켜. 섹션마다 여백을 손으로 적지 않게 한 곳에 모은다. */
export function Section({
children,
className,
muted = false,
}: {
children: ReactNode;
className?: string;
muted?: boolean;
}) {
return (
<section className={cn('border-b border-border', muted && 'bg-muted/40')}>
<div className={cn('mx-auto w-full max-w-6xl px-5 py-16 sm:py-20', className)}>{children}</div>
</section>
);
}
export function SectionHead({eyebrow, title, description}: {eyebrow?: string; title: string; description?: string}) {
return (
<header className="mb-10 max-w-2xl">
{eyebrow && <p className="mb-2 text-xs font-medium tracking-wide text-muted-foreground">{eyebrow}</p>}
<h2 className="text-2xl font-bold tracking-tight text-balance sm:text-3xl">{title}</h2>
{description && <p className="mt-3 text-sm leading-relaxed text-muted-foreground">{description}</p>}
</header>
);
}

View File

@ -0,0 +1,102 @@
import {useEffect, useState} from 'react';
import {Building2, Coffee, ImageOff, Stethoscope, UtensilsCrossed} from 'lucide-react';
import {PlaceCategory} from '@o2o/shared';
import {fetchShowcase, type ShowcaseItem} from './showcaseApi';
const CATEGORY_ICON: Record<number, typeof Building2> = {
[PlaceCategory.LODGING]: Building2,
[PlaceCategory.CAFE]: Coffee,
[PlaceCategory.RESTAURANT]: UtensilsCrossed,
[PlaceCategory.CLINIC]: Stethoscope,
};
const CATEGORY_LABEL: Record<number, string> = {
[PlaceCategory.LODGING]: '숙박',
[PlaceCategory.CAFE]: '카페',
[PlaceCategory.RESTAURANT]: '음식점',
[PlaceCategory.CLINIC]: '피부과 · 성형외과',
};
/** 발행 사이트 주소는 루트 상대경로로 온다(`/s/<slug>`). 발행 호스트는 번들에 구워진 값이다. */
const PUBLISH_HOST = import.meta.env.VITE_PUBLISH_HOST ?? window.location.host;
function siteHref(url: string): string {
return `${window.location.protocol}//${PUBLISH_HOST}${url}`;
}
/**
* .
*
* . "진짜로 나갔다" ,
* 0 . .
*/
export function ShowcaseGrid({limit = 6}: {limit?: number}) {
const [items, setItems] = useState<ShowcaseItem[] | null>(null);
useEffect(() => {
let alive = true;
fetchShowcase(limit)
.then((rows) => alive && setItems(rows))
.catch(() => alive && setItems([]));
return () => {
alive = false;
};
}, [limit]);
if (items !== null && items.length === 0) return null;
return (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{(items ?? Array.from({length: Math.min(limit, 3)}, () => null)).map((item, index) =>
item ? <ShowcaseCard key={item.url} item={item} /> : <SkeletonCard key={index} />,
)}
</div>
);
}
function ShowcaseCard({item}: {item: ShowcaseItem}) {
const Icon = CATEGORY_ICON[item.category] ?? Building2;
return (
<a
href={siteHref(item.url)}
target="_blank"
rel="noreferrer"
className="group overflow-hidden rounded-xl border border-border bg-card transition-colors hover:border-foreground/25"
>
<div className="flex aspect-[16/10] items-center justify-center overflow-hidden bg-muted">
{item.thumbnailUrl ? (
<img
src={item.thumbnailUrl}
alt={`${item.name} 홈페이지`}
loading="lazy"
className="size-full object-cover transition-transform duration-300 group-hover:scale-[1.02]"
/>
) : (
// ★ 썸네일은 발행에 성공한 뒤에만 채워진다 — 없는 건 정상이다(사진 없는 가게).
<ImageOff className="size-6 text-muted-foreground/50" aria-hidden />
)}
</div>
<div className="flex items-center gap-2 px-4 py-3">
<Icon className="size-4 shrink-0 text-muted-foreground" aria-hidden />
<div className="min-w-0">
<p className="truncate text-sm font-semibold tracking-tight">{item.name}</p>
<p className="truncate text-xs text-muted-foreground">
{[item.region, CATEGORY_LABEL[item.category]].filter(Boolean).join(' · ')}
</p>
</div>
</div>
</a>
);
}
function SkeletonCard() {
return (
<div className="overflow-hidden rounded-xl border border-border bg-card">
<div className="aspect-[16/10] animate-pulse bg-muted" />
<div className="space-y-2 px-4 py-3">
<div className="h-3.5 w-2/3 animate-pulse rounded bg-muted" />
<div className="h-3 w-1/2 animate-pulse rounded bg-muted" />
</div>
</div>
);
}

View File

@ -0,0 +1,46 @@
import type {IndustryType} from '@o2o/shared';
import {PlaceCategory} from '@o2o/shared';
/**
* "이렇게 나옵니다" .
*
* (@/api) . (api/mutator)
* ** ** ,
* . fetch .
*/
const BASE_URL = import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:9800';
export type ShowcaseItem = {
name: string;
category: PlaceCategory;
/** 없을 수 있다 — 서버가 null 필드를 지워서 보낸다(RemoveNoneResponse). */
region?: string;
/** 루트 상대경로(`/s/<slug>`). */
url: string;
thumbnailUrl?: string;
};
const INDUSTRY_BY_CATEGORY: Record<number, IndustryType> = {
[PlaceCategory.LODGING]: 'stay',
[PlaceCategory.CAFE]: 'cafe',
[PlaceCategory.RESTAURANT]: 'restaurant',
[PlaceCategory.CLINIC]: 'clinic',
};
export function industryOf(category: PlaceCategory): IndustryType | null {
return INDUSTRY_BY_CATEGORY[category] ?? null;
}
export async function fetchShowcase(limit = 6): Promise<ShowcaseItem[]> {
const res = await fetch(`${BASE_URL}/v1/showcase?limit=${limit}`);
if (!res.ok) return [];
const body = await res.json();
if (body?.result?.success === false) return [];
return (body?.items ?? []).map((item: Record<string, unknown>) => ({
name: String(item.name ?? ''),
category: Number(item.category) as PlaceCategory,
region: (item.region as string) ?? undefined,
url: String(item.url ?? ''),
thumbnailUrl: (item.thumbnail_url as string) ?? undefined,
}));
}

View File

@ -0,0 +1,264 @@
import {useState, type FormEvent} from 'react';
import {Link, useNavigate} from 'react-router';
import {ArrowRight, Building2, Check, Coffee, Search, Stethoscope, UtensilsCrossed} from 'lucide-react';
import type {IndustryType} from '@o2o/shared';
import {MarketingShell, Section, SectionHead} from '@/components/layout/MarketingShell';
import {ShowcaseGrid} from '@/features/marketing/ShowcaseGrid';
import {Button} from '@/components/ui/button';
const INDUSTRIES: {id: IndustryType; label: string; icon: typeof Building2}[] = [
{id: 'stay', label: '숙박', icon: Building2},
{id: 'cafe', label: '카페', icon: Coffee},
{id: 'restaurant', label: '음식점', icon: UtensilsCrossed},
{id: 'clinic', label: '피부과 · 성형외과', icon: Stethoscope},
];
/**
* . .
*
* "예쁜 홈페이지" **AI 1 **(PRODUCT.md 1).
* '쉽게·빠르게·저렴하게'
* , .
*
* **** . 100%
* ("우리는 카페인가 음식점인가").
* (services/place_category.py), .
*
* . (b94daa9).
*/
export function LandingPage() {
const navigate = useNavigate();
const [query, setQuery] = useState('');
const submit = (event: FormEvent) => {
event.preventDefault();
const q = query.trim();
// ★ `new=1` 을 함께 보낸다. 안 보내면 지난번에 만들다 만 에디터가 복원돼 뜬다
// (stores/builder persist — BuilderPage 주석).
navigate(q ? `/builder?new=1&q=${encodeURIComponent(q)}` : '/builder?new=1');
};
return (
<MarketingShell>
{/* ── 상단 ─────────────────────────────────────── */}
<section className="border-b border-border">
<div className="mx-auto w-full max-w-6xl px-5 py-20 text-center sm:py-28">
<h1 className="mx-auto max-w-3xl text-3xl leading-[1.25] font-bold tracking-tight text-balance sm:text-5xl">
SEO · AEO
<br />
</h1>
<p className="mx-auto mt-5 max-w-xl text-sm leading-relaxed text-muted-foreground sm:text-base">
</p>
<form onSubmit={submit} className="mx-auto mt-9 flex max-w-lg gap-2">
<div className="relative flex-1">
<Search
className="pointer-events-none absolute top-1/2 left-4 size-4 -translate-y-1/2 text-muted-foreground"
aria-hidden
/>
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="가게 이름을 입력하세요"
aria-label="가게 이름"
className="h-12 w-full rounded-lg border border-border bg-card pr-4 pl-11 text-sm shadow-sm transition-colors outline-none placeholder:text-muted-foreground focus:border-ring focus:ring-1 focus:ring-ring"
/>
</div>
<Button type="submit" variant="primary" size="lg" className="shrink-0">
</Button>
</form>
{/* 업종은 고르지 않아도 된다 — 여기 있는 건 "누구를 위한 서비스인가" 를 말하기 위한 것이다. */}
<div className="mt-6 flex flex-wrap items-center justify-center gap-2">
{INDUSTRIES.map(({id, label, icon: Icon}) => (
<Link
key={id}
to={`/builder?new=1&industry=${id}`}
className="inline-flex items-center gap-1.5 rounded-full border border-border px-3.5 py-1.5 text-xs text-muted-foreground transition-colors hover:border-foreground/25 hover:text-foreground"
>
<Icon className="size-3.5" aria-hidden />
{label}
</Link>
))}
</div>
<ul className="mt-8 flex flex-wrap items-center justify-center gap-x-6 gap-y-2 text-xs text-muted-foreground">
{[
'가입 없이 만들어 봅니다',
'검색엔진에 바로 알립니다',
'AI가 읽는 형태로 나갑니다',
].map((line) => (
<li key={line} className="flex items-center gap-1.5">
<Check className="size-3.5 text-success" aria-hidden />
{line}
</li>
))}
</ul>
</div>
</section>
{/* ── 왜 필요한가 ───────────────────────────────── */}
<Section muted>
<div className="grid items-center gap-12 lg:grid-cols-2">
<div>
<SectionHead
eyebrow="지금 벌어지는 일"
title="네이버에 다 올려놨는데, AI는 왜 모를까요?"
description="네이버·카카오가 AI 크롤러를 막습니다. 거기 올린 영업시간도 가격도 AI는 읽지 못합니다."
/>
<p className="text-sm leading-relaxed text-muted-foreground">
AI는 . AI가 ,
<b className="text-foreground"> </b> .
</p>
</div>
{/* AI 답변 예시 — 실제 답변을 옮긴 게 아니라 상황을 보여주는 그림이다. */}
<figure className="m-0 rounded-xl border border-border bg-card p-5">
<figcaption className="mb-3 text-[11px] font-medium tracking-wide text-muted-foreground">
AI
</figcaption>
<p className="rounded-lg border border-border bg-muted/50 px-3.5 py-2.5 text-[13px]">
</p>
<p className="mt-3 text-[13px] leading-relaxed text-muted-foreground">
. ,
.
</p>
<ul className="mt-4 space-y-1.5 border-t border-border pt-3 text-[11px] text-muted-foreground">
<li>· · 2023 </li>
<li>· · </li>
<li className="font-medium text-destructive">· </li>
</ul>
</figure>
</div>
</Section>
{/* ── 무엇을 하나 ──────────────────────────────── */}
<Section>
<SectionHead
eyebrow="SEO + AEO"
title="검색엔진과 AI, 양쪽이 읽는 방식으로 만듭니다"
description="SEO 는 검색 결과에 걸리는 일, AEO 는 AI 답변에 인용되는 일. 요구하는 게 달라서 둘을 같이 맞춥니다."
/>
<ol className="grid gap-5 sm:grid-cols-3">
{[
{
title: '흩어진 정보를 모읍니다',
body: '가게가 이미 공개해 둔 곳에서 영업시간·가격·시설을 모읍니다. 맞으면 두고, 틀리면 고칩니다.',
},
{
title: 'AEO — AI가 읽는 형태로',
body: '자바스크립트 없이도 본문이 읽히는 정적 페이지로 굽고, 구조화 데이터와 llms.txt 를 함께 내보냅니다.',
},
{
title: 'SEO — 검색에 바로 걸리게',
body: '발행 즉시 네이버·Bing 에 통보하고, 구글에는 사이트맵으로 제출합니다.',
},
].map(({title, body}) => (
<li key={title} className="rounded-xl border border-border bg-card p-5">
<h3 className="text-base font-semibold tracking-tight">{title}</h3>
<p className="mt-2 text-sm leading-relaxed text-muted-foreground">{body}</p>
</li>
))}
</ol>
</Section>
{/* ── 이렇게 나옵니다 ───────────────────────────── */}
<Section muted>
<SectionHead
eyebrow="이렇게 나옵니다"
title="먼저 시작한 가게들"
description="실제로 발행된 홈페이지입니다."
/>
<ShowcaseGrid limit={6} />
<div className="mt-8">
<Link
to="/showcase"
className="inline-flex items-center gap-1.5 text-sm font-medium transition-colors hover:text-muted-foreground"
>
<ArrowRight className="size-4" aria-hidden />
</Link>
</div>
</Section>
{/* ── 발행 기준 ────────────────────────────────── */}
<Section>
<div className="grid gap-10 lg:grid-cols-[1fr_1.1fr]">
<SectionHead
title="틀린 정보는 발행하지 않습니다"
description="여기가 틀리면 틀린 채로 퍼집니다. 세 가지를 통과하지 못하면 발행을 멈춥니다."
/>
<ul className="space-y-3 text-sm">
{[
'확인하지 않은 값은 한 줄도 나가지 않습니다.',
'직접 쓴 소개가 하나도 없으면 발행하지 않습니다.',
'화면에 보이는 값과 검색엔진에 보내는 값이 다르면 그 자리에서 멈춥니다.',
].map((line) => (
<li key={line} className="flex gap-2.5 text-muted-foreground">
<Check className="mt-0.5 size-4 shrink-0 text-success" aria-hidden />
<span>{line}</span>
</li>
))}
</ul>
</div>
</Section>
{/* ── 만들고 끝이 아니다 ───────────────────────── */}
<Section muted>
<div className="grid gap-10 lg:grid-cols-[1fr_1.1fr]">
<SectionHead
eyebrow="그다음"
title="만들고 끝이 아닙니다"
description="AI가 무엇을 인용하는지는 매달 달라집니다."
/>
<div>
<ul className="space-y-3 text-sm text-muted-foreground">
{[
'AI 노출(AEO) 진단 리포트 — 지금 무엇이 인용되고 무엇이 빠졌는지',
'SEO·AEO 최적화 가이드 — 다음 달에 무엇을 고칠지',
'페이지 제작 1회 · 영상 콘텐츠 2종',
'리포트 리뷰 미팅 — 결과를 같이 봅니다',
].map((line) => (
<li key={line} className="flex gap-2.5">
<Check className="mt-0.5 size-4 shrink-0 text-success" aria-hidden />
<span>{line}</span>
</li>
))}
</ul>
<Link
to="/pricing"
className="mt-6 inline-flex items-center gap-1.5 text-sm font-medium transition-colors hover:text-muted-foreground"
>
<ArrowRight className="size-4" aria-hidden />
</Link>
</div>
</div>
</Section>
{/* ── 마지막 ───────────────────────────────────── */}
<Section className="text-center">
<h2 className="text-2xl font-bold tracking-tight text-balance sm:text-3xl">
</h2>
<p className="mt-3 text-sm text-muted-foreground"> .</p>
<div className="mt-7 flex flex-wrap justify-center gap-2">
<Link to="/builder?new=1">
<Button variant="primary" size="lg">
</Button>
</Link>
<Link to="/pricing">
<Button variant="outline" size="lg">
</Button>
</Link>
</div>
</Section>
</MarketingShell>
);
}

View File

@ -0,0 +1,109 @@
import {Link} from 'react-router';
import {Check} from 'lucide-react';
import {MarketingShell, Section} from '@/components/layout/MarketingShell';
import {Button} from '@/components/ui/button';
/**
* .
*
* . **
* ** "할 것이냐".
* .
*/
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>
);
}

View File

@ -0,0 +1,37 @@
import {Link} from 'react-router';
import {MarketingShell, Section} from '@/components/layout/MarketingShell';
import {ShowcaseGrid} from '@/features/marketing/ShowcaseGrid';
import {Button} from '@/components/ui/button';
/**
* .
*
* 릿 . "진짜로 나갔다" .
* (ShowcaseGrid ) .
*/
export function ShowcasePage() {
return (
<MarketingShell>
<Section>
<header className="mb-10 max-w-2xl">
<h1 className="text-3xl font-bold tracking-tight text-balance sm:text-4xl"> </h1>
<p className="mt-3 text-sm leading-relaxed text-muted-foreground">
. .
</p>
</header>
<ShowcaseGrid limit={24} />
</Section>
<Section muted className="text-center">
<h2 className="text-2xl font-bold tracking-tight text-balance"> ?</h2>
<p className="mt-3 text-sm text-muted-foreground"> .</p>
<Link to="/builder?new=1" className="mt-7 inline-block">
<Button variant="primary" size="lg">
</Button>
</Link>
</Section>
</MarketingShell>
);
}