브랜드 분석 페이지 수정, ado2 컨텐츠 추가, header jwt 인증 작업 .
parent
7d4f3c7e05
commit
7ee911f0aa
305
index.css
305
index.css
|
|
@ -2230,6 +2230,15 @@
|
||||||
margin-bottom: 24px;
|
margin-bottom: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.url-input-logo {
|
||||||
|
margin-bottom: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.url-input-logo img {
|
||||||
|
height: 48px;
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
|
||||||
.url-input-title {
|
.url-input-title {
|
||||||
font-family: 'Pretendard', sans-serif;
|
font-family: 'Pretendard', sans-serif;
|
||||||
font-size: 40px;
|
font-size: 40px;
|
||||||
|
|
@ -2258,30 +2267,32 @@
|
||||||
|
|
||||||
.url-input-wrapper {
|
.url-input-wrapper {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 12px;
|
align-items: stretch;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
background-color: #01393B;
|
||||||
|
border: 1px solid #034A4D;
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: visible;
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.url-input-field {
|
.url-input-field {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
width: 100%;
|
||||||
padding: 16px 20px;
|
padding: 16px 20px;
|
||||||
background-color: #01393B;
|
background-color: transparent;
|
||||||
border: 1px solid #034A4D;
|
border: none;
|
||||||
border-radius: 12px;
|
|
||||||
font-family: 'Pretendard', sans-serif;
|
font-family: 'Pretendard', sans-serif;
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
color: #E5F1F2;
|
color: #E5F1F2;
|
||||||
outline: none;
|
outline: none;
|
||||||
transition: border-color 0.2s ease;
|
text-align: left;
|
||||||
}
|
}
|
||||||
|
|
||||||
.url-input-field::placeholder {
|
.url-input-field::placeholder {
|
||||||
color: #6AB0B3;
|
color: #6AB0B3;
|
||||||
}
|
}
|
||||||
|
|
||||||
.url-input-field:focus {
|
|
||||||
border-color: #AE72F9;
|
|
||||||
}
|
|
||||||
|
|
||||||
.url-input-button {
|
.url-input-button {
|
||||||
padding: 16px 32px;
|
padding: 16px 32px;
|
||||||
|
|
@ -2321,6 +2332,142 @@
|
||||||
margin: 24px 0 0 0;
|
margin: 24px 0 0 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* URL Input Dropdown */
|
||||||
|
.url-input-dropdown-container {
|
||||||
|
position: relative;
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.url-input-dropdown-trigger {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 16px 16px;
|
||||||
|
background-color: transparent;
|
||||||
|
border: none;
|
||||||
|
font-family: 'Pretendard', sans-serif;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #E5F1F2;
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
transition: background-color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.url-input-dropdown-trigger:hover {
|
||||||
|
background-color: rgba(255, 255, 255, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.url-input-dropdown-arrow {
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.url-input-dropdown-arrow.open {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.url-input-dropdown-menu {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 4px);
|
||||||
|
left: 0;
|
||||||
|
min-width: 100%;
|
||||||
|
background-color: #01393B;
|
||||||
|
border: 1px solid #034A4D;
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
z-index: 100;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.url-input-dropdown-item {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
padding: 12px 16px;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
font-family: 'Pretendard', sans-serif;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #9BCACC;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.url-input-dropdown-item:hover {
|
||||||
|
background-color: #034A4D;
|
||||||
|
color: #E5F1F2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.url-input-dropdown-item.active {
|
||||||
|
background-color: #034A4D;
|
||||||
|
color: #94FBE0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* URL Input Autocomplete (Dashboard) */
|
||||||
|
.url-input-field-container {
|
||||||
|
position: relative;
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.url-input-autocomplete-dropdown {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 8px);
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
background-color: #002224;
|
||||||
|
border: 1px solid rgba(148, 251, 224, 0.2);
|
||||||
|
border-radius: 12px;
|
||||||
|
max-height: 300px;
|
||||||
|
overflow-y: auto;
|
||||||
|
z-index: 100;
|
||||||
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.url-input-autocomplete-loading {
|
||||||
|
padding: 16px;
|
||||||
|
text-align: center;
|
||||||
|
color: rgba(255, 255, 255, 0.6);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.url-input-autocomplete-item {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
padding: 12px 16px;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
border-bottom: 1px solid rgba(148, 251, 224, 0.1);
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.url-input-autocomplete-item:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.url-input-autocomplete-item:hover {
|
||||||
|
background-color: rgba(148, 251, 224, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.url-input-autocomplete-title {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #E5F1F2;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.url-input-autocomplete-title b {
|
||||||
|
color: #94FBE0;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.url-input-autocomplete-address {
|
||||||
|
font-size: 12px;
|
||||||
|
color: rgba(255, 255, 255, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
/* =====================================================
|
/* =====================================================
|
||||||
Landing Page Components
|
Landing Page Components
|
||||||
===================================================== */
|
===================================================== */
|
||||||
|
|
@ -2490,6 +2637,8 @@
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
transition: box-shadow 0.2s ease;
|
transition: box-shadow 0.2s ease;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
|
overflow: visible;
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero-input-wrapper.focused {
|
.hero-input-wrapper.focused {
|
||||||
|
|
@ -2543,6 +2692,146 @@
|
||||||
height: 24px;
|
height: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Hero Dropdown */
|
||||||
|
.hero-dropdown-container {
|
||||||
|
position: relative;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-dropdown-trigger {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
background-color: transparent;
|
||||||
|
border: none;
|
||||||
|
border-right: 1px solid #E5E7EB;
|
||||||
|
font-family: 'Pretendard', sans-serif;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #374151;
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
transition: color 0.2s ease;
|
||||||
|
margin-right: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-dropdown-trigger:hover {
|
||||||
|
color: #111827;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-dropdown-arrow {
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-dropdown-arrow.open {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-dropdown-menu {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 8px);
|
||||||
|
left: 0;
|
||||||
|
min-width: 100px;
|
||||||
|
background-color: #ffffff;
|
||||||
|
border: 1px solid #E5E7EB;
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
z-index: 100;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-dropdown-item {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
padding: 12px 16px;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
font-family: 'Pretendard', sans-serif;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #6B7280;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-dropdown-item:hover {
|
||||||
|
background-color: #F3F4F6;
|
||||||
|
color: #111827;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-dropdown-item.active {
|
||||||
|
background-color: #F0FDF4;
|
||||||
|
color: #059669;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hero Autocomplete */
|
||||||
|
.hero-input-container {
|
||||||
|
flex: 1;
|
||||||
|
position: relative;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-autocomplete-dropdown {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 8px);
|
||||||
|
left: -24px;
|
||||||
|
right: -60px;
|
||||||
|
background-color: #ffffff;
|
||||||
|
border: 1px solid #E5E7EB;
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
z-index: 200;
|
||||||
|
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2);
|
||||||
|
max-height: 300px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-autocomplete-loading {
|
||||||
|
padding: 16px;
|
||||||
|
text-align: center;
|
||||||
|
color: #6B7280;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-autocomplete-item {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
padding: 12px 16px;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
border-bottom: 1px solid #F3F4F6;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-autocomplete-item:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-autocomplete-item:hover {
|
||||||
|
background-color: #F9FAFB;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-autocomplete-title {
|
||||||
|
font-family: 'Pretendard', sans-serif;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #111827;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-autocomplete-title b {
|
||||||
|
color: #059669;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-autocomplete-address {
|
||||||
|
font-family: 'Pretendard', sans-serif;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #6B7280;
|
||||||
|
}
|
||||||
|
|
||||||
.hero-input-hint {
|
.hero-input-hint {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@
|
||||||
import React, { useMemo } from 'react';
|
import React, { useMemo } from 'react';
|
||||||
import { CrawlingResponse } from '../../types/api';
|
import { CrawlingResponse } from '../../types/api';
|
||||||
import GeometricChart, { USP } from './GeometricChart';
|
import GeometricChart, { USP } from './GeometricChart';
|
||||||
import KeywordBubble from './KeywordBubble';
|
|
||||||
|
|
||||||
interface AnalysisResultSectionProps {
|
interface AnalysisResultSectionProps {
|
||||||
onBack: () => void;
|
onBack: () => void;
|
||||||
|
|
@ -74,11 +73,12 @@ const parseMarkdownBlocks = (text: string): MarkdownBlock[] => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderInlineMarkdown = (text: string): React.ReactNode[] => {
|
const renderInlineMarkdown = (text: string): React.ReactNode[] => {
|
||||||
const parts = text.split(/(\*\*[^*]+\*\*|`[^`]+`|#[^\s#]+)/g).filter(Boolean);
|
// Use non-greedy .+? instead of [^*]+ to handle edge cases better
|
||||||
|
const parts = text.split(/(\*\*.+?\*\*|`[^`]+`|#[^\s#]+)/g).filter(Boolean);
|
||||||
return parts.map((part, idx) => {
|
return parts.map((part, idx) => {
|
||||||
if (part.startsWith('**') && part.endsWith('**')) {
|
if (part.startsWith('**') && part.endsWith('**')) {
|
||||||
return (
|
return (
|
||||||
<strong key={idx} className="text-white">
|
<strong key={idx} className="text-white font-semibold">
|
||||||
{part.slice(2, -2)}
|
{part.slice(2, -2)}
|
||||||
</strong>
|
</strong>
|
||||||
);
|
);
|
||||||
|
|
@ -245,12 +245,6 @@ const buildTargets = (sectionText: string, tags: string[]) => {
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const ArrowLeftIcon = () => (
|
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
||||||
<path d="M15 18l-6-6 6-6" />
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
|
|
||||||
const SparklesIcon = ({ className = '' }: { className?: string }) => (
|
const SparklesIcon = ({ className = '' }: { className?: string }) => (
|
||||||
<svg className={className} viewBox="0 0 24 24" fill="currentColor" aria-hidden>
|
<svg className={className} viewBox="0 0 24 24" fill="currentColor" aria-hidden>
|
||||||
<path d="M12 2l2.4 6.8L22 12l-7.6 3.2L12 22l-2.4-6.8L2 12l7.6-3.2L12 2z" />
|
<path d="M12 2l2.4 6.8L22 12l-7.6 3.2L12 22l-2.4-6.8L2 12l7.6-3.2L12 2z" />
|
||||||
|
|
@ -280,12 +274,6 @@ const UsersIcon = () => (
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
|
|
||||||
const CrownIcon = ({ className = '' }: { className?: string }) => (
|
|
||||||
<svg className={className} viewBox="0 0 24 24" fill="currentColor" aria-hidden>
|
|
||||||
<path d="M3 7l4 4 5-6 5 6 4-4v10H3z" />
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
|
|
||||||
const TrendingUpIcon = () => (
|
const TrendingUpIcon = () => (
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
<path d="M3 17l6-6 4 4 7-7" />
|
<path d="M3 17l6-6 4 4 7-7" />
|
||||||
|
|
@ -327,12 +315,11 @@ const AnalysisResultSection: React.FC<AnalysisResultSectionProps> = ({ onBack, o
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-brand-bg text-brand-text pb-24 selection:bg-brand-accent/30 font-sans">
|
<div className="min-h-screen bg-brand-bg text-brand-text pb-24 selection:bg-brand-accent/30 font-sans">
|
||||||
<div className="p-6">
|
<div className="asset-header">
|
||||||
<button
|
<button onClick={onBack} className="btn-back-new">
|
||||||
onClick={onBack}
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
className="flex items-center gap-2 px-4 py-2 rounded-full border border-brand-muted/30 text-brand-muted hover:text-brand-accent hover:border-brand-accent transition-all text-sm group"
|
<path d="M15 18l-6-6 6-6" />
|
||||||
>
|
</svg>
|
||||||
<ArrowLeftIcon />
|
|
||||||
<span>뒤로가기</span>
|
<span>뒤로가기</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -345,7 +332,7 @@ const AnalysisResultSection: React.FC<AnalysisResultSectionProps> = ({ onBack, o
|
||||||
</div>
|
</div>
|
||||||
<h1 className="text-4xl font-bold mb-3 tracking-tight text-white">브랜드 인텔리전스</h1>
|
<h1 className="text-4xl font-bold mb-3 tracking-tight text-white">브랜드 인텔리전스</h1>
|
||||||
<p className="text-brand-muted text-lg max-w-xl mx-auto">
|
<p className="text-brand-muted text-lg max-w-xl mx-auto">
|
||||||
<span className="text-brand-accent font-semibold">AI 데이터 분석</span>을 통해 도출된 브랜드 전략입니다.
|
<span className="text-brand-accent font-semibold">AI 데이터 분석</span>을 통해 도출된 {processed_info.customer_name}의 핵심 전략입니다.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -390,11 +377,11 @@ const AnalysisResultSection: React.FC<AnalysisResultSectionProps> = ({ onBack, o
|
||||||
<span className="block text-xs text-brand-muted mb-1 group-hover:text-brand-accent transition-colors">
|
<span className="block text-xs text-brand-muted mb-1 group-hover:text-brand-accent transition-colors">
|
||||||
카테고리 정의
|
카테고리 정의
|
||||||
</span>
|
</span>
|
||||||
<span className="font-bold text-lg text-white">{positioningCategory}</span>
|
<div className="font-bold text-lg text-white">{renderMarkdown(positioningCategory)}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-gradient-to-r from-brand-bg/50 to-brand-cardHover p-5 rounded-xl border border-brand-muted/20 border-l-4 border-l-brand-accent">
|
<div className="bg-gradient-to-r from-brand-bg/50 to-brand-cardHover p-5 rounded-xl border border-brand-muted/20 border-l-4 border-l-brand-accent">
|
||||||
<span className="block text-xs text-brand-accent mb-1 font-semibold">핵심 가치 (Core Value)</span>
|
<span className="block text-xs text-brand-accent mb-1 font-semibold">핵심 가치 (Core Value)</span>
|
||||||
<span className="font-semibold text-white">{positioningCore}</span>
|
<div className="font-semibold text-white">{renderMarkdown(positioningCore)}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -411,7 +398,7 @@ const AnalysisResultSection: React.FC<AnalysisResultSectionProps> = ({ onBack, o
|
||||||
>
|
>
|
||||||
<div className="min-w-[120px]">
|
<div className="min-w-[120px]">
|
||||||
<div className="font-bold text-white group-hover:text-brand-accent transition-colors">
|
<div className="font-bold text-white group-hover:text-brand-accent transition-colors">
|
||||||
{target.segment}
|
{renderInlineMarkdown(target.segment)}
|
||||||
</div>
|
</div>
|
||||||
{target.age && <div className="text-xs text-brand-muted">{target.age}</div>}
|
{target.age && <div className="text-xs text-brand-muted">{target.age}</div>}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -423,14 +410,14 @@ const AnalysisResultSection: React.FC<AnalysisResultSectionProps> = ({ onBack, o
|
||||||
key={i}
|
key={i}
|
||||||
className="text-[10px] px-2 py-0.5 bg-brand-accent/10 text-brand-accent rounded-sm font-medium"
|
className="text-[10px] px-2 py-0.5 bg-brand-accent/10 text-brand-accent rounded-sm font-medium"
|
||||||
>
|
>
|
||||||
{need}
|
{renderInlineMarkdown(need)}
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{target.triggers.length > 0 && (
|
{target.triggers.length > 0 && (
|
||||||
<p className="text-xs text-gray-400 border-t border-white/5 pt-2 mt-2">
|
<p className="text-xs text-gray-400 border-t border-white/5 pt-2 mt-2">
|
||||||
<span className="text-brand-muted">Trigger:</span> {target.triggers.join(', ')}
|
<span className="text-brand-muted">Trigger:</span> {target.triggers.map((t: string, i: number) => <span key={i}>{i > 0 && ', '}{renderInlineMarkdown(t)}</span>)}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -444,10 +431,6 @@ const AnalysisResultSection: React.FC<AnalysisResultSectionProps> = ({ onBack, o
|
||||||
<div className="bg-brand-card rounded-3xl p-8 border border-brand-muted/10 min-h-[500px] flex flex-col relative overflow-hidden">
|
<div className="bg-brand-card rounded-3xl p-8 border border-brand-muted/10 min-h-[500px] flex flex-col relative overflow-hidden">
|
||||||
<div className="flex justify-between items-center mb-2 z-10">
|
<div className="flex justify-between items-center mb-2 z-10">
|
||||||
<h3 className="text-xl font-bold text-white">주요 셀링 포인트 (USP)</h3>
|
<h3 className="text-xl font-bold text-white">주요 셀링 포인트 (USP)</h3>
|
||||||
<div className="flex items-center gap-1 text-xs text-brand-accent bg-brand-accent/10 px-2 py-1 rounded">
|
|
||||||
<TrendingUpIcon />
|
|
||||||
<span>AI Data Analysis</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 flex items-center justify-center relative z-10 -my-4">
|
<div className="flex-1 flex items-center justify-center relative z-10 -my-4">
|
||||||
|
|
@ -455,51 +438,56 @@ const AnalysisResultSection: React.FC<AnalysisResultSectionProps> = ({ onBack, o
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{topUSP && (
|
{topUSP && (
|
||||||
<div className="mt-2 mb-6 p-4 rounded-xl bg-gradient-to-r from-brand-accent/10 to-transparent border border-brand-accent/20 relative overflow-hidden">
|
<div className="mt-2 mb-6 p-4 rounded-xl bg-brand-card border border-brand-muted/20 relative overflow-hidden">
|
||||||
<div className="absolute top-0 right-0 p-2 opacity-10">
|
<div className="relative z-10 flex justify-between items-center">
|
||||||
<CrownIcon className="w-16 h-16 text-brand-accent" />
|
<div>
|
||||||
</div>
|
<div className="flex items-center gap-2 mb-2">
|
||||||
<div className="relative z-10">
|
<span className="text-xs text-brand-accent font-bold uppercase tracking-wider">{topUSP.subLabel}</span>
|
||||||
<div className="text-xs text-brand-accent font-bold uppercase tracking-wider mb-1 flex items-center gap-1">
|
<span className="text-xs bg-brand-muted/30 text-white px-2 py-0.5 rounded font-semibold">CORE</span>
|
||||||
<CrownIcon className="w-3 h-3" /> Core Competitiveness
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between items-end">
|
|
||||||
<div>
|
|
||||||
<div className="text-lg font-bold text-white">{topUSP.label}</div>
|
|
||||||
<div className="text-xs text-brand-accent/80 font-mono tracking-wider">{topUSP.subLabel}</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div className="text-lg font-bold text-white mb-1">{topUSP.label}</div>
|
||||||
|
<div className="text-sm text-brand-muted">{topUSP.description}</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="text-4xl font-bold text-brand-muted">{topUSP.score}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-3 z-10">
|
<div className="space-y-3 z-10">
|
||||||
{usps
|
{usps
|
||||||
.filter((usp) => usp.label !== topUSP?.label)
|
.filter((usp) => usp.label !== topUSP?.label)
|
||||||
.slice(0, 4)
|
|
||||||
.map((usp, idx) => (
|
.map((usp, idx) => (
|
||||||
<div
|
<div
|
||||||
key={idx}
|
key={idx}
|
||||||
className="p-3 rounded-xl bg-brand-bg/40 border border-white/5 hover:bg-brand-bg/60 transition-colors"
|
className="p-4 rounded-xl bg-brand-bg/40 border border-white/5 hover:bg-brand-bg/60 transition-colors flex justify-between items-center"
|
||||||
>
|
>
|
||||||
<div className="flex justify-between items-start mb-1">
|
<div>
|
||||||
<div className="text-xs text-brand-muted font-bold uppercase tracking-tight">{usp.subLabel}</div>
|
<div className="text-xs text-brand-muted font-bold uppercase tracking-tight mb-1">{usp.subLabel}</div>
|
||||||
|
<div className="text-base font-bold text-white mb-1">{usp.label}</div>
|
||||||
|
<div className="text-sm text-gray-400">{usp.description}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm font-bold text-white mb-1">{usp.label}</div>
|
<div className="text-3xl font-bold text-brand-muted ml-4">{usp.score}</div>
|
||||||
<div className="text-xs text-gray-400 leading-tight truncate">{usp.description}</div>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-brand-card rounded-3xl p-8 border border-brand-muted/10 relative overflow-hidden">
|
</div>
|
||||||
<h3 className="text-xl font-bold mb-6 text-center text-white">추천 타겟 키워드</h3>
|
</div>
|
||||||
<div className="flex flex-wrap justify-center gap-3 relative z-10">
|
|
||||||
{tags.length === 0 && <span className="text-sm text-brand-muted">정보 없음</span>}
|
<div className="max-w-7xl mx-auto px-4 md:px-8 mt-8">
|
||||||
{tags.map((keyword, idx) => (
|
<div className="bg-brand-card rounded-3xl p-8 border border-brand-muted/10 relative overflow-hidden">
|
||||||
<KeywordBubble key={idx} text={keyword} />
|
<h3 className="text-xl font-bold mb-6 text-white">추천 타겟 키워드</h3>
|
||||||
))}
|
<div className="flex flex-wrap gap-3 relative z-10">
|
||||||
</div>
|
{tags.length === 0 && <span className="text-sm text-brand-muted">정보 없음</span>}
|
||||||
|
{tags.map((keyword, idx) => (
|
||||||
|
<span
|
||||||
|
key={idx}
|
||||||
|
className="px-5 py-2.5 rounded-full bg-brand-card border border-brand-muted/30 text-white text-sm hover:border-brand-accent/50 transition-colors"
|
||||||
|
>
|
||||||
|
# {keyword}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
|
|
||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { getVideosList } from '../../utils/api';
|
import { getVideosList, deleteVideo } from '../../utils/api';
|
||||||
import { VideoListItem } from '../../types/api';
|
import { VideoListItem } from '../../types/api';
|
||||||
|
|
||||||
interface ADO2ContentsPageProps {
|
interface ADO2ContentsPageProps {
|
||||||
|
|
@ -82,8 +82,14 @@ const ADO2ContentsPage: React.FC<ADO2ContentsPageProps> = ({ onBack }) => {
|
||||||
|
|
||||||
const handleDelete = async (taskId: string) => {
|
const handleDelete = async (taskId: string) => {
|
||||||
if (!confirm('이 콘텐츠를 삭제하시겠습니까?')) return;
|
if (!confirm('이 콘텐츠를 삭제하시겠습니까?')) return;
|
||||||
// TODO: 삭제 API 연동
|
try {
|
||||||
alert('삭제 기능은 아직 구현되지 않았습니다.');
|
await deleteVideo(taskId);
|
||||||
|
// 삭제 성공 후 목록 새로고침
|
||||||
|
fetchVideos();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Delete failed:', err);
|
||||||
|
alert('삭제에 실패했습니다.');
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -1,54 +1,190 @@
|
||||||
|
|
||||||
import React, { useState } from 'react';
|
import React, { useState, useRef, useCallback } from 'react';
|
||||||
|
import { searchNaverLocal, NaverLocalSearchItem, AutocompleteRequest } from '../../utils/api';
|
||||||
|
|
||||||
|
type SearchType = 'url' | 'name';
|
||||||
|
|
||||||
interface UrlInputContentProps {
|
interface UrlInputContentProps {
|
||||||
onAnalyze: (url: string) => void;
|
onAnalyze: (value: string, type?: SearchType) => void;
|
||||||
|
onAutocomplete?: (data: AutocompleteRequest) => void;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const UrlInputContent: React.FC<UrlInputContentProps> = ({ onAnalyze, error }) => {
|
const UrlInputContent: React.FC<UrlInputContentProps> = ({ onAnalyze, onAutocomplete, error }) => {
|
||||||
const [url, setUrl] = useState('');
|
const [inputValue, setInputValue] = useState('');
|
||||||
|
const [searchType, setSearchType] = useState<SearchType>('url');
|
||||||
|
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
|
||||||
|
const [autocompleteResults, setAutocompleteResults] = useState<NaverLocalSearchItem[]>([]);
|
||||||
|
const [isAutocompleteLoading, setIsAutocompleteLoading] = useState(false);
|
||||||
|
const [showAutocomplete, setShowAutocomplete] = useState(false);
|
||||||
|
const debounceRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
const autocompleteRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const handleSubmit = (e: React.FormEvent) => {
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (url.trim()) {
|
if (inputValue.trim()) {
|
||||||
onAnalyze(url.trim());
|
onAnalyze(inputValue.trim(), searchType);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const searchTypeOptions = [
|
||||||
|
{ value: 'url' as SearchType, label: 'URL' },
|
||||||
|
{ value: 'name' as SearchType, label: '업체명' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const getPlaceholder = () => {
|
||||||
|
return searchType === 'url'
|
||||||
|
? 'https://www.castad.com'
|
||||||
|
: '업체명을 입력하세요';
|
||||||
|
};
|
||||||
|
|
||||||
|
const getGuideText = () => {
|
||||||
|
return searchType === 'url'
|
||||||
|
? 'URL에서 가져온 정보로 영상이 자동 생성됩니다.'
|
||||||
|
: '업체명으로 검색하여 정보를 가져옵니다.';
|
||||||
|
};
|
||||||
|
|
||||||
|
// 업체명 검색 시 자동완성 (디바운스 적용)
|
||||||
|
const handleAutocompleteSearch = useCallback(async (query: string) => {
|
||||||
|
if (!query.trim() || searchType !== 'name') {
|
||||||
|
setAutocompleteResults([]);
|
||||||
|
setShowAutocomplete(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsAutocompleteLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await searchNaverLocal(query);
|
||||||
|
setAutocompleteResults(response.items || []);
|
||||||
|
setShowAutocomplete(response.items && response.items.length > 0);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('자동완성 검색 오류:', error);
|
||||||
|
setAutocompleteResults([]);
|
||||||
|
setShowAutocomplete(false);
|
||||||
|
} finally {
|
||||||
|
setIsAutocompleteLoading(false);
|
||||||
|
}
|
||||||
|
}, [searchType]);
|
||||||
|
|
||||||
|
// 자동완성 항목 선택
|
||||||
|
const handleSelectAutocomplete = (item: NaverLocalSearchItem) => {
|
||||||
|
const request: AutocompleteRequest = {
|
||||||
|
address: item.address,
|
||||||
|
roadAddress: item.roadAddress,
|
||||||
|
title: item.title,
|
||||||
|
};
|
||||||
|
|
||||||
|
setInputValue(item.title.replace(/<[^>]*>/g, '')); // HTML 태그 제거
|
||||||
|
setShowAutocomplete(false);
|
||||||
|
setAutocompleteResults([]);
|
||||||
|
|
||||||
|
if (onAutocomplete) {
|
||||||
|
onAutocomplete(request);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="url-input-container">
|
<div className="url-input-container">
|
||||||
<div className="url-input-content">
|
<div className="url-input-content">
|
||||||
{/* 아이콘 */}
|
{/* 로고 */}
|
||||||
<div className="url-input-icon">
|
<div className="url-input-logo">
|
||||||
<svg width="40" height="40" viewBox="0 0 24 24" fill="currentColor">
|
<img src="/assets/images/ado2-logo.svg" alt="ADO2" />
|
||||||
<path d="M12 2l2.4 7.2L22 12l-7.6 2.4L12 22l-2.4-7.2L2 12l7.6-2.4z" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 제목 */}
|
|
||||||
<h1 className="url-input-title">브랜드 분석</h1>
|
|
||||||
<p className="url-input-subtitle">
|
|
||||||
쉽고 빠르게, 브랜드 소셜 미디어 캠페인을 만드세요.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{/* URL 입력 폼 */}
|
{/* URL 입력 폼 */}
|
||||||
<form onSubmit={handleSubmit} className="url-input-form">
|
<form onSubmit={handleSubmit} className="url-input-form">
|
||||||
<div className="url-input-wrapper">
|
<div className="url-input-wrapper">
|
||||||
<input
|
{/* 드롭다운 */}
|
||||||
type="url"
|
<div className="url-input-dropdown-container">
|
||||||
value={url}
|
<button
|
||||||
onChange={(e) => setUrl(e.target.value)}
|
type="button"
|
||||||
placeholder="네이버 지도 URL을 입력하세요"
|
className="url-input-dropdown-trigger"
|
||||||
className="url-input-field"
|
onClick={() => setIsDropdownOpen(!isDropdownOpen)}
|
||||||
/>
|
>
|
||||||
<button
|
<span>{searchTypeOptions.find(opt => opt.value === searchType)?.label}</span>
|
||||||
type="submit"
|
<svg
|
||||||
disabled={!url.trim()}
|
width="12"
|
||||||
className="url-input-button"
|
height="12"
|
||||||
>
|
viewBox="0 0 24 24"
|
||||||
분석 시작
|
fill="none"
|
||||||
</button>
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
className={`url-input-dropdown-arrow ${isDropdownOpen ? 'open' : ''}`}
|
||||||
|
>
|
||||||
|
<path d="M6 9l6 6 6-6" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
{isDropdownOpen && (
|
||||||
|
<div className="url-input-dropdown-menu">
|
||||||
|
{searchTypeOptions.map((option) => (
|
||||||
|
<button
|
||||||
|
key={option.value}
|
||||||
|
type="button"
|
||||||
|
className={`url-input-dropdown-item ${searchType === option.value ? 'active' : ''}`}
|
||||||
|
onClick={() => {
|
||||||
|
setSearchType(option.value);
|
||||||
|
setIsDropdownOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 입력 필드 */}
|
||||||
|
<div className="url-input-field-container" ref={autocompleteRef}>
|
||||||
|
<input
|
||||||
|
type={searchType === 'url' ? 'url' : 'text'}
|
||||||
|
value={inputValue}
|
||||||
|
onChange={(e) => {
|
||||||
|
const value = e.target.value;
|
||||||
|
setInputValue(value);
|
||||||
|
|
||||||
|
// 업체명 검색일 때 자동완성 검색 (디바운스)
|
||||||
|
if (searchType === 'name') {
|
||||||
|
if (debounceRef.current) {
|
||||||
|
clearTimeout(debounceRef.current);
|
||||||
|
}
|
||||||
|
debounceRef.current = setTimeout(() => {
|
||||||
|
handleAutocompleteSearch(value);
|
||||||
|
}, 300);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onFocus={() => {
|
||||||
|
if (searchType === 'name' && autocompleteResults.length > 0) {
|
||||||
|
setShowAutocomplete(true);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder={getPlaceholder()}
|
||||||
|
className="url-input-field"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 자동완성 결과 */}
|
||||||
|
{showAutocomplete && searchType === 'name' && (
|
||||||
|
<div className="url-input-autocomplete-dropdown">
|
||||||
|
{isAutocompleteLoading ? (
|
||||||
|
<div className="url-input-autocomplete-loading">검색 중...</div>
|
||||||
|
) : (
|
||||||
|
autocompleteResults.map((item, index) => (
|
||||||
|
<button
|
||||||
|
key={index}
|
||||||
|
type="button"
|
||||||
|
className="url-input-autocomplete-item"
|
||||||
|
onMouseDown={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
handleSelectAutocomplete(item);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="url-input-autocomplete-title" dangerouslySetInnerHTML={{ __html: item.title }} />
|
||||||
|
<div className="url-input-autocomplete-address">{item.roadAddress || item.address}</div>
|
||||||
|
</button>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 에러 메시지 */}
|
{/* 에러 메시지 */}
|
||||||
|
|
@ -59,7 +195,7 @@ const UrlInputContent: React.FC<UrlInputContentProps> = ({ onAnalyze, error }) =
|
||||||
|
|
||||||
{/* 안내 텍스트 */}
|
{/* 안내 텍스트 */}
|
||||||
<p className="url-input-guide">
|
<p className="url-input-guide">
|
||||||
네이버 지도에서 업체 URL을 복사하여 붙여넣기 해주세요.
|
{getGuideText()}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,12 @@
|
||||||
|
|
||||||
import React, { useState, useEffect, useRef } from 'react';
|
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
|
import { searchNaverLocal, NaverLocalSearchItem, AutocompleteRequest } from '../../utils/api';
|
||||||
|
|
||||||
|
type SearchType = 'url' | 'name';
|
||||||
|
|
||||||
interface HeroSectionProps {
|
interface HeroSectionProps {
|
||||||
onAnalyze?: (url: string) => void;
|
onAnalyze?: (value: string, type?: SearchType) => void;
|
||||||
|
onAutocomplete?: (data: AutocompleteRequest) => void;
|
||||||
onNext?: () => void;
|
onNext?: () => void;
|
||||||
error?: string | null;
|
error?: string | null;
|
||||||
scrollProgress?: number; // 0 ~ 1 (스크롤 진행률)
|
scrollProgress?: number; // 0 ~ 1 (스크롤 진행률)
|
||||||
|
|
@ -47,12 +51,78 @@ const orbConfigs: OrbConfig[] = [
|
||||||
{ id: 'orb-6', size: 450, initialX: 65, initialY: 70, color: 'radial-gradient(circle, rgba(180, 255, 235, 0.95) 15%, rgba(200, 160, 255, 0.8) 50%, rgba(94, 235, 195, 0.45) 100%)', minX: 45, maxX: 110, minY: 55, maxY: 110 },
|
{ id: 'orb-6', size: 450, initialX: 65, initialY: 70, color: 'radial-gradient(circle, rgba(180, 255, 235, 0.95) 15%, rgba(200, 160, 255, 0.8) 50%, rgba(94, 235, 195, 0.45) 100%)', minX: 45, maxX: 110, minY: 55, maxY: 110 },
|
||||||
];
|
];
|
||||||
|
|
||||||
const HeroSection: React.FC<HeroSectionProps> = ({ onAnalyze, onNext, error: externalError, scrollProgress = 0 }) => {
|
const HeroSection: React.FC<HeroSectionProps> = ({ onAnalyze, onAutocomplete, onNext, error: externalError, scrollProgress = 0 }) => {
|
||||||
const [url, setUrl] = useState('');
|
const [inputValue, setInputValue] = useState('');
|
||||||
|
const [searchType, setSearchType] = useState<SearchType>('url');
|
||||||
|
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
|
||||||
const [localError, setLocalError] = useState('');
|
const [localError, setLocalError] = useState('');
|
||||||
const [isFocused, setIsFocused] = useState(false);
|
const [isFocused, setIsFocused] = useState(false);
|
||||||
|
const [autocompleteResults, setAutocompleteResults] = useState<NaverLocalSearchItem[]>([]);
|
||||||
|
const [isAutocompleteLoading, setIsAutocompleteLoading] = useState(false);
|
||||||
|
const [showAutocomplete, setShowAutocomplete] = useState(false);
|
||||||
const orbRefs = useRef<(HTMLDivElement | null)[]>([]);
|
const orbRefs = useRef<(HTMLDivElement | null)[]>([]);
|
||||||
const animationRefs = useRef<number[]>([]);
|
const animationRefs = useRef<number[]>([]);
|
||||||
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||||
|
const autocompleteRef = useRef<HTMLDivElement>(null);
|
||||||
|
const debounceRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
|
||||||
|
const searchTypeOptions = [
|
||||||
|
{ value: 'url' as SearchType, label: 'URL' },
|
||||||
|
{ value: 'name' as SearchType, label: '업체명' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// 드롭다운 외부 클릭 감지
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
|
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
||||||
|
setIsDropdownOpen(false);
|
||||||
|
}
|
||||||
|
if (autocompleteRef.current && !autocompleteRef.current.contains(event.target as Node)) {
|
||||||
|
setShowAutocomplete(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 업체명 검색 시 자동완성 (디바운스 적용)
|
||||||
|
const handleAutocompleteSearch = useCallback(async (query: string) => {
|
||||||
|
if (!query.trim() || searchType !== 'name') {
|
||||||
|
setAutocompleteResults([]);
|
||||||
|
setShowAutocomplete(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsAutocompleteLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await searchNaverLocal(query);
|
||||||
|
setAutocompleteResults(response.items || []);
|
||||||
|
setShowAutocomplete(response.items && response.items.length > 0);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('자동완성 검색 오류:', error);
|
||||||
|
setAutocompleteResults([]);
|
||||||
|
setShowAutocomplete(false);
|
||||||
|
} finally {
|
||||||
|
setIsAutocompleteLoading(false);
|
||||||
|
}
|
||||||
|
}, [searchType]);
|
||||||
|
|
||||||
|
// 자동완성 항목 선택
|
||||||
|
const handleSelectAutocomplete = async (item: NaverLocalSearchItem) => {
|
||||||
|
const request: AutocompleteRequest = {
|
||||||
|
address: item.address,
|
||||||
|
roadAddress: item.roadAddress,
|
||||||
|
title: item.title,
|
||||||
|
};
|
||||||
|
|
||||||
|
setInputValue(item.title.replace(/<[^>]*>/g, '')); // HTML 태그 제거
|
||||||
|
setShowAutocomplete(false);
|
||||||
|
setAutocompleteResults([]);
|
||||||
|
|
||||||
|
if (onAutocomplete) {
|
||||||
|
onAutocomplete(request);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Random movement for orbs
|
// Random movement for orbs
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -132,20 +202,32 @@ const HeroSection: React.FC<HeroSectionProps> = ({ onAnalyze, onNext, error: ext
|
||||||
|
|
||||||
const error = externalError || localError;
|
const error = externalError || localError;
|
||||||
|
|
||||||
|
const getPlaceholder = () => {
|
||||||
|
return searchType === 'url'
|
||||||
|
? 'https://www.castad.com'
|
||||||
|
: '업체명을 입력하세요';
|
||||||
|
};
|
||||||
|
|
||||||
|
const getGuideText = () => {
|
||||||
|
return searchType === 'url'
|
||||||
|
? 'URL에서 가져온 정보로 영상이 자동 생성됩니다.'
|
||||||
|
: '업체명으로 검색하여 정보를 가져옵니다.';
|
||||||
|
};
|
||||||
|
|
||||||
const handleStart = () => {
|
const handleStart = () => {
|
||||||
if (!url.trim()) {
|
if (!inputValue.trim()) {
|
||||||
setLocalError('URL을 입력해주세요.');
|
setLocalError(searchType === 'url' ? 'URL을 입력해주세요.' : '업체명을 입력해주세요.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isValidUrl(url.trim())) {
|
if (searchType === 'url' && !isValidUrl(inputValue.trim())) {
|
||||||
setLocalError('올바른 URL 형식이 아닙니다. (예: https://example.com)');
|
setLocalError('올바른 URL 형식이 아닙니다. (예: https://example.com)');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setLocalError('');
|
setLocalError('');
|
||||||
if (onAnalyze) {
|
if (onAnalyze) {
|
||||||
onAnalyze(url);
|
onAnalyze(inputValue, searchType);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -183,34 +265,116 @@ const HeroSection: React.FC<HeroSectionProps> = ({ onAnalyze, onNext, error: ext
|
||||||
|
|
||||||
{/* Input Form */}
|
{/* Input Form */}
|
||||||
<div className="hero-form">
|
<div className="hero-form">
|
||||||
<span className="hero-input-label">URL 입력</span>
|
<div className={`hero-input-wrapper ${isFocused ? 'focused' : ''} ${error ? 'error' : ''} ${inputValue && !isFocused ? 'filled' : ''}`}>
|
||||||
<div className={`hero-input-wrapper ${isFocused ? 'focused' : ''} ${error ? 'error' : ''} ${url && !isFocused ? 'filled' : ''}`}>
|
{/* 드롭다운 */}
|
||||||
<input
|
<div className="hero-dropdown-container" ref={dropdownRef}>
|
||||||
type="text"
|
<button
|
||||||
value={url}
|
type="button"
|
||||||
onChange={(e) => {
|
className="hero-dropdown-trigger"
|
||||||
setUrl(e.target.value);
|
onClick={() => setIsDropdownOpen(!isDropdownOpen)}
|
||||||
if (localError) setLocalError('');
|
>
|
||||||
}}
|
<span>{searchTypeOptions.find(opt => opt.value === searchType)?.label}</span>
|
||||||
onFocus={() => setIsFocused(true)}
|
<svg
|
||||||
onBlur={() => setIsFocused(false)}
|
width="12"
|
||||||
placeholder="https://www.castad.com"
|
height="12"
|
||||||
className={`hero-input ${url ? 'has-value' : ''}`}
|
viewBox="0 0 24 24"
|
||||||
/>
|
fill="none"
|
||||||
{url && (
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
className={`hero-dropdown-arrow ${isDropdownOpen ? 'open' : ''}`}
|
||||||
|
>
|
||||||
|
<path d="M6 9l6 6 6-6" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
{isDropdownOpen && (
|
||||||
|
<div className="hero-dropdown-menu">
|
||||||
|
{searchTypeOptions.map((option) => (
|
||||||
|
<button
|
||||||
|
key={option.value}
|
||||||
|
type="button"
|
||||||
|
className={`hero-dropdown-item ${searchType === option.value ? 'active' : ''}`}
|
||||||
|
onClick={() => {
|
||||||
|
setSearchType(option.value);
|
||||||
|
setIsDropdownOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="hero-input-container" ref={autocompleteRef}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={inputValue}
|
||||||
|
onChange={(e) => {
|
||||||
|
const value = e.target.value;
|
||||||
|
setInputValue(value);
|
||||||
|
if (localError) setLocalError('');
|
||||||
|
|
||||||
|
// 업체명 검색일 때 자동완성 검색 (디바운스)
|
||||||
|
if (searchType === 'name') {
|
||||||
|
if (debounceRef.current) {
|
||||||
|
clearTimeout(debounceRef.current);
|
||||||
|
}
|
||||||
|
debounceRef.current = setTimeout(() => {
|
||||||
|
handleAutocompleteSearch(value);
|
||||||
|
}, 300);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onFocus={() => {
|
||||||
|
setIsFocused(true);
|
||||||
|
if (searchType === 'name' && autocompleteResults.length > 0) {
|
||||||
|
setShowAutocomplete(true);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onBlur={() => setIsFocused(false)}
|
||||||
|
placeholder={getPlaceholder()}
|
||||||
|
className={`hero-input ${inputValue ? 'has-value' : ''}`}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 자동완성 결과 */}
|
||||||
|
{showAutocomplete && searchType === 'name' && (
|
||||||
|
<div className="hero-autocomplete-dropdown">
|
||||||
|
{isAutocompleteLoading ? (
|
||||||
|
<div className="hero-autocomplete-loading">검색 중...</div>
|
||||||
|
) : (
|
||||||
|
autocompleteResults.map((item, index) => (
|
||||||
|
<button
|
||||||
|
key={index}
|
||||||
|
type="button"
|
||||||
|
className="hero-autocomplete-item"
|
||||||
|
onMouseDown={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
handleSelectAutocomplete(item);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="hero-autocomplete-title" dangerouslySetInnerHTML={{ __html: item.title }} />
|
||||||
|
<div className="hero-autocomplete-address">{item.roadAddress || item.address}</div>
|
||||||
|
</button>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{inputValue && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="hero-input-clear"
|
className="hero-input-clear"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setUrl('');
|
setInputValue('');
|
||||||
setLocalError('');
|
setLocalError('');
|
||||||
|
setAutocompleteResults([]);
|
||||||
|
setShowAutocomplete(false);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<img src="/assets/images/input-clear-icon.svg" alt="Clear" />
|
<img src="/assets/images/input-clear-icon.svg" alt="Clear" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<span className="hero-input-hint">URL에서 가져온 정보로 영상이 자동 생성됩니다.</span>
|
<span className="hero-input-hint">{getGuideText()}</span>
|
||||||
{error && (
|
{error && (
|
||||||
<p className="hero-error">{error}</p>
|
<p className="hero-error">{error}</p>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
168
src/utils/api.ts
168
src/utils/api.ts
|
|
@ -21,6 +21,8 @@ import {
|
||||||
} from '../types/api';
|
} from '../types/api';
|
||||||
|
|
||||||
const API_URL = import.meta.env.VITE_API_URL || 'http://40.82.133.44';
|
const API_URL = import.meta.env.VITE_API_URL || 'http://40.82.133.44';
|
||||||
|
console.log('[API] API_URL:', API_URL);
|
||||||
|
console.log('[API] VITE_API_URL env:', import.meta.env.VITE_API_URL);
|
||||||
|
|
||||||
// 크롤링 타임아웃: 5분
|
// 크롤링 타임아웃: 5분
|
||||||
const CRAWL_TIMEOUT = 5 * 60 * 1000;
|
const CRAWL_TIMEOUT = 5 * 60 * 1000;
|
||||||
|
|
@ -61,6 +63,7 @@ export async function generateLyric(request: LyricGenerateRequest): Promise<Lyri
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
|
...getAuthHeader(),
|
||||||
},
|
},
|
||||||
body: JSON.stringify(request),
|
body: JSON.stringify(request),
|
||||||
});
|
});
|
||||||
|
|
@ -76,6 +79,9 @@ export async function generateLyric(request: LyricGenerateRequest): Promise<Lyri
|
||||||
export async function getLyricStatus(taskId: string): Promise<LyricStatusResponse> {
|
export async function getLyricStatus(taskId: string): Promise<LyricStatusResponse> {
|
||||||
const response = await fetch(`${API_URL}/lyric/status/${taskId}`, {
|
const response = await fetch(`${API_URL}/lyric/status/${taskId}`, {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
...getAuthHeader(),
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
|
@ -89,6 +95,9 @@ export async function getLyricStatus(taskId: string): Promise<LyricStatusRespons
|
||||||
export async function getLyricDetail(taskId: string): Promise<LyricDetailResponse> {
|
export async function getLyricDetail(taskId: string): Promise<LyricDetailResponse> {
|
||||||
const response = await fetch(`${API_URL}/lyric/${taskId}`, {
|
const response = await fetch(`${API_URL}/lyric/${taskId}`, {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
...getAuthHeader(),
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
|
@ -144,6 +153,7 @@ export async function generateSong(taskId: string, request: SongGenerateRequest)
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
|
...getAuthHeader(),
|
||||||
},
|
},
|
||||||
body: JSON.stringify(request),
|
body: JSON.stringify(request),
|
||||||
});
|
});
|
||||||
|
|
@ -159,6 +169,9 @@ export async function generateSong(taskId: string, request: SongGenerateRequest)
|
||||||
export async function getSongStatus(songId: string): Promise<SongStatusResponse> {
|
export async function getSongStatus(songId: string): Promise<SongStatusResponse> {
|
||||||
const response = await fetch(`${API_URL}/song/status/${songId}`, {
|
const response = await fetch(`${API_URL}/song/status/${songId}`, {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
...getAuthHeader(),
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
|
@ -172,6 +185,9 @@ export async function getSongStatus(songId: string): Promise<SongStatusResponse>
|
||||||
export async function downloadSong(taskId: string): Promise<SongDownloadResponse> {
|
export async function downloadSong(taskId: string): Promise<SongDownloadResponse> {
|
||||||
const response = await fetch(`${API_URL}/song/download/${taskId}`, {
|
const response = await fetch(`${API_URL}/song/download/${taskId}`, {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
...getAuthHeader(),
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
|
@ -234,6 +250,9 @@ export async function waitForSongComplete(
|
||||||
export async function generateVideo(taskId: string, orientation: 'vertical' | 'horizontal' = 'vertical'): Promise<VideoGenerateResponse> {
|
export async function generateVideo(taskId: string, orientation: 'vertical' | 'horizontal' = 'vertical'): Promise<VideoGenerateResponse> {
|
||||||
const response = await fetch(`${API_URL}/video/generate/${taskId}?orientation=${orientation}`, {
|
const response = await fetch(`${API_URL}/video/generate/${taskId}?orientation=${orientation}`, {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
...getAuthHeader(),
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
|
@ -247,6 +266,9 @@ export async function generateVideo(taskId: string, orientation: 'vertical' | 'h
|
||||||
export async function getVideoStatus(taskId: string): Promise<VideoStatusResponse> {
|
export async function getVideoStatus(taskId: string): Promise<VideoStatusResponse> {
|
||||||
const response = await fetch(`${API_URL}/video/status/${taskId}`, {
|
const response = await fetch(`${API_URL}/video/status/${taskId}`, {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
...getAuthHeader(),
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
|
@ -257,9 +279,28 @@ export async function getVideoStatus(taskId: string): Promise<VideoStatusRespons
|
||||||
}
|
}
|
||||||
|
|
||||||
// 영상 다운로드(결과 조회) API
|
// 영상 다운로드(결과 조회) API
|
||||||
export async function downloadVideo(taskId: string): Promise<VideoDownloadResponse> {
|
// export async function downloadVideo(taskId: string): Promise<VideoDownloadResponse> {
|
||||||
const response = await fetch(`${API_URL}/video/download/${taskId}`, {
|
// const response = await fetch(`${API_URL}/video/download/${taskId}`, {
|
||||||
|
// method: 'GET',
|
||||||
|
// headers: {
|
||||||
|
// ...getAuthHeader(),
|
||||||
|
// },
|
||||||
|
// });
|
||||||
|
|
||||||
|
// if (!response.ok) {
|
||||||
|
// throw new Error(`HTTP error! status: ${response.status}`);
|
||||||
|
// }
|
||||||
|
|
||||||
|
// return response.json();
|
||||||
|
// }
|
||||||
|
|
||||||
|
// 비디오 목록 조회 API
|
||||||
|
export async function getVideosList(page: number = 1, pageSize: number = 10): Promise<VideosListResponse> {
|
||||||
|
const response = await fetch(`${API_URL}/archive/videos/?page=${page}&page_size=${pageSize}`, {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
...getAuthHeader(),
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
|
@ -269,17 +310,18 @@ export async function downloadVideo(taskId: string): Promise<VideoDownloadRespon
|
||||||
return response.json();
|
return response.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
// 비디오 목록 조회 API
|
// 비디오 삭제 API
|
||||||
export async function getVideosList(page: number = 1, pageSize: number = 10): Promise<VideosListResponse> {
|
export async function deleteVideo(taskId: string): Promise<void> {
|
||||||
const response = await fetch(`${API_URL}/videos/?page=${page}&page_size=${pageSize}`, {
|
const response = await fetch(`${API_URL}/archive/videos/delete/${taskId}`, {
|
||||||
method: 'GET',
|
method: 'DELETE',
|
||||||
|
headers: {
|
||||||
|
...getAuthHeader(),
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`HTTP error! status: ${response.status}`);
|
throw new Error(`HTTP error! status: ${response.status}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return response.json();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 이미지 업로드 API (multipart/form-data)
|
// 이미지 업로드 API (multipart/form-data)
|
||||||
|
|
@ -308,6 +350,9 @@ export async function uploadImages(
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${API_URL}/image/upload/blob`, {
|
const response = await fetch(`${API_URL}/image/upload/blob`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
...getAuthHeader(),
|
||||||
|
},
|
||||||
body: formData,
|
body: formData,
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
});
|
});
|
||||||
|
|
@ -401,6 +446,7 @@ export function clearTokens() {
|
||||||
// 인증 헤더 생성
|
// 인증 헤더 생성
|
||||||
function getAuthHeader(): HeadersInit {
|
function getAuthHeader(): HeadersInit {
|
||||||
const token = getAccessToken();
|
const token = getAccessToken();
|
||||||
|
console.log('[Auth] Token exists:', !!token, token ? `${token.substring(0, 20)}...` : 'null');
|
||||||
return token ? { 'Authorization': `Bearer ${token}` } : {};
|
return token ? { 'Authorization': `Bearer ${token}` } : {};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -418,20 +464,39 @@ export async function getKakaoLoginUrl(): Promise<KakaoLoginUrlResponse> {
|
||||||
}
|
}
|
||||||
|
|
||||||
// 카카오 콜백 처리 (인가 코드로 JWT 토큰 발급)
|
// 카카오 콜백 처리 (인가 코드로 JWT 토큰 발급)
|
||||||
|
// 1. callback 호출 후 2. verify로 토큰 발급
|
||||||
export async function kakaoCallback(code: string): Promise<KakaoCallbackResponse> {
|
export async function kakaoCallback(code: string): Promise<KakaoCallbackResponse> {
|
||||||
const response = await fetch(`${API_URL}/user/auth/kakao/callback?code=${encodeURIComponent(code)}`, {
|
// 1단계: 콜백 처리
|
||||||
|
const callbackResponse = await fetch(`${API_URL}/user/auth/kakao/callback?code=${encodeURIComponent(code)}`, {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!callbackResponse.ok) {
|
||||||
throw new Error(`HTTP error! status: ${response.status}`);
|
throw new Error(`Callback HTTP error! status: ${callbackResponse.status}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const data: KakaoCallbackResponse = await response.json();
|
// 2단계: 코드 검증 및 토큰 발급
|
||||||
|
const verifyResponse = await fetch(`${API_URL}/user/auth/kakao/verify`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ code }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!verifyResponse.ok) {
|
||||||
|
throw new Error(`Verify HTTP error! status: ${verifyResponse.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data: KakaoCallbackResponse = await verifyResponse.json();
|
||||||
|
console.log('[Auth] Kakao verify response:', {
|
||||||
|
hasAccessToken: !!data.access_token,
|
||||||
|
hasRefreshToken: !!data.refresh_token
|
||||||
|
});
|
||||||
|
|
||||||
// 토큰 저장
|
// 토큰 저장
|
||||||
saveTokens(data.access_token, data.refresh_token);
|
saveTokens(data.access_token, data.refresh_token);
|
||||||
|
console.log('[Auth] Tokens saved to localStorage');
|
||||||
|
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
@ -525,3 +590,80 @@ export async function getUserMe(): Promise<UserMeResponse> {
|
||||||
export function isLoggedIn(): boolean {
|
export function isLoggedIn(): boolean {
|
||||||
return !!getAccessToken();
|
return !!getAccessToken();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 네이버 지역 검색 & 자동완성 API
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
export interface NaverLocalSearchItem {
|
||||||
|
title: string;
|
||||||
|
link: string;
|
||||||
|
category: string;
|
||||||
|
description: string;
|
||||||
|
telephone: string;
|
||||||
|
address: string;
|
||||||
|
roadAddress: string;
|
||||||
|
mapx: string;
|
||||||
|
mapy: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NaverLocalSearchResponse {
|
||||||
|
lastBuildDate: string;
|
||||||
|
total: number;
|
||||||
|
start: number;
|
||||||
|
display: number;
|
||||||
|
items: NaverLocalSearchItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AutocompleteRequest {
|
||||||
|
address: string;
|
||||||
|
roadAddress: string;
|
||||||
|
title: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 네이버 지역 검색 API (백엔드 프록시 경유)
|
||||||
|
export async function searchNaverLocal(query: string): Promise<NaverLocalSearchResponse> {
|
||||||
|
const response = await fetch(`${API_URL}/naver/local/search?query=${encodeURIComponent(query)}`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
...getAuthHeader(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP error! status: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 자동완성 API (업체 정보로 크롤링)
|
||||||
|
export async function autocomplete(request: AutocompleteRequest): Promise<CrawlingResponse> {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeoutId = setTimeout(() => controller.abort(), CRAWL_TIMEOUT);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_URL}/autocomplete`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(request),
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP error! status: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json();
|
||||||
|
} catch (error) {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
if (error instanceof Error && error.name === 'AbortError') {
|
||||||
|
throw new Error('자동완성 요청 시간이 초과되었습니다. 다시 시도해주세요.');
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
/// <reference types="vite/client" />
|
||||||
Loading…
Reference in New Issue