- 히어로·하단 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
220 lines
9.6 KiB
TypeScript
220 lines
9.6 KiB
TypeScript
/**
|
|
* "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>
|
|
);
|
|
}
|