폼을 감췄더니 그 폼을 담던 relative 박스가 높이 0으로 접혀, top-1/2 로 그 박스 가운데에 앉던 쇼케이스 띠가 헤드라인 바로 아래로 붙어버렸다(사장님 스크린샷 실측). 폼이 없을 때만 min-h 로 그 자리를 대신 잡아준다. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
329 lines
16 KiB
TypeScript
329 lines
16 KiB
TypeScript
import {useState, type FormEvent} from 'react';
|
|
import {Link, useNavigate, useSearchParams} from 'react-router';
|
|
import {ArrowRight, ArrowUp, Check} from 'lucide-react';
|
|
import {MarketingShell, Section, SectionHead} from '@/components/layout/MarketingShell';
|
|
import {ShowcaseGrid, ShowcasePeeks} from '@/features/marketing/ShowcaseGrid';
|
|
import {Button} from '@/components/ui/button';
|
|
import type {MetaFunction} from 'react-router';
|
|
import {ORIGIN} from '@/lib/site';
|
|
/**
|
|
* ★ 제목은 브랜드가 아니라 **사람이 검색창에 치는 말**로 시작한다. "Web4Ai" 는 검색량이
|
|
* 없는 신규 이름이고, 예전 제목("Web4Ai · AI 웹 빌더")에는 검색어가 한 단어도 없었다.
|
|
* seo 규칙: 핵심 키워드 앞, 브랜드 뒤, 50~60자.
|
|
*/
|
|
export const meta: MetaFunction = () => [
|
|
{title: '가게 홈페이지 만들기 · AI 검색에 인용되는 방식으로 | Web4Ai'},
|
|
{
|
|
name: 'description',
|
|
content:
|
|
'네이버 플레이스만 있으면 우리 가게 홈페이지가 만들어집니다. 주소·영업시간·메뉴가 구조화 데이터로 함께 나가 검색엔진과 AI 답변이 함께 읽습니다. 사업자가 확인한 정보만 발행합니다.',
|
|
},
|
|
{name: 'robots', content: 'index, follow, max-snippet:-1, max-image-preview:large'},
|
|
{tagName: 'link', rel: 'canonical', href: `${ORIGIN}/`},
|
|
{property: 'og:title', content: '가게 홈페이지 만들기 · AI 검색에 인용되는 방식으로'},
|
|
{
|
|
property: 'og:description',
|
|
content: '네이버 플레이스만 있으면 검색·AI 답변이 함께 읽는 우리 가게 홈페이지가 만들어집니다.',
|
|
},
|
|
{property: 'og:url', content: `${ORIGIN}/`},
|
|
// 이 앱이 무엇인지 기계에게 말하는 유일한 자리다. 발행본에는 LodgingBusiness 가 나가는데
|
|
// 정작 랜딩에는 구조화 데이터가 하나도 없었다.
|
|
{
|
|
'script:ld+json': {
|
|
'@context': 'https://schema.org',
|
|
'@type': 'Organization',
|
|
'@id': `${ORIGIN}/#org`,
|
|
name: 'Web4Ai',
|
|
url: `${ORIGIN}/`,
|
|
logo: `${ORIGIN}/brand/favicon-w4a.svg`,
|
|
description:
|
|
'네이버 플레이스 정보를 사업자가 확인해 가게 공식 홈페이지로 발행하는 서비스. 검색엔진과 AI 답변엔진이 함께 읽는 형식으로 내보냅니다.',
|
|
},
|
|
},
|
|
];
|
|
|
|
|
|
/**
|
|
* 히어로 제목의 앞말. '홈페이지' 는 고정이고 이 여섯이 돌아간다.
|
|
* ★ 개수를 바꾸면 index.css 의 `o2o-rotate-6` 키프레임도 같이 고쳐야 한다 —
|
|
* 안 고치면 뒤쪽 문구가 영영 안 보이거나 빈 줄이 지나간다. 조용히 틀리는 종류다.
|
|
*/
|
|
const HEADLINES = [
|
|
'SEO · AEO 최적화',
|
|
'AI가 먼저 찾는',
|
|
'챗GPT가 인용하는',
|
|
'검색에 바로 걸리는',
|
|
'손님이 먼저 만나는',
|
|
'우리 가게가 직접 말하는',
|
|
];
|
|
|
|
|
|
/**
|
|
* 랜딩 — 원페이지. 로그인 전 첫 화면이다.
|
|
*
|
|
* ★ 파는 것은 "예쁜 홈페이지"가 아니라 **AI 답변에서의 1차 출처 지위**다(PRODUCT.md 1절).
|
|
* 문구도 그 축에서만 쓴다 — '쉽게·빠르게·저렴하게'로 말하는 순간 홈페이지 빌더와
|
|
* 같은 자리에서 비교당하고, 그 축에서는 이길 수 없다.
|
|
*
|
|
* ★ 상단이 받는 건 **상호명**이지 업종이 아니다. 사장님은 자기 가게 이름은 100% 알지만
|
|
* 업종은 경계에서 멈춘다("우리는 카페인가 음식점인가"). 업종은 검색 결과의 분류로
|
|
* 자동으로 정해지고(services/place_category.py), 못 정하면 그때 고르게 한다.
|
|
*
|
|
* ★ 여기서 로그인을 받지 않는다. 관문은 에디터 진입 하나다(b94daa9).
|
|
*/
|
|
export function LandingPage() {
|
|
const navigate = useNavigate();
|
|
const [query, setQuery] = useState('');
|
|
const [searchParams] = useSearchParams();
|
|
const buildMode = searchParams.get('build') === '1';
|
|
|
|
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 showAuthCta={buildMode}>
|
|
{/* ── 상단 ─────────────────────────────────────── */}
|
|
<section className="relative flex min-h-[calc(100dvh-4rem)] items-center overflow-hidden border-b border-border">
|
|
<div className="mx-auto w-full max-w-6xl px-5 text-center">
|
|
<h1 className="mx-auto text-4xl leading-[1.15] font-extrabold tracking-[-0.045em] sm:text-6xl lg:text-7xl">
|
|
{/* 앞말만 돈다. 마지막 항목은 첫 항목의 복제다 — 되감을 때 글자가 튀지 않게(index.css). */}
|
|
<span className="o2o-rotator">
|
|
<span className="o2o-rotator-track">
|
|
{[...HEADLINES, HEADLINES[0]].map((line, index) => (
|
|
<span key={index} aria-hidden={index > 0}>
|
|
{line}
|
|
</span>
|
|
))}
|
|
</span>
|
|
</span>
|
|
홈페이지
|
|
</h1>
|
|
{buildMode && (
|
|
<p className="mx-auto mt-6 text-base text-muted-foreground sm:text-lg">
|
|
가게 이름 한 줄로 시작해 보세요
|
|
</p>
|
|
)}
|
|
|
|
{/* ★ 입력 카드와 발행 사이트 띠를 **같은 밴드**에 겹친다(아임웹과 같은 구조).
|
|
띠를 카드 아래 따로 두면 첫 화면이 세로로 길어지고, 카드가 뜬금없이 혼자 뜬다. */}
|
|
<div className={`relative mt-14 ${buildMode ? '' : 'min-h-52'}`}>
|
|
<div className="pointer-events-none absolute top-1/2 left-1/2 w-screen -translate-x-1/2 -translate-y-1/2">
|
|
<ShowcasePeeks />
|
|
</div>
|
|
|
|
{buildMode && (
|
|
<form
|
|
onSubmit={submit}
|
|
className="relative z-10 mx-auto max-w-2xl rounded-2xl border border-border bg-card p-4 text-left shadow-[0_18px_50px_-20px_rgb(20_22_34/0.35)]"
|
|
>
|
|
<div className="flex items-center gap-2">
|
|
<img src="/brand/web4ai-wordmark.svg" alt="Web4Ai" className="h-4 w-auto" />
|
|
<span className="rounded-full bg-muted px-2 py-0.5 text-[10px] font-medium text-muted-foreground">
|
|
가게 이름으로 시작
|
|
</span>
|
|
{/* 업종부터 고르고 싶은 사람을 위한 갈래. 위저드의 업종 단계로 바로 보낸다. */}
|
|
<Link
|
|
to="/builder?new=1&step=industry"
|
|
className="ml-auto rounded-full border border-border px-3 py-1 text-xs text-muted-foreground transition-colors hover:border-foreground/25 hover:text-foreground"
|
|
>
|
|
업종부터 고르기
|
|
</Link>
|
|
</div>
|
|
|
|
<input
|
|
value={query}
|
|
onChange={(e) => setQuery(e.target.value)}
|
|
placeholder="가게 이름을 입력하세요"
|
|
aria-label="가게 이름"
|
|
className="mt-4 w-full border-0 bg-transparent px-1 text-base outline-none placeholder:text-muted-foreground sm:text-lg"
|
|
/>
|
|
|
|
<div className="mt-8 flex items-center justify-between gap-3">
|
|
<p className="truncate text-xs text-muted-foreground">
|
|
숙박 · 카페 · 음식점 · 피부과 · 성형외과
|
|
</p>
|
|
<button
|
|
type="submit"
|
|
aria-label="확인하기"
|
|
className="grid size-9 shrink-0 place-items-center rounded-full bg-primary text-primary-foreground transition-colors hover:bg-primary/90"
|
|
>
|
|
<ArrowUp className="size-4" aria-hidden />
|
|
</button>
|
|
</div>
|
|
</form>
|
|
)}
|
|
</div>
|
|
</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>
|
|
);
|
|
}
|
|
|
|
// 라우트 모듈은 default export 를 요구한다(routes.ts 가 이 파일을 가리킨다).
|
|
export default LandingPage;
|