From 3ba8fc839da59a84a86fa51a4a51011ec2734431 Mon Sep 17 00:00:00 2001 From: Haewon Kam Date: Mon, 31 Aug 2026 16:58:38 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20/discovery=20CTA=20'URL=20=EC=9E=85?= =?UTF-8?q?=EB=A0=A5=EC=9C=BC=EB=A1=9C=20=EC=8B=9C=EC=9E=91=ED=95=98?= =?UTF-8?q?=EA=B8=B0'=20=E2=80=94=20URL=20=EC=9E=85=EB=A0=A5=20=EC=A7=84?= =?UTF-8?q?=EB=8B=A8=20=EC=8B=A0=EC=B2=AD=20=EB=AA=A8=EB=8B=AC=20+=20disco?= =?UTF-8?q?very=5Fleads=20=EC=A0=80=EC=9E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 히어로·하단 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 Claude-Session: https://claude.ai/code/session_01WgWoJZAvvzMxTpWeenZSia --- src/components/discovery/DiscoveryCta.tsx | 13 +- src/components/discovery/DiscoveryHero.tsx | 13 +- .../discovery/DiscoveryLeadModal.tsx | 219 ++++++++++++++++++ src/pages/DiscoveryLandingPage.tsx | 8 +- .../migrations/20260831_discovery_leads.sql | 24 ++ 5 files changed, 265 insertions(+), 12 deletions(-) create mode 100644 src/components/discovery/DiscoveryLeadModal.tsx create mode 100644 supabase/migrations/20260831_discovery_leads.sql diff --git a/src/components/discovery/DiscoveryCta.tsx b/src/components/discovery/DiscoveryCta.tsx index 588b83b..0b29ced 100644 --- a/src/components/discovery/DiscoveryCta.tsx +++ b/src/components/discovery/DiscoveryCta.tsx @@ -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 (
- - 무료 AEO / GEO 진단 받기 - + URL 입력으로 시작하기 + void; } export default function DiscoveryHero({ @@ -25,6 +26,7 @@ export default function DiscoveryHero({ clinicName, sampleReportPath, criteriaCount, + onStart, }: DiscoveryHeroProps) { return (
@@ -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" > - - 무료 AEO / GEO 진단 받기 + URL 입력으로 시작하기 - + 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('idle'); + const [fieldError, setFieldError] = useState(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 ( + + {open && ( + + e.stopPropagation()} + role="dialog" + aria-modal="true" + aria-label="무료 AEO/GEO 진단 신청" + > + {/* 닫기 (커스텀 filled SVG — 라인 아이콘 금지 규칙) */} + + + {state === 'done' ? ( +
+
+ +
+

신청이 접수되었습니다

+

+ 입력하신 URL을 실측 진단한 뒤, 남겨주신 연락처로 리포트를 안내드립니다. +
+ 문의: {DISCOVERY_CONTACT_EMAIL} +

+ +
+ ) : ( +
+

URL 입력으로 시작하기

+

+ 병원 홈페이지 URL만 입력하면 AI 검색 인용 가능성을 실측 진단합니다. 진단은 + 무료입니다. +

+ + + 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" + /> + + + 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" + /> + + + 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 && ( +

+ {fieldError} +

+ )} + + {state === 'error' && ( +

+ 저장에 실패했습니다. 아래 이메일로 직접 신청해 주세요. +
+ + {DISCOVERY_CONTACT_EMAIL}로 신청 메일 보내기 + +

+ )} + + + +

+ 입력 정보는 진단 회신 용도로만 사용합니다. +

+
+ )} +
+
+ )} +
+ ); +} diff --git a/src/pages/DiscoveryLandingPage.tsx b/src/pages/DiscoveryLandingPage.tsx index cf9bf89..92b31ba 100644 --- a/src/pages/DiscoveryLandingPage.tsx +++ b/src/pages/DiscoveryLandingPage.tsx @@ -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)} /> @@ -47,7 +50,8 @@ export default function DiscoveryLandingPage() { unverifiedCount={unverifiedCount} /> - + setLeadOpen(true)} /> + setLeadOpen(false)} />
); } diff --git a/supabase/migrations/20260831_discovery_leads.sql b/supabase/migrations/20260831_discovery_leads.sql new file mode 100644 index 0000000..631feb4 --- /dev/null +++ b/supabase/migrations/20260831_discovery_leads.sql @@ -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 입력으로 시작하기)에서 저장.';