상단에 입력칸 하나만 있으면 무엇이 만들어지는지 알 수 없다. 결과물을 바로 밑에서 흘린다. - CSS 만으로 돈다(index.css o2o-marquee). setInterval 로 돌리면 탭이 백그라운드일 때 프레임이 밀려 돌아왔을 때 툭 끊긴 것처럼 보인다 - 같은 목록을 두 벌 그리고 트랙을 -50% 까지만 민다 — 한 벌이면 끝에서 빈 화면이 지나간다 - 개수가 늘어도 흐르는 속도는 그대로(카드 수에 비례해 duration) - 복제분은 aria-hidden·tabIndex=-1 — 스크린리더가 같은 목록을 두 번 읽지 않게 - hover 하면 멈추고, prefers-reduced-motion 이면 아예 안 움직인다 - CTA 문구를 [확인하기] 로(시안과 같게) tsc·eslint·vite build 통과
268 lines
13 KiB
TypeScript
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>
|
|
);
|
|
}
|