diff --git a/docs/DEVLOG.md b/docs/DEVLOG.md
index fcbfb82..663b230 100644
--- a/docs/DEVLOG.md
+++ b/docs/DEVLOG.md
@@ -5,6 +5,24 @@
---
+## 2026-09-03 — 랜딩 · 요금 · 쇼케이스 — 로그인 전 화면이 생겼다
+
+**왜**
+`/` 가 곧장 위저드로 튀어서, 이 제품이 무엇을 파는 물건인지 말할 자리가 한 곳도 없었다.
+처음 온 사람이 업종 선택 화면부터 만난다.
+
+**한 일**
+- `/` 는 비로그인이면 랜딩, 로그인이면 `/sites`. `/pricing` · `/showcase` 신설
+- `MarketingShell` — 사이드바 없는 문서형 껍데기. `AppShell` 은 작업 화면이라 나눴다
+ (b07ade2 가 온보딩에서 사이드바를 뺀 것과 같은 판단)
+- 랜딩 상단은 **상호명 한 칸**이다. 업종 칩은 "누구를 위한 서비스인가"를 말하는 용도이고
+ 고르지 않아도 된다 — 업종은 검색 결과가 정한다
+- 쇼케이스는 발행 썸네일을 그대로 건다. **예시 데이터로 채우지 않는다** — 이 섹션이 파는 건
+ "진짜로 나갔다"는 사실 하나라, 가짜를 걸면 그 자리에서 가치가 0 이다. 없으면 섹션을 감춘다
+- 요금은 플랜 하나(70만원/월). 비교표를 만들지 않는다 — 고를 것이 가격대가 아니다
+
+**검증** — tsc·eslint·vite build 통과.
+
## 2026-09-03 — 상호명 검색을 로그인 앞으로 · 업종은 LLM 없이 정한다
**왜**
diff --git a/solution/frontend/src/app/router.tsx b/solution/frontend/src/app/router.tsx
index ccd6b79..f8f6066 100644
--- a/solution/frontend/src/app/router.tsx
+++ b/solution/frontend/src/app/router.tsx
@@ -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() {
);
}
- return ;
+ if (user) return ;
+ return ;
}
export const router = createBrowserRouter([
@@ -35,12 +41,13 @@ export const router = createBrowserRouter([
// 로그인 화면의 [회원가입] 이 여기로 온다. 이 줄이 없으면 링크는 있고 목적지만 404 다.
{path: '/signup', element: },
- // ★ 비로그인의 첫 화면은 업종 선택(위저드 1단계)이다.
- // `?new=1` 을 붙이는 이유: 위저드 상태는 새로고침을 넘기려고 저장돼 있어서(stores/builder persist),
- // 그냥 /builder 로 보내면 지난번에 만들다 만 **에디터**가 복원돼 뜬다. 처음 들어오는 사람에게는
- // 그게 "왜 자꾸 빌더로 튀냐"로 보인다. 그래서 진입 경로에서 한 번 비우고 시작한다.
+ // 비로그인 = 랜딩, 로그인 = 내 사이트. Home 이 그걸 가른다.
{path: '/', element: },
+ // 로그인 전 화면. ★ 랜딩과 같은 껍데기(MarketingShell)를 쓴다 — 사이드바 없는 문서형이다.
+ {path: '/pricing', element: },
+ {path: '/showcase', element: },
+
// 로그인한 사장님의 홈. 만든 사이트를 열고 고치는 자리다.
{
path: '/sites',
diff --git a/solution/frontend/src/components/layout/AppShell.tsx b/solution/frontend/src/components/layout/AppShell.tsx
index 55f4384..990aba0 100644
--- a/solution/frontend/src/components/layout/AppShell.tsx
+++ b/solution/frontend/src/components/layout/AppShell.tsx
@@ -36,7 +36,9 @@ export function AppShell({children, nav = OWNER_NAV}: {children: ReactNode; nav?
return (
-
+ {/* ★ 로고는 언제나 홈(/)이다. 메뉴 첫 항목으로 보내면 앱마다 목적지가 달라지고,
+ 로고를 눌러 첫 화면으로 가려던 사람이 엉뚱한 목록에 떨어진다. */}
+
diff --git a/solution/frontend/src/components/layout/MarketingShell.tsx b/solution/frontend/src/components/layout/MarketingShell.tsx
new file mode 100644
index 0000000..7b0e8b2
--- /dev/null
+++ b/solution/frontend/src/components/layout/MarketingShell.tsx
@@ -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 (
+
+ );
+}
+
+/** 랜딩의 한 켜. 섹션마다 여백을 손으로 적지 않게 한 곳에 모은다. */
+export function Section({
+ children,
+ className,
+ muted = false,
+}: {
+ children: ReactNode;
+ className?: string;
+ muted?: boolean;
+}) {
+ return (
+
+ );
+}
+
+export function SectionHead({eyebrow, title, description}: {eyebrow?: string; title: string; description?: string}) {
+ return (
+
+ {eyebrow && {eyebrow}
}
+ {title}
+ {description && {description}
}
+
+ );
+}
diff --git a/solution/frontend/src/features/marketing/ShowcaseGrid.tsx b/solution/frontend/src/features/marketing/ShowcaseGrid.tsx
new file mode 100644
index 0000000..eda4c63
--- /dev/null
+++ b/solution/frontend/src/features/marketing/ShowcaseGrid.tsx
@@ -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 = {
+ [PlaceCategory.LODGING]: Building2,
+ [PlaceCategory.CAFE]: Coffee,
+ [PlaceCategory.RESTAURANT]: UtensilsCrossed,
+ [PlaceCategory.CLINIC]: Stethoscope,
+};
+
+const CATEGORY_LABEL: Record = {
+ [PlaceCategory.LODGING]: '숙박',
+ [PlaceCategory.CAFE]: '카페',
+ [PlaceCategory.RESTAURANT]: '음식점',
+ [PlaceCategory.CLINIC]: '피부과 · 성형외과',
+};
+
+/** 발행 사이트 주소는 루트 상대경로로 온다(`/s/`). 발행 호스트는 번들에 구워진 값이다. */
+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(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 (
+
+ {(items ?? Array.from({length: Math.min(limit, 3)}, () => null)).map((item, index) =>
+ item ? : ,
+ )}
+
+ );
+}
+
+function ShowcaseCard({item}: {item: ShowcaseItem}) {
+ const Icon = CATEGORY_ICON[item.category] ?? Building2;
+ return (
+
+
+ {item.thumbnailUrl ? (
+
+ ) : (
+ // ★ 썸네일은 발행에 성공한 뒤에만 채워진다 — 없는 건 정상이다(사진 없는 가게).
+
+ )}
+
+
+
+
+
{item.name}
+
+ {[item.region, CATEGORY_LABEL[item.category]].filter(Boolean).join(' · ')}
+
+
+
+
+ );
+}
+
+function SkeletonCard() {
+ return (
+
+ );
+}
diff --git a/solution/frontend/src/features/marketing/showcaseApi.ts b/solution/frontend/src/features/marketing/showcaseApi.ts
new file mode 100644
index 0000000..e2e9ea2
--- /dev/null
+++ b/solution/frontend/src/features/marketing/showcaseApi.ts
@@ -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/`). */
+ url: string;
+ thumbnailUrl?: string;
+};
+
+const INDUSTRY_BY_CATEGORY: Record = {
+ [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 {
+ 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) => ({
+ 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,
+ }));
+}
diff --git a/solution/frontend/src/pages/LandingPage.tsx b/solution/frontend/src/pages/LandingPage.tsx
new file mode 100644
index 0000000..77ac1ec
--- /dev/null
+++ b/solution/frontend/src/pages/LandingPage.tsx
@@ -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 (
+
+ {/* ── 상단 ─────────────────────────────────────── */}
+
+
+
+ SEO · AEO 되는
+
+ 웹사이트
+
+
+ 가게 이름 한 줄이면 만들어 드립니다
+
+
+
+
+ {/* 업종은 고르지 않아도 된다 — 여기 있는 건 "누구를 위한 서비스인가" 를 말하기 위한 것이다. */}
+
+ {INDUSTRIES.map(({id, label, icon: Icon}) => (
+
+
+ {label}
+
+ ))}
+
+
+
+ {[
+ '가입 없이 만들어 봅니다',
+ '검색엔진에 바로 알립니다',
+ 'AI가 읽는 형태로 나갑니다',
+ ].map((line) => (
+
+
+ {line}
+
+ ))}
+
+
+
+
+ {/* ── 왜 필요한가 ───────────────────────────────── */}
+
+
+
+
+
+ 그래서 AI는 블로그 후기에서 추측합니다. AI가 읽을 수 있는 페이지 하나를 놓아 두면,
+ 그 페이지가 우리 가게의 정답 이 됩니다.
+
+
+
+ {/* AI 답변 예시 — 실제 답변을 옮긴 게 아니라 상황을 보여주는 그림이다. */}
+
+
+ AI 답변 예시
+
+
+ 양양에 조용한 독채 펜션 추천해줘
+
+
+ 죽도해변 근처에 독채형 숙소가 몇 곳 있는 것으로 보입니다. 다만 객실 수나 요금, 반려동물 동반
+ 여부는 확인할 수 있는 출처가 없어 정확히 안내드리기 어렵습니다.
+
+
+ · 블로그 후기 · 2023년 글
+ · 카페 게시글 · 작성자 미상
+ · 공식 홈페이지 — 없음
+
+
+
+
+
+ {/* ── 무엇을 하나 ──────────────────────────────── */}
+
+
+
+ {[
+ {
+ title: '흩어진 정보를 모읍니다',
+ body: '가게가 이미 공개해 둔 곳에서 영업시간·가격·시설을 모읍니다. 맞으면 두고, 틀리면 고칩니다.',
+ },
+ {
+ title: 'AEO — AI가 읽는 형태로',
+ body: '자바스크립트 없이도 본문이 읽히는 정적 페이지로 굽고, 구조화 데이터와 llms.txt 를 함께 내보냅니다.',
+ },
+ {
+ title: 'SEO — 검색에 바로 걸리게',
+ body: '발행 즉시 네이버·Bing 에 통보하고, 구글에는 사이트맵으로 제출합니다.',
+ },
+ ].map(({title, body}) => (
+
+ {title}
+ {body}
+
+ ))}
+
+
+
+ {/* ── 이렇게 나옵니다 ───────────────────────────── */}
+
+
+ {/* ── 발행 기준 ────────────────────────────────── */}
+
+
+
+
+ {[
+ '확인하지 않은 값은 한 줄도 나가지 않습니다.',
+ '직접 쓴 소개가 하나도 없으면 발행하지 않습니다.',
+ '화면에 보이는 값과 검색엔진에 보내는 값이 다르면 그 자리에서 멈춥니다.',
+ ].map((line) => (
+
+
+ {line}
+
+ ))}
+
+
+
+
+ {/* ── 만들고 끝이 아니다 ───────────────────────── */}
+
+
+
+
+
+ {[
+ 'AI 노출(AEO) 진단 리포트 — 지금 무엇이 인용되고 무엇이 빠졌는지',
+ 'SEO·AEO 최적화 가이드 — 다음 달에 무엇을 고칠지',
+ '페이지 제작 1회 · 영상 콘텐츠 2종',
+ '리포트 리뷰 미팅 — 결과를 같이 봅니다',
+ ].map((line) => (
+
+
+ {line}
+
+ ))}
+
+
+ 요금 보기
+
+
+
+
+
+
+ {/* ── 마지막 ───────────────────────────────────── */}
+
+
+ 가게 이름부터 넣어 보세요
+
+ 가입 없이 만들어 볼 수 있습니다.
+
+
+
+ 무료로 만들어 보기
+
+
+
+
+ 요금 보기
+
+
+
+
+
+ );
+}
diff --git a/solution/frontend/src/pages/PricingPage.tsx b/solution/frontend/src/pages/PricingPage.tsx
new file mode 100644
index 0000000..0388cb7
--- /dev/null
+++ b/solution/frontend/src/pages/PricingPage.tsx
@@ -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 (
+
+
+
+
+
+
+
Web4AI
+
+ 70만원
+ / 월
+
+
+
+
AI가 우리를 찾아내게 만듭니다
+
+ ChatGPT·제미나이·퍼플렉시티 답변에 우리 정보가 인용되도록 홈페이지를 진단하고 고칩니다.
+
+
+
+ {INCLUDED.map((item) => (
+
+
+ {item}
+
+ ))}
+
+
+
+ {TERMS.map(({label, value}) => (
+
+
{label}
+ {value}
+
+ ))}
+
+
+
+
+ 먼저 만들어 보기
+
+
+
+ 가입하지 않아도 끝까지 만들어 볼 수 있습니다.
+
+
+
+
+
+ 자주 묻는 질문
+
+ {[
+ {
+ q: '네이버 플레이스랑 뭐가 다른가요?',
+ a: '네이버는 AI 크롤러를 막습니다. 그래서 네이버에 올린 정보는 ChatGPT 같은 AI가 읽지 못합니다. 이 홈페이지는 AI가 읽을 수 있게 만듭니다.',
+ },
+ {
+ q: '홈페이지가 이미 있는데도 필요한가요?',
+ a: '있는 홈페이지가 AI에게 읽히는지가 관건입니다. 화면에는 보이는데 크롤러에게는 빈 페이지인 경우가 많습니다. 진단이 그걸 먼저 봅니다.',
+ },
+ {
+ q: '무엇을 준비해야 하나요?',
+ a: '지금 운영 중인 홈페이지 주소 하나면 시작합니다. 없으면 여기서 만드는 것부터 합니다.',
+ },
+ {
+ q: '결과는 어떻게 확인하나요?',
+ a: '매달 진단 리포트를 드리고, 리뷰 미팅에서 무엇이 달라졌는지 같이 봅니다.',
+ },
+ ].map(({q, a}) => (
+
+
{q}
+ {a}
+
+ ))}
+
+
+
+ );
+}
diff --git a/solution/frontend/src/pages/ShowcasePage.tsx b/solution/frontend/src/pages/ShowcasePage.tsx
new file mode 100644
index 0000000..7dd3fed
--- /dev/null
+++ b/solution/frontend/src/pages/ShowcasePage.tsx
@@ -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 (
+
+
+
+
+ 우리 가게도 만들어 볼까요?
+ 가게 이름만 있으면 됩니다.
+
+
+ 무료로 만들어 보기
+
+
+
+
+ );
+}