From dc8a1db3f246b6dcb927310fe48cecac4bd6fea4 Mon Sep 17 00:00:00 2001 From: Haewon Kam Date: Tue, 28 Apr 2026 11:25:45 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EB=B7=B0=EC=84=B1=ED=98=95=EC=99=B8?= =?UTF-8?q?=EA=B3=BC=20=EB=AF=B8=ED=8C=85=20=EB=8C=80=EB=B9=84=20=E2=80=94?= =?UTF-8?q?=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EA=B0=B1=EC=8B=A0(2026-04-28)?= =?UTF-8?q?=20+=20URL=207=EB=B6=84=ED=95=A0=20+=20=EA=B0=80=EA=B2=A9=20?= =?UTF-8?q?=EA=B0=9C=ED=8E=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - mockReport.ts (view-clinic): 8채널 실측 갱신 · YouTube · 강남언니 · 네이버 플레이스 · 네이버 블로그 → Firecrawl · Instagram (KR · EN) · Facebook (KR · EN) → Apify · createdAt 2026-04-13 → 2026-04-28 - mockPlan.ts (view-clinic): KPI 베이스라인 동기화 + scheduledDate 5월로 이동 - MultiChannelInput: 단일 textarea → 7-필드 분할 (홈페이지 · YT · IG · FB · 네이버플레이스 · 블로그 · 강남언니), 채널별 실시간 검증 아이콘 - PricingPage / FeatureComparisonTable: 49만/149만/399만 → 9만/29만/99만, 월 리포트 수 1회/4회/10회, 경쟁사 추적 1/3/5 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.7 --- src/components/MultiChannelInput.tsx | 212 +++++++++--------- .../pricing/FeatureComparisonTable.tsx | 7 +- src/data/mockPlan.ts | 16 +- src/data/mockReport.ts | 47 ++-- src/pages/PricingPage.tsx | 24 +- 5 files changed, 155 insertions(+), 151 deletions(-) diff --git a/src/components/MultiChannelInput.tsx b/src/components/MultiChannelInput.tsx index 87d4b53..6aedfa8 100644 --- a/src/components/MultiChannelInput.tsx +++ b/src/components/MultiChannelInput.tsx @@ -1,21 +1,22 @@ /** * MultiChannelInput — 랜딩 Hero / CTA 공용 멀티 URL 입력 컴포넌트. * - * 설계 배경 (PART III 피봇): - * - 기존 Hero/CTA의 단일 URL input은 "홈페이지 URL → 자동 SNS 발견"을 전제로 했지만, - * 영업 현장에서는 병원이 본인들의 YouTube/Instagram/FB/네이버플레이스/블로그/강남언니 URL을 + * 설계 배경 (PART III 피봇 + 2026-04 입력 분할 개편): + * - 영업 현장에서는 병원이 본인들의 YouTube/Instagram/FB/네이버플레이스/블로그/강남언니 URL을 * 이미 알고 있는 경우가 많아 이를 직접 받는 편이 정확도·속도 면에서 우월합니다. - * - Hero와 CTA가 동일한 입력 로직(url state + handleAnalyze + navigate)을 **중복 구현**하던 - * 문제를 이 컴포넌트 하나로 해소합니다. + * - 단일 textarea + 자동 분류 방식은 사용자가 "어떻게 입력해야 하는지" 직관이 없어 + * 공란이 되거나 잘못된 URL이 섞이는 경우가 많았습니다. + * - 따라서 채널별로 7개 필드를 명시적으로 노출 — 빈 칸을 보면 "여기에 무엇을 넣어야 하는지"가 명확합니다. * * 동작: - * 1) Textarea에 URL을 공백/쉼표/줄바꿈으로 구분해 붙여넣기 - * 2) `classifyUrls()`로 실시간 7채널 분류 (`useMemo`) - * 3) 채널별 칩 프리뷰 — 검출 건수 표시, unknown URL은 경고 - * 4) "채널 분석 시작" 버튼 클릭 → onAnalyze(payload) 호출 (부모가 navigation 처리) + * 1) 7개 필드 각각에 URL 입력 (한 줄에 하나) + * 2) 입력 시 `classifyUrls()`로 검증 — 해당 채널 패턴과 매치되는지 실시간 확인 + * 3) 매치 성공: 우측에 체크 아이콘, 실패: 경고 아이콘 + * 4) 1개 이상 유효 입력 시 Analyze 버튼 활성화 + * 5) Submit 시 onAnalyze(payload) 호출 (부모가 navigation 처리) * * DS 준수: - * - Filled Icons Only (lucide 무단 사용 금지 — 이모지·outlined 금지) + * - Filled Icons Only (lucide 무단 사용 금지) * - DS Primary pill: rounded-full + gradient `from-[#4F1DA1] to-[#021341]` * - variant='hero': 글래스 배경 (랜딩 Hero) * - variant='cta': 다크 배경 (CTA 섹션) @@ -31,6 +32,7 @@ import { DatabaseFilled, FileTextFilled, MessageFilled, + CheckFilled, WarningFilled, } from './icons/FilledIcons'; import { @@ -64,137 +66,137 @@ interface MultiChannelInputProps { onAnalyze: (payload: AnalyzePayload) => void; } -/** 채널 칩 메타 — 렌더링 순서·아이콘·한글명. */ +type ChannelKey = keyof Omit; + +/** 채널별 메타 — 라벨/아이콘/색상/플레이스홀더 (뷰성형외과 실 URL을 데모 placeholder로). */ const CHANNEL_META: Array<{ - key: keyof Omit; + key: ChannelKey; label: string; Icon: (props: { size?: number; className?: string }) => ReactElement; - color: string; // 활성화 시 텍스트/아이콘 색 + color: string; + placeholder: string; }> = [ - { key: 'homepage', label: '홈페이지', Icon: GlobeFilled, color: 'text-[#4F1DA1]' }, - { key: 'youtube', label: 'YouTube', Icon: YoutubeFilled, color: 'text-[#FF0000]' }, - { key: 'instagram', label: 'Instagram', Icon: InstagramFilled,color: 'text-[#E1306C]' }, - { key: 'facebook', label: 'Facebook', Icon: FacebookFilled, color: 'text-[#1877F2]' }, - { key: 'naverPlace', label: '네이버 플레이스', Icon: DatabaseFilled, color: 'text-[#03C75A]' }, - { key: 'naverBlog', label: '네이버 블로그', Icon: FileTextFilled, color: 'text-[#03C75A]' }, - { key: 'gangnamUnni', label: '강남언니', Icon: MessageFilled, color: 'text-[#FF5C89]' }, + { key: 'homepage', label: '홈페이지', Icon: GlobeFilled, color: 'text-[#4F1DA1]', placeholder: 'viewclinic.com' }, + { key: 'youtube', label: 'YouTube', Icon: YoutubeFilled, color: 'text-[#FF0000]', placeholder: 'youtube.com/@ViewclinicKR' }, + { key: 'instagram', label: 'Instagram', Icon: InstagramFilled,color: 'text-[#E1306C]', placeholder: 'instagram.com/viewplastic' }, + { key: 'facebook', label: 'Facebook', Icon: FacebookFilled, color: 'text-[#1877F2]', placeholder: 'facebook.com/viewps1' }, + { key: 'naverPlace', label: '네이버 플레이스', Icon: DatabaseFilled, color: 'text-[#03C75A]', placeholder: 'place.naver.com/hospital/11709005' }, + { key: 'naverBlog', label: '네이버 블로그', Icon: FileTextFilled, color: 'text-[#03C75A]', placeholder: 'blog.naver.com/viewclinicps' }, + { key: 'gangnamUnni', label: '강남언니', Icon: MessageFilled, color: 'text-[#FF5C89]', placeholder: 'gangnamunni.com/hospitals/189' }, ]; +type EmptyClassified = Record; + +const EMPTY_URLS: EmptyClassified = { + homepage: '', youtube: '', instagram: '', facebook: '', + naverPlace: '', naverBlog: '', gangnamUnni: '', +}; + +/** + * 단일 입력값 검증 — 해당 채널 패턴과 매치되는지. + * 빈 값: 'empty', 매치 성공: 'valid', SNS인데 다른 채널: 'wrong', 파싱 실패: 'invalid' + */ +function validateField(value: string, expected: ChannelKey): 'empty' | 'valid' | 'wrong' | 'invalid' { + if (!value.trim()) return 'empty'; + const c = classifyUrls(value); + if (c[expected].length > 0) return 'valid'; + // 다른 채널로 분류됐으면 'wrong', unknown이면 'invalid' + for (const k of Object.keys(c) as Array) { + if (k !== 'unknown' && k !== expected && c[k].length > 0) return 'wrong'; + } + return 'invalid'; +} + export default function MultiChannelInput({ variant = 'hero', onAnalyze }: MultiChannelInputProps) { - const [text, setText] = useState(''); + const [urls, setUrls] = useState(EMPTY_URLS); - // 실시간 분류 — text가 바뀔 때만 재계산. - const classified = useMemo(() => classifyUrls(text), [text]); + // 통합 분류 결과 — 7개 필드 값을 join해 classifyUrls에 한 번에 통과시켜 manualChannels 구성. + const aggregated = useMemo(() => { + const joined = Object.values(urls).filter(Boolean).join('\n'); + return classifyUrls(joined); + }, [urls]); - const canAnalyze = hasAnalyzableChannels(classified); - const primaryUrl = pickPrimaryUrl(classified); + const canAnalyze = hasAnalyzableChannels(aggregated); + const primaryUrl = pickPrimaryUrl(aggregated); const handleSubmit = () => { if (!canAnalyze || !primaryUrl) return; const payload: AnalyzePayload = { primaryUrl, manualChannels: { - // homepage는 primaryUrl로 들어가므로 manualChannels에서는 제외 (Edge Function에서 `url`을 쓰므로) - youtube: classified.youtube.length ? classified.youtube : undefined, - instagram: classified.instagram.length ? classified.instagram : undefined, - facebook: classified.facebook.length ? classified.facebook : undefined, - naverPlace: classified.naverPlace.length ? classified.naverPlace : undefined, - naverBlog: classified.naverBlog.length ? classified.naverBlog : undefined, - gangnamUnni: classified.gangnamUnni.length ? classified.gangnamUnni : undefined, + youtube: aggregated.youtube.length ? aggregated.youtube : undefined, + instagram: aggregated.instagram.length ? aggregated.instagram : undefined, + facebook: aggregated.facebook.length ? aggregated.facebook : undefined, + naverPlace: aggregated.naverPlace.length ? aggregated.naverPlace : undefined, + naverBlog: aggregated.naverBlog.length ? aggregated.naverBlog : undefined, + gangnamUnni: aggregated.gangnamUnni.length ? aggregated.gangnamUnni : undefined, }, - rawInput: text, + rawInput: Object.entries(urls) + .filter(([, v]) => v.trim()) + .map(([k, v]) => `${k}: ${v}`) + .join('\n'), }; onAnalyze(payload); }; // variant별 스타일 토큰 - // ▸ `text-center` 추가 (원본 Hero 단일 URL input과 일관): Form 필드가 아닌 - // "검색창 같은 자유 입력" 시그널로 전환. placeholder 여러 줄도 중앙 정렬. const isHero = variant === 'hero'; - const textareaClass = isHero - ? 'w-full px-6 py-4 text-sm md:text-base text-center bg-white/80 backdrop-blur-sm border border-slate-200 rounded-2xl focus:outline-none focus:ring-2 focus:ring-accent/20 focus:border-accent/40 shadow-sm text-primary-900 placeholder:text-slate-400 transition-all resize-none leading-relaxed' - : 'w-full px-6 py-4 text-sm md:text-base text-center bg-white/5 backdrop-blur-sm border border-white/15 rounded-2xl focus:outline-none focus:ring-2 focus:ring-accent/30 focus:border-accent/60 text-white placeholder:text-white/40 transition-all resize-none leading-relaxed'; + const inputClass = isHero + ? 'w-full pl-11 pr-10 py-2.5 text-sm bg-white/80 backdrop-blur-sm border border-slate-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-accent/20 focus:border-accent/40 shadow-sm text-primary-900 placeholder:text-slate-400 transition-all' + : 'w-full pl-11 pr-10 py-2.5 text-sm bg-white/5 backdrop-blur-sm border border-white/15 rounded-xl focus:outline-none focus:ring-2 focus:ring-accent/30 focus:border-accent/60 text-white placeholder:text-white/40 transition-all'; + const labelClass = isHero ? 'text-slate-600' : 'text-white/70'; const helperClass = isHero ? 'text-slate-500' : 'text-white/60'; - const chipInactiveBg = isHero ? 'bg-slate-50 border-slate-100' : 'bg-white/5 border-white/10'; - const chipInactiveText = isHero ? 'text-slate-400' : 'text-white/40'; - const chipActiveBg = isHero ? 'bg-white border-slate-200 shadow-sm' : 'bg-white/10 border-white/25'; - const chipActiveText = isHero ? 'text-primary-900' : 'text-white'; return (
- {/* URL Textarea - Placeholder 카피 원칙: "한 줄씩" 같은 입력 규칙 제거. - classifyUrls.ts가 공백·쉼표·줄바꿈·임의 텍스트 섞임 모두 처리하므로 - 사용자에게 입력 형식 고민을 넘기지 않고, "뭉치 → 자동 분류" 를 제품 약속으로 선언. - 정렬: `text-center` — 원본 Hero의 단일 URL input과 일관, Form 필드 인상을 줄여 - "검색창 같은 자유 입력" 느낌으로 전환. */} -