- MultiChannelInput: URL 뭉치 붙여넣기 → classifyUrls로 7개 채널 자동 분류
(homepage·YouTube·Instagram·Facebook·네이버플레이스·블로그·강남언니)
· "Analyze" pill 버튼 복원 + variant별 색 분기 (hero=dark brand,
cta=#fff3eb→#e4cfff→#f5f9ff warm 3-stop)
· placeholder 중앙 정렬 + "한 줄씩" 규칙 제거 (유연 파싱 노출)
- Navbar: Free Report CTA 제거 → Login + 문의하기 (contact@o2o.kr) duo
- LoginPage: 계약 고객용 스캐폴딩 페이지 신규 추가
- PricingPage: 계약 기반 영업 반영, FAQ에서 해지·환불 항목 제거
(세부 정책 미확정 → 후속 추가)
- Landing 카피 Strategic Planner 포지셔닝 피봇:
· Hero sub: "10분 진단 → 12개월 전략 설계"
· Solution AGDP: Audit / Generation / Direction / Planning 재해석
· Modules: Intelligence + Planning Available, 나머지 Coming Soon 정직화
· TargetAudience: 전략 파트너 / 전략 자문 + Partner Program 신청 waitlist
· Problems: 콘텐츠 소진 / 경쟁사 분석 부재 / 데이터 부족 3축
· UseCases: 진단·전략·KPI(Medical) · 수주·자문·포트폴리오(Agency)
- discover-channels Edge Function: manualChannels 수용 — 사용자 붙여넣은
URL이 Firecrawl 스크래핑보다 우선, naverPlace/gangnamUnni 직접 주입
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
314 lines
8.4 KiB
TypeScript
314 lines
8.4 KiB
TypeScript
import { createClient } from "@supabase/supabase-js";
|
|
|
|
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL;
|
|
const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY;
|
|
|
|
export const supabase = createClient(supabaseUrl, supabaseAnonKey);
|
|
|
|
/** Common headers for Edge Function calls (includes JWT auth) */
|
|
const fnHeaders = () => ({
|
|
"Content-Type": "application/json",
|
|
"Authorization": `Bearer ${supabaseAnonKey}`,
|
|
});
|
|
|
|
export async function generateMarketingReport(url: string, clinicName?: string) {
|
|
const response = await fetch(
|
|
`${supabaseUrl}/functions/v1/generate-report`,
|
|
{
|
|
method: "POST",
|
|
headers: fnHeaders(),
|
|
body: JSON.stringify({ url, clinicName }),
|
|
}
|
|
);
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`Report generation failed: ${response.statusText}`);
|
|
}
|
|
|
|
return response.json();
|
|
}
|
|
|
|
export async function fetchReportById(reportId: string) {
|
|
const { data, error } = await supabase
|
|
.from("marketing_reports")
|
|
.select("*")
|
|
.eq("id", reportId)
|
|
.single();
|
|
|
|
if (error) throw new Error(`Failed to fetch report: ${error.message}`);
|
|
return data;
|
|
}
|
|
|
|
/**
|
|
* Fetch pipeline status for a report.
|
|
* Used by AnalysisLoadingPage to resume interrupted pipelines.
|
|
*/
|
|
export interface PipelineStatus {
|
|
reportId: string;
|
|
clinicId?: string;
|
|
runId?: string;
|
|
status: string;
|
|
clinicName?: string;
|
|
hasChannelData: boolean;
|
|
hasReport: boolean;
|
|
}
|
|
|
|
export async function fetchPipelineStatus(reportId: string): Promise<PipelineStatus> {
|
|
const { data, error } = await supabase
|
|
.from("marketing_reports")
|
|
.select("id, status, clinic_name, channel_data, report")
|
|
.eq("id", reportId)
|
|
.single();
|
|
|
|
if (error || !data) throw new Error(`Report not found: ${error?.message}`);
|
|
|
|
return {
|
|
reportId: data.id,
|
|
status: data.status || "unknown",
|
|
clinicName: data.clinic_name,
|
|
hasChannelData: !!data.channel_data && Object.keys(data.channel_data).length > 0,
|
|
hasReport: !!data.report && Object.keys(data.report).length > 0,
|
|
};
|
|
}
|
|
|
|
export interface EnrichChannelsRequest {
|
|
reportId: string;
|
|
clinicName: string;
|
|
instagramHandle?: string;
|
|
instagramHandles?: string[];
|
|
youtubeChannelId?: string;
|
|
facebookHandle?: string;
|
|
address?: string;
|
|
}
|
|
|
|
/**
|
|
* Fire-and-forget: triggers background channel enrichment.
|
|
* Returns enrichment result when the Edge Function completes (~27s).
|
|
*/
|
|
export async function enrichChannels(params: EnrichChannelsRequest) {
|
|
const response = await fetch(
|
|
`${supabaseUrl}/functions/v1/enrich-channels`,
|
|
{
|
|
method: "POST",
|
|
headers: fnHeaders(),
|
|
body: JSON.stringify(params),
|
|
}
|
|
);
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`Channel enrichment failed: ${response.statusText}`);
|
|
}
|
|
|
|
return response.json();
|
|
}
|
|
|
|
export async function scrapeWebsite(url: string, clinicName?: string) {
|
|
const response = await fetch(
|
|
`${supabaseUrl}/functions/v1/scrape-website`,
|
|
{
|
|
method: "POST",
|
|
headers: fnHeaders(),
|
|
body: JSON.stringify({ url, clinicName }),
|
|
}
|
|
);
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`Scraping failed: ${response.statusText}`);
|
|
}
|
|
|
|
return response.json();
|
|
}
|
|
|
|
// ─── Pipeline V2 API Functions ───
|
|
|
|
/**
|
|
* 사용자가 직접 붙여넣은 채널 URL 묶음.
|
|
* 전달 시 Edge Function이 해당 플랫폼의 Firecrawl discovery를 스킵하고
|
|
* 제공된 URL을 `verified_channels`에 직접 주입합니다.
|
|
*/
|
|
export interface ManualChannels {
|
|
youtube?: string[];
|
|
instagram?: string[];
|
|
facebook?: string[];
|
|
naverPlace?: string[];
|
|
naverBlog?: string[];
|
|
gangnamUnni?: string[];
|
|
}
|
|
|
|
/**
|
|
* Phase 1: Discover & verify social channels from website URL.
|
|
* Returns verified handles + reportId for subsequent phases.
|
|
*
|
|
* @param url 병원 홈페이지 URL (필수).
|
|
* @param clinicName 병원명 (선택) — 수동 입력 또는 clinic_registry 매칭용.
|
|
* @param manualChannels (선택) 사용자가 직접 붙여넣은 채널 URL. 존재하는 플랫폼은
|
|
* Firecrawl discovery를 스킵하고 사용자 제공 값을 우선 사용합니다.
|
|
*/
|
|
export async function discoverChannels(
|
|
url: string,
|
|
clinicName?: string,
|
|
manualChannels?: ManualChannels,
|
|
) {
|
|
const response = await fetch(
|
|
`${supabaseUrl}/functions/v1/discover-channels`,
|
|
{
|
|
method: "POST",
|
|
headers: fnHeaders(),
|
|
body: JSON.stringify({ url, clinicName, manualChannels }),
|
|
}
|
|
);
|
|
|
|
const data = await response.json();
|
|
|
|
if (!response.ok) {
|
|
const err = new Error(data.error || `Channel discovery failed: ${response.statusText}`);
|
|
(err as Error & { code?: string; domain?: string }).code = data.error;
|
|
(err as Error & { code?: string; domain?: string }).domain = data.domain;
|
|
throw err;
|
|
}
|
|
|
|
return data;
|
|
}
|
|
|
|
/**
|
|
* Phase 2: Collect all channel data using verified handles.
|
|
* Reads verified_channels from DB, runs parallel API calls.
|
|
*/
|
|
export async function collectChannelData(reportId: string, clinicId?: string, runId?: string) {
|
|
const response = await fetch(
|
|
`${supabaseUrl}/functions/v1/collect-channel-data`,
|
|
{
|
|
method: "POST",
|
|
headers: fnHeaders(),
|
|
body: JSON.stringify({ reportId, clinicId, runId }),
|
|
}
|
|
);
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`Data collection failed: ${response.statusText}`);
|
|
}
|
|
|
|
return response.json();
|
|
}
|
|
|
|
/**
|
|
* Phase 3: Generate AI report using real collected data from DB.
|
|
*/
|
|
export async function generateReportV2(reportId: string, clinicId?: string, runId?: string) {
|
|
const response = await fetch(
|
|
`${supabaseUrl}/functions/v1/generate-report`,
|
|
{
|
|
method: "POST",
|
|
headers: fnHeaders(),
|
|
body: JSON.stringify({ reportId, clinicId, runId }),
|
|
}
|
|
);
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`Report generation failed: ${response.statusText}`);
|
|
}
|
|
|
|
return response.json();
|
|
}
|
|
|
|
// ─── Pipeline V2 Phase 4: Content Plan ───
|
|
|
|
/**
|
|
* Phase 4: Generate AI content strategy and calendar.
|
|
* Reads report data from DB, calls Perplexity for strategy generation,
|
|
* stores result in content_plans table.
|
|
*/
|
|
export async function generateContentPlan(reportId: string, clinicId?: string, runId?: string) {
|
|
const response = await fetch(
|
|
`${supabaseUrl}/functions/v1/generate-content-plan`,
|
|
{
|
|
method: "POST",
|
|
headers: fnHeaders(),
|
|
body: JSON.stringify({ reportId, clinicId, runId }),
|
|
}
|
|
);
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`Content plan generation failed: ${response.statusText}`);
|
|
}
|
|
|
|
return response.json();
|
|
}
|
|
|
|
/**
|
|
* Fetch the active content plan for a clinic.
|
|
* Returns the most recent is_active=true plan with all JSONB columns.
|
|
*/
|
|
export async function fetchActiveContentPlan(clinicId: string) {
|
|
const { data, error } = await supabase
|
|
.from("content_plans")
|
|
.select("*")
|
|
.eq("clinic_id", clinicId)
|
|
.eq("is_active", true)
|
|
.order("created_at", { ascending: false })
|
|
.limit(1)
|
|
.single();
|
|
|
|
if (error) return null;
|
|
return data;
|
|
}
|
|
|
|
/**
|
|
* Update a single calendar entry within a content plan's JSONB.
|
|
* Uses Postgres JSONB path update.
|
|
*/
|
|
export async function updateCalendarEntry(
|
|
planId: string,
|
|
entryId: string,
|
|
updates: Record<string, unknown>,
|
|
) {
|
|
// Read current calendar
|
|
const { data: plan, error: readErr } = await supabase
|
|
.from("content_plans")
|
|
.select("calendar")
|
|
.eq("id", planId)
|
|
.single();
|
|
|
|
if (readErr || !plan) throw new Error(`Plan not found: ${readErr?.message}`);
|
|
|
|
const calendar = plan.calendar as { weeks: { entries: Record<string, unknown>[] }[] };
|
|
|
|
// Find and update entry
|
|
for (const week of calendar.weeks) {
|
|
for (let i = 0; i < week.entries.length; i++) {
|
|
if (week.entries[i].id === entryId) {
|
|
week.entries[i] = { ...week.entries[i], ...updates, isManualEdit: true };
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
const { error: writeErr } = await supabase
|
|
.from("content_plans")
|
|
.update({ calendar })
|
|
.eq("id", planId);
|
|
|
|
if (writeErr) throw new Error(`Update failed: ${writeErr.message}`);
|
|
}
|
|
|
|
/**
|
|
* Trigger strategy adjustment for a clinic.
|
|
* Compares current vs previous channel_snapshots, generates AI recommendations.
|
|
*/
|
|
export async function triggerStrategyAdjustment(clinicId: string) {
|
|
const response = await fetch(
|
|
`${supabaseUrl}/functions/v1/adjust-strategy`,
|
|
{
|
|
method: "POST",
|
|
headers: fnHeaders(),
|
|
body: JSON.stringify({ clinicId }),
|
|
}
|
|
);
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`Strategy adjustment failed: ${response.statusText}`);
|
|
}
|
|
|
|
return response.json();
|
|
}
|