feat: /discovery CTA 'URL 입력으로 시작하기' — URL 입력 진단 신청 모달 + discovery_leads 저장

- 히어로·하단 CTA 버튼명 변경(무료 AEO/GEO 진단 받기 → URL 입력으로 시작하기), mailto 직행 제거
- DiscoveryLeadModal: URL(필수)·연락처(필수)·상호(선택) → Supabase discovery_leads insert
- RLS anon insert 전용(조회 불가), 저장 실패 시 o2oteam@o2o.kr mailto 폴백 (정직성 규약)
- 마이그레이션 20260831_discovery_leads.sql (적용은 supabase login 후 db push 필요)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WgWoJZAvvzMxTpWeenZSia
This commit is contained in:
Haewon Kam 2026-08-31 16:58:38 +09:00
parent f50426eaed
commit 3ba8fc839d
5 changed files with 265 additions and 12 deletions

View File

@ -6,9 +6,11 @@ import { buildDiscoveryMailto, DISCOVERY_CONTACT_EMAIL } from './discoveryContac
interface DiscoveryCtaProps {
criteriaCount: number;
/** CTA 클릭 → URL 입력 신청 모달 열기 */
onStart: () => void;
}
export default function DiscoveryCta({ criteriaCount }: DiscoveryCtaProps) {
export default function DiscoveryCta({ criteriaCount, onStart }: DiscoveryCtaProps) {
return (
<section className="bg-[#0A1128] px-6 py-24">
<motion.div
@ -25,12 +27,13 @@ export default function DiscoveryCta({ criteriaCount }: DiscoveryCtaProps) {
진단은 무료입니다. {criteriaCount}개 항목 실측 리포트를 받아보고 결정하세요.
</p>
<div className="flex flex-col sm:flex-row items-center gap-3.5">
<a
href={buildDiscoveryMailto('무료 진단 신청')}
<button
type="button"
onClick={onStart}
className="inline-flex items-center h-[52px] px-[30px] bg-white rounded-full text-base font-bold text-primary-900 hover:bg-slate-100 transition-colors"
>
무료 AEO / GEO 진단 받기
</a>
URL 입력으로 시작하기
</button>
<a
href={buildDiscoveryMailto('무료 진단 신청')}
className="text-[15px] text-slate-500 hover:text-slate-300 transition-colors"

View File

@ -9,7 +9,6 @@ import { ArrowRight } from 'lucide-react';
import type { OverallScore } from '../../types/discovery';
import AbmrLoop from './AbmrLoop';
import AiAnswerMock from './AiAnswerMock';
import { buildDiscoveryMailto } from './discoveryContact';
interface DiscoveryHeroProps {
overall: OverallScore;
@ -17,6 +16,8 @@ interface DiscoveryHeroProps {
clinicName: string;
sampleReportPath: string;
criteriaCount: number;
/** CTA 클릭 → URL 입력 신청 모달 열기 */
onStart: () => void;
}
export default function DiscoveryHero({
@ -25,6 +26,7 @@ export default function DiscoveryHero({
clinicName,
sampleReportPath,
criteriaCount,
onStart,
}: DiscoveryHeroProps) {
return (
<section className="relative overflow-hidden bg-[radial-gradient(ellipse_at_top,#e0e7ff_0%,#faf5ff_45%,#fdf2f8_100%)]">
@ -81,13 +83,14 @@ export default function DiscoveryHero({
transition={{ duration: 0.6, delay: 0.3 }}
className="flex flex-col sm:flex-row items-center gap-4 mb-18"
>
<a
href={buildDiscoveryMailto('무료 진단 신청')}
<button
type="button"
onClick={onStart}
className="inline-flex items-center justify-center gap-2 px-10 py-4 text-lg font-medium text-white rounded-full shadow-xl bg-gradient-to-r from-[#4F1DA1] to-[#021341] hover:opacity-90 transition-opacity"
>
무료 AEO / GEO 진단 받기
URL 입력으로 시작하기
<ArrowRight className="w-5 h-5" />
</a>
</button>
<Link
to={sampleReportPath}
className="inline-flex items-center justify-center px-10 py-4 text-lg font-medium text-primary-900 bg-white/80 border border-slate-200 rounded-full hover:bg-white transition-colors"

View File

@ -0,0 +1,219 @@
/**
* "URL 입력으로 시작하기" 진단 신청 모달.
*
* - 병원 URL(필수) + 회신 연락처(필수) + 상호(선택) → Supabase discovery_leads insert
* - RLS는 anon insert만 허용 (조회는 service_role 전용)
* - 저장 실패 시 성공한 척하지 않고 mailto 폴백을 보여준다 (정직성 규약)
*/
import { useEffect, useState } from 'react';
import { AnimatePresence, motion } from 'motion/react';
import { ArrowRight } from 'lucide-react';
import { supabase } from '../../lib/supabase';
import { DISCOVERY_CONTACT_EMAIL } from './discoveryContact';
interface DiscoveryLeadModalProps {
open: boolean;
onClose: () => void;
}
type SubmitState = 'idle' | 'submitting' | 'done' | 'error';
/** 스킴이 없으면 https://를 붙이고, 호스트에 점이 있어야 유효로 본다 */
const normalizeUrl = (raw: string): string | null => {
const trimmed = raw.trim();
if (!trimmed) return null;
const withScheme = /^https?:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`;
try {
const u = new URL(withScheme);
if (!u.hostname.includes('.')) return null;
return u.toString();
} catch {
return null;
}
};
export default function DiscoveryLeadModal({ open, onClose }: DiscoveryLeadModalProps) {
const [url, setUrl] = useState('');
const [clinicName, setClinicName] = useState('');
const [contact, setContact] = useState('');
const [state, setState] = useState<SubmitState>('idle');
const [fieldError, setFieldError] = useState<string | null>(null);
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [open, onClose]);
// @types/react 미설치 프로젝트 방침이라 React.FormEvent 대신 구조적 타입 사용
const handleSubmit = async (e: { preventDefault: () => void }) => {
e.preventDefault();
const normalized = normalizeUrl(url);
if (!normalized) {
setFieldError('URL 형식을 확인해 주세요. 예: viewclinic.com');
return;
}
if (!contact.trim()) {
setFieldError('진단 결과를 받을 이메일 또는 전화번호를 입력해 주세요.');
return;
}
setFieldError(null);
setState('submitting');
const { error } = await supabase.from('discovery_leads').insert({
url: normalized,
clinic_name: clinicName.trim() || null,
contact: contact.trim(),
source: 'discovery_landing',
});
setState(error ? 'error' : 'done');
};
const fallbackMailto = () => {
const body = `병원/업체 URL: ${url}\n상호: ${clinicName || '-'}\n회신 연락처: ${contact}`;
return `mailto:${DISCOVERY_CONTACT_EMAIL}?subject=${encodeURIComponent(
'[INFINITH AI Discovery] 무료 진단 신청',
)}&body=${encodeURIComponent(body)}`;
};
return (
<AnimatePresence>
{open && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-50 flex items-center justify-center px-6 bg-[#0A1128]/60 backdrop-blur-sm"
onClick={onClose}
data-no-print
>
<motion.div
initial={{ opacity: 0, y: 24, scale: 0.98 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 24, scale: 0.98 }}
transition={{ duration: 0.25 }}
className="relative w-full max-w-md bg-white rounded-2xl shadow-2xl p-8"
onClick={(e) => e.stopPropagation()}
role="dialog"
aria-modal="true"
aria-label="무료 AEO/GEO 진단 신청"
>
{/* 닫기 (커스텀 filled SVG — 라인 아이콘 금지 규칙) */}
<button
type="button"
onClick={onClose}
aria-label="닫기"
className="absolute top-4 right-4 w-8 h-8 flex items-center justify-center rounded-full bg-slate-100 hover:bg-slate-200 transition-colors"
>
<svg width="12" height="12" viewBox="0 0 12 12" aria-hidden="true">
<path
d="M2.3 0.9 6 4.6 9.7 0.9a1 1 0 0 1 1.4 1.4L7.4 6l3.7 3.7a1 1 0 0 1-1.4 1.4L6 7.4 2.3 11.1a1 1 0 0 1-1.4-1.4L4.6 6 0.9 2.3A1 1 0 0 1 2.3 0.9Z"
fill="#0A1128"
/>
</svg>
</button>
{state === 'done' ? (
<div className="flex flex-col items-center text-center py-4">
<div className="w-14 h-14 rounded-full bg-[#F3F0FF] flex items-center justify-center mb-5">
<svg width="24" height="24" viewBox="0 0 24 24" aria-hidden="true">
<path
d="M9.6 16.2 4.8 11.4a1.2 1.2 0 0 1 1.7-1.7l3.1 3.1 7.9-7.9a1.2 1.2 0 0 1 1.7 1.7l-8.8 8.8a1.2 1.2 0 0 1-1.7 0Z"
fill="#6C5CE7"
/>
</svg>
</div>
<h3 className="text-lg font-bold text-primary-900 mb-2">신청이 접수되었습니다</h3>
<p className="text-sm leading-relaxed text-slate-600 mb-6">
입력하신 URL을 실측 진단한 뒤, 남겨주신 연락처로 리포트를 안내드립니다.
<br />
문의: {DISCOVERY_CONTACT_EMAIL}
</p>
<button
type="button"
onClick={onClose}
className="inline-flex items-center h-11 px-8 bg-gradient-to-r from-[#4F1DA1] to-[#021341] rounded-full text-sm font-bold text-white hover:opacity-90 transition-opacity"
>
확인
</button>
</div>
) : (
<form onSubmit={handleSubmit}>
<h3 className="text-xl font-bold text-primary-900 mb-1.5">URL 입력으로 시작하기</h3>
<p className="text-sm leading-relaxed text-slate-600 mb-6">
병원 홈페이지 URL만 입력하면 AI 검색 인용 가능성을 실측 진단합니다. 진단은
무료입니다.
</p>
<label className="block text-xs font-semibold text-slate-500 mb-1.5">
병원/업체 홈페이지 URL <span className="text-[#7C3A4B]">*</span>
</label>
<input
type="text"
value={url}
onChange={(e) => setUrl(e.target.value)}
placeholder="viewclinic.com"
autoFocus
className="w-full h-12 px-4 mb-4 rounded-xl border border-slate-200 bg-[linear-gradient(to_right,#fff3eb,#e4cfff,#f5f9ff)] text-[15px] text-primary-900 placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-[#6C5CE7]/40"
/>
<label className="block text-xs font-semibold text-slate-500 mb-1.5">
상호 <span className="text-slate-400 font-normal">(선택)</span>
</label>
<input
type="text"
value={clinicName}
onChange={(e) => setClinicName(e.target.value)}
placeholder="뷰성형외과"
className="w-full h-12 px-4 mb-4 rounded-xl border border-slate-200 bg-white text-[15px] text-primary-900 placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-[#6C5CE7]/40"
/>
<label className="block text-xs font-semibold text-slate-500 mb-1.5">
진단 결과 받을 이메일/전화 <span className="text-[#7C3A4B]">*</span>
</label>
<input
type="text"
value={contact}
onChange={(e) => setContact(e.target.value)}
placeholder="marketing@clinic.com"
className="w-full h-12 px-4 mb-4 rounded-xl border border-slate-200 bg-white text-[15px] text-primary-900 placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-[#6C5CE7]/40"
/>
{fieldError && (
<p className="text-[13px] text-[#7C3A4B] bg-[#FFF0F0] border border-[#F5D5DC] rounded-lg px-3.5 py-2.5 mb-4">
{fieldError}
</p>
)}
{state === 'error' && (
<p className="text-[13px] leading-relaxed text-[#7C5C3A] bg-[#FFF6ED] border border-[#F5E0C5] rounded-lg px-3.5 py-2.5 mb-4">
저장에 실패했습니다. 아래 이메일로 직접 신청해 주세요.
<br />
<a href={fallbackMailto()} className="font-semibold underline">
{DISCOVERY_CONTACT_EMAIL}로 신청 메일 보내기
</a>
</p>
)}
<button
type="submit"
disabled={state === 'submitting'}
className="w-full inline-flex items-center justify-center gap-2 h-[52px] bg-gradient-to-r from-[#4F1DA1] to-[#021341] rounded-full text-base font-bold text-white hover:opacity-90 transition-opacity disabled:opacity-60"
>
{state === 'submitting' ? '접수 중…' : '무료 진단 신청'}
{state !== 'submitting' && <ArrowRight className="w-5 h-5" />}
</button>
<p className="text-xs text-slate-400 text-center mt-4">
입력 정보는 진단 회신 용도로만 사용합니다.
</p>
</form>
)}
</motion.div>
</motion.div>
)}
</AnimatePresence>
);
}

View File

@ -6,7 +6,7 @@
* scoreDiscovery(AEO_GEO_RUBRIC, DISCOVERY_VIEWCLINIC) 계산값에서 파생한다.
* 재진단으로 데이터가 바뀌면 랜딩 수치도 함께 바뀐다 (JSX 하드코딩 금지).
*/
import { useMemo } from 'react';
import { useMemo, useState } from 'react';
import { AEO_GEO_RUBRIC } from '../data/aeoGeoRubric';
import { DISCOVERY_VIEWCLINIC } from '../data/discovery_viewclinic';
import { scoreDiscovery, nextGradeTarget } from '../lib/discoveryScore';
@ -17,8 +17,10 @@ import SampleReport from '../components/discovery/SampleReport';
import Roadmap from '../components/discovery/Roadmap';
import HonestyStrip from '../components/discovery/HonestyStrip';
import DiscoveryCta from '../components/discovery/DiscoveryCta';
import DiscoveryLeadModal from '../components/discovery/DiscoveryLeadModal';
export default function DiscoveryLandingPage() {
const [leadOpen, setLeadOpen] = useState(false);
const result = DISCOVERY_VIEWCLINIC;
const overall = useMemo(() => scoreDiscovery(AEO_GEO_RUBRIC, result), [result]);
const target = nextGradeTarget(overall.score);
@ -36,6 +38,7 @@ export default function DiscoveryLandingPage() {
clinicName={result.clinicName}
sampleReportPath={`/discovery/${result.id}`}
criteriaCount={criteriaCount}
onStart={() => setLeadOpen(true)}
/>
<WhyNow />
<HowItWorks criteriaCount={criteriaCount} categoryCount={categoryCount} />
@ -47,7 +50,8 @@ export default function DiscoveryLandingPage() {
unverifiedCount={unverifiedCount}
/>
<HonestyStrip />
<DiscoveryCta criteriaCount={criteriaCount} />
<DiscoveryCta criteriaCount={criteriaCount} onStart={() => setLeadOpen(true)} />
<DiscoveryLeadModal open={leadOpen} onClose={() => setLeadOpen(false)} />
</div>
);
}

View File

@ -0,0 +1,24 @@
-- AI Discovery 랜딩 "URL 입력으로 시작하기" 신청 저장 테이블
-- 익명(anon) 방문자가 insert만 할 수 있고, 읽기·수정·삭제는 service_role 전용.
create table if not exists public.discovery_leads (
id uuid primary key default gen_random_uuid(),
url text not null,
clinic_name text,
contact text not null,
source text not null default 'discovery_landing',
status text not null default 'new', -- new | contacted | audited | closed
created_at timestamptz not null default now()
);
alter table public.discovery_leads enable row level security;
-- 방문자 신청: insert만 허용 (select 정책이 없으므로 anon은 조회 불가)
create policy "anon can insert discovery leads"
on public.discovery_leads
for insert
to anon
with check (true);
comment on table public.discovery_leads is
'AI Discovery 랜딩 진단 신청. /discovery CTA(URL 입력으로 시작하기)에서 저장.';