o2o-infinith-frontend/src/features/channels/components/MultiChannelInput.tsx

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>
);
}