224 lines
10 KiB
TypeScript
224 lines
10 KiB
TypeScript
/**
|
|
* MultiChannelInput — 랜딩 Hero / CTA 공용 멀티 URL 입력 컴포넌트.
|
|
*
|
|
* 설계 배경 (PART III 피봇 + 2026-04 입력 분할 개편):
|
|
* - 영업 현장에서는 병원이 본인들의 YouTube/Instagram/FB/네이버플레이스/블로그/강남언니 URL을
|
|
* 이미 알고 있는 경우가 많아 이를 직접 받는 편이 정확도·속도 면에서 우월합니다.
|
|
* - 단일 textarea + 자동 분류 방식은 사용자가 "어떻게 입력해야 하는지" 직관이 없어
|
|
* 공란이 되거나 잘못된 URL이 섞이는 경우가 많았습니다.
|
|
* - 따라서 채널별로 7개 필드를 명시적으로 노출 — 빈 칸을 보면 "여기에 무엇을 넣어야 하는지"가 명확합니다.
|
|
*
|
|
* 동작:
|
|
* 1) 7개 필드 각각에 URL 입력 (한 줄에 하나)
|
|
* 2) 입력 시 `classifyUrls()`로 검증 — 해당 채널 패턴과 매치되는지 실시간 확인
|
|
* 3) 매치 성공: 우측에 체크 아이콘, 실패: 경고 아이콘
|
|
* 4) 1개 이상 유효 입력 시 Analyze 버튼 활성화
|
|
* 5) Submit 시 onAnalyze(payload) 호출 (부모가 navigation 처리)
|
|
*
|
|
* DS 준수:
|
|
* - Filled Icons Only (lucide 무단 사용 금지)
|
|
* - DS Primary pill: rounded-full + gradient `from-[#4F1DA1] to-[#021341]`
|
|
* - variant='hero': 글래스 배경 (랜딩 Hero)
|
|
* - variant='cta': 다크 배경 (CTA 섹션)
|
|
*/
|
|
|
|
import { useMemo, useState } from 'react';
|
|
import { ArrowRight } from 'lucide-react';
|
|
import { CheckFilled, WarningFilled } from '@/shared/icons/FilledIcons';
|
|
import {
|
|
classifyUrls,
|
|
hasAnalyzableChannels,
|
|
pickPrimaryUrl,
|
|
type ClassifiedUrls,
|
|
} from '../lib/classifyUrls';
|
|
import { Button } from '@/shared/ui/button';
|
|
import { PLATFORM_META } from '@/features/clinics/components/PlatformChips';
|
|
import type { PlatformKey } from '@/features/clinics/types/workspace';
|
|
|
|
/** discover-channels Edge Function에 전달되는 수동 채널 URL 묶음. */
|
|
export interface ManualChannels {
|
|
youtube?: string[];
|
|
instagram?: string[];
|
|
facebook?: string[];
|
|
naverPlace?: string[];
|
|
naverBlog?: string[];
|
|
gangnamUnni?: string[];
|
|
}
|
|
|
|
export interface AnalyzePayload {
|
|
/** discover-channels `url` 필드 (필수) — 홈페이지 우선, 없으면 첫 SNS URL. */
|
|
primaryUrl: string;
|
|
/** 사용자가 제공한 채널별 URL — Edge Function이 Firecrawl discovery를 스킵. */
|
|
manualChannels: ManualChannels;
|
|
/** 사용자 원본 입력 (디버깅·재실행용) */
|
|
rawInput: string;
|
|
}
|
|
|
|
interface MultiChannelInputProps {
|
|
variant?: 'hero' | 'cta';
|
|
onAnalyze: (payload: AnalyzePayload) => void;
|
|
}
|
|
|
|
type ChannelKey = keyof Omit<ClassifiedUrls, 'unknown'>;
|
|
|
|
/** ChannelKey(분류기 결과 키) → 공통 PlatformKey 매핑 — 'homepage' ↔ 'website' 만 다름 */
|
|
const CHANNEL_TO_PLATFORM: Record<ChannelKey, PlatformKey> = {
|
|
homepage: 'website',
|
|
youtube: 'youtube',
|
|
instagram: 'instagram',
|
|
facebook: 'facebook',
|
|
naverPlace: 'naverPlace',
|
|
naverBlog: 'naverBlog',
|
|
gangnamUnni: 'gangnamUnni',
|
|
};
|
|
|
|
const PLACEHOLDER: Record<ChannelKey, string> = {
|
|
homepage: 'viewclinic.com',
|
|
youtube: 'youtube.com/@ViewclinicKR',
|
|
instagram: 'instagram.com/viewplastic',
|
|
facebook: 'facebook.com/viewps1',
|
|
naverPlace: 'place.naver.com/hospital/11709005',
|
|
naverBlog: 'blog.naver.com/viewclinicps',
|
|
gangnamUnni: 'gangnamunni.com/hospitals/189',
|
|
};
|
|
|
|
/** 채널 입력 순서 — 공통 PLATFORM_META 의 label/color/Icon 을 그대로 사용 */
|
|
const CHANNEL_ORDER: ChannelKey[] = [
|
|
'homepage', 'youtube', 'instagram', 'facebook', 'naverPlace', 'naverBlog', 'gangnamUnni',
|
|
];
|
|
|
|
type ChannelUrlInputs = Record<ChannelKey, string>;
|
|
|
|
const EMPTY_URLS: ChannelUrlInputs = {
|
|
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<keyof ClassifiedUrls>) {
|
|
if (k !== 'unknown' && k !== expected && c[k].length > 0) return 'wrong';
|
|
}
|
|
return 'invalid';
|
|
}
|
|
|
|
export default function MultiChannelInput({ variant = 'hero', onAnalyze }: MultiChannelInputProps) {
|
|
const [urls, setUrls] = useState<ChannelUrlInputs>(EMPTY_URLS);
|
|
|
|
// 통합 분류 결과 — 7개 필드 값을 join해 classifyUrls에 한 번에 통과시켜 manualChannels 구성.
|
|
const aggregated = useMemo(() => {
|
|
const joined = Object.values(urls).filter(Boolean).join('\n');
|
|
return classifyUrls(joined);
|
|
}, [urls]);
|
|
|
|
const canAnalyze = hasAnalyzableChannels(aggregated);
|
|
const primaryUrl = pickPrimaryUrl(aggregated);
|
|
|
|
const handleSubmit = () => {
|
|
if (!canAnalyze || !primaryUrl) return;
|
|
const payload: AnalyzePayload = {
|
|
primaryUrl,
|
|
manualChannels: {
|
|
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: Object.entries(urls)
|
|
.filter(([, v]) => v.trim())
|
|
.map(([k, v]) => `${k}: ${v}`)
|
|
.join('\n'),
|
|
};
|
|
onAnalyze(payload);
|
|
};
|
|
|
|
// variant별 스타일 토큰
|
|
const isHero = variant === 'hero';
|
|
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';
|
|
|
|
return (
|
|
<div className="w-full max-w-2xl mx-auto">
|
|
{/* 7개 채널별 입력 필드 — 한 줄에 하나, 좌측 아이콘 + 우측 검증 상태 */}
|
|
<div className="flex flex-col gap-2">
|
|
{CHANNEL_ORDER.map((key) => {
|
|
const meta = PLATFORM_META[CHANNEL_TO_PLATFORM[key]];
|
|
const Icon = meta.Icon;
|
|
const value = urls[key];
|
|
const status = validateField(value, key);
|
|
const inactiveColor = isHero ? '#94a3b8' /* slate-400 */ : 'rgba(255,255,255,0.4)';
|
|
return (
|
|
<div key={key} className="relative">
|
|
{/* 좌측 채널 아이콘 — 입력 시 브랜드 컬러, 빈 칸일 땐 회색.
|
|
z-10 으로 input 의 bg/backdrop-blur 위에 올림 (없으면 input 배경이 아이콘을 가림) */}
|
|
<div className="absolute left-3 top-1/2 -translate-y-1/2 pointer-events-none flex items-center gap-2 z-10">
|
|
<Icon
|
|
size={16}
|
|
style={{ color: value ? meta.color : inactiveColor }}
|
|
/>
|
|
</div>
|
|
<input
|
|
type="url"
|
|
inputMode="url"
|
|
value={value}
|
|
onChange={(e) => setUrls((prev) => ({ ...prev, [key]: e.target.value }))}
|
|
placeholder={`${meta.label} · ${PLACEHOLDER[key]}`}
|
|
aria-label={meta.label}
|
|
className={inputClass}
|
|
spellCheck={false}
|
|
autoComplete="off"
|
|
/>
|
|
{/* 우측 검증 상태 아이콘 — 동일하게 z-10 */}
|
|
<div className="absolute right-3 top-1/2 -translate-y-1/2 pointer-events-none z-10">
|
|
{status === 'valid' && (
|
|
<CheckFilled size={16} className="text-emerald-500" />
|
|
)}
|
|
{(status === 'wrong' || status === 'invalid') && (
|
|
<WarningFilled size={16} className={isHero ? 'text-amber-500' : 'text-amber-300'} />
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
{/* 보조 안내 — 1개 이상 입력 시 어느 채널이 분석 대상인지 요약 */}
|
|
<p className={`text-xs font-medium mt-3 text-center leading-relaxed break-keep ${labelClass}`}>
|
|
7개 채널 중 알고 계신 URL만 입력해주세요 — 1개만 입력하셔도 분석이 시작됩니다.
|
|
</p>
|
|
|
|
{/* 분석 시작 버튼 — DS Primary pill */}
|
|
<Button
|
|
onClick={handleSubmit}
|
|
disabled={!canAnalyze}
|
|
className={`w-full max-w-md mx-auto mt-4 px-10 py-4 h-auto text-lg font-medium rounded-full shadow-xl hover:shadow-2xl flex items-center justify-center gap-2 group bg-gradient-to-r transform-gpu will-change-transform transition-[transform,filter,box-shadow] duration-500 ease-[cubic-bezier(0.16,1,0.3,1)] motion-safe:hover:scale-[1.02] motion-safe:hover:brightness-110 active:scale-[0.98] active:duration-150 ${
|
|
isHero
|
|
? 'from-brand-purple to-brand-purple-deep text-white'
|
|
: 'from-brand-grad-peach via-brand-grad-violet to-brand-grad-sky text-primary-900'
|
|
}`}
|
|
>
|
|
Analyze
|
|
<ArrowRight className="size-5 transform-gpu will-change-transform transition-transform duration-500 ease-[cubic-bezier(0.16,1,0.3,1)] motion-safe:group-hover:translate-x-1" />
|
|
</Button>
|
|
|
|
{/* 보조 안내 (하단) */}
|
|
<p className={`text-xs font-medium mt-3 text-center leading-relaxed break-keep ${helperClass}`}>
|
|
네이버 블로그 · 플레이스 · 소셜미디어 등 Online Presence 종합 분석 리포트를 제공합니다.
|
|
</p>
|
|
</div>
|
|
);
|
|
}
|