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:
parent
f50426eaed
commit
3ba8fc839d
@ -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"
|
||||
|
||||
@ -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"
|
||||
|
||||
219
src/components/discovery/DiscoveryLeadModal.tsx
Normal file
219
src/components/discovery/DiscoveryLeadModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
24
supabase/migrations/20260831_discovery_leads.sql
Normal file
24
supabase/migrations/20260831_discovery_leads.sql
Normal 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 입력으로 시작하기)에서 저장.';
|
||||
Loading…
Reference in New Issue
Block a user