[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:
parent
a61f9724ea
commit
af391b4c56
@ -5,6 +5,24 @@
|
||||
|
||||
---
|
||||
|
||||
## 2026-09-03 — 랜딩 · 요금 · 쇼케이스 — 로그인 전 화면이 생겼다
|
||||
|
||||
**왜**
|
||||
`/` 가 곧장 위저드로 튀어서, 이 제품이 무엇을 파는 물건인지 말할 자리가 한 곳도 없었다.
|
||||
처음 온 사람이 업종 선택 화면부터 만난다.
|
||||
|
||||
**한 일**
|
||||
- `/` 는 비로그인이면 랜딩, 로그인이면 `/sites`. `/pricing` · `/showcase` 신설
|
||||
- `MarketingShell` — 사이드바 없는 문서형 껍데기. `AppShell` 은 작업 화면이라 나눴다
|
||||
(b07ade2 가 온보딩에서 사이드바를 뺀 것과 같은 판단)
|
||||
- 랜딩 상단은 **상호명 한 칸**이다. 업종 칩은 "누구를 위한 서비스인가"를 말하는 용도이고
|
||||
고르지 않아도 된다 — 업종은 검색 결과가 정한다
|
||||
- 쇼케이스는 발행 썸네일을 그대로 건다. **예시 데이터로 채우지 않는다** — 이 섹션이 파는 건
|
||||
"진짜로 나갔다"는 사실 하나라, 가짜를 걸면 그 자리에서 가치가 0 이다. 없으면 섹션을 감춘다
|
||||
- 요금은 플랜 하나(70만원/월). 비교표를 만들지 않는다 — 고를 것이 가격대가 아니다
|
||||
|
||||
**검증** — tsc·eslint·vite build 통과.
|
||||
|
||||
## 2026-09-03 — 상호명 검색을 로그인 앞으로 · 업종은 LLM 없이 정한다
|
||||
|
||||
**왜**
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
117
solution/frontend/src/components/layout/MarketingShell.tsx
Normal file
117
solution/frontend/src/components/layout/MarketingShell.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
102
solution/frontend/src/features/marketing/ShowcaseGrid.tsx
Normal file
102
solution/frontend/src/features/marketing/ShowcaseGrid.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
46
solution/frontend/src/features/marketing/showcaseApi.ts
Normal file
46
solution/frontend/src/features/marketing/showcaseApi.ts
Normal 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,
|
||||
}));
|
||||
}
|
||||
264
solution/frontend/src/pages/LandingPage.tsx
Normal file
264
solution/frontend/src/pages/LandingPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
109
solution/frontend/src/pages/PricingPage.tsx
Normal file
109
solution/frontend/src/pages/PricingPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
37
solution/frontend/src/pages/ShowcasePage.tsx
Normal file
37
solution/frontend/src/pages/ShowcasePage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user