o2o-site-AEO/solution/frontend/src/pages/LandingPage.tsx
Mina Choi 4ef8e7d232 [feat] solution/frontend: 히어로 아래 발행 사이트 마퀴 — 입력칸만으론 뭐가 나오는지 모른다
상단에 입력칸 하나만 있으면 무엇이 만들어지는지 알 수 없다. 결과물을 바로 밑에서 흘린다.

- CSS 만으로 돈다(index.css o2o-marquee). setInterval 로 돌리면 탭이 백그라운드일 때
  프레임이 밀려 돌아왔을 때 툭 끊긴 것처럼 보인다
- 같은 목록을 두 벌 그리고 트랙을 -50% 까지만 민다 — 한 벌이면 끝에서 빈 화면이 지나간다
- 개수가 늘어도 흐르는 속도는 그대로(카드 수에 비례해 duration)
- 복제분은 aria-hidden·tabIndex=-1 — 스크린리더가 같은 목록을 두 번 읽지 않게
- hover 하면 멈추고, prefers-reduced-motion 이면 아예 안 움직인다
- CTA 문구를 [확인하기] 로(시안과 같게)

tsc·eslint·vite build 통과
2026-09-03 10:34:02 +09:00

268 lines
13 KiB
TypeScript

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, ShowcasePeeks} 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>
{/* 입력칸만 있으면 무엇이 나오는지 알 수 없다 — 결과물 두 장을 먼저 보여준다. */}
<ShowcasePeeks />
</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>
);
}