feat: 랜딩페이지 완성 및 에디터 홈 버튼 기능 구현
주요 변경사항: - 🎨 가격 섹션을 CTA 섹션과 동일한 그라디언트 스타일로 변경 - 🗑️ 중복된 CTA 섹션 제거 및 페이지 구성 최적화 - 🏠 에디터 상단 로고 클릭 시 홈 이동 기능 추가 (저장 경고 포함) - 📱 인증 페이지 컴포넌트 추가 (SignIn/SignUp) - 💰 가격 정보 섹션 및 FAQ 섹션 추가 - 🔧 TopBar 컴포넌트에 로고 클릭 핸들러 추가 UI/UX 개선: - 가격 섹션: 파란색-보라색 그라디언트 배경 적용 - 카드 스타일: 반투명 배경 및 backdrop-blur 효과 - 텍스트 색상: 그라디언트 배경에 맞는 흰색/파란색 톤 적용 - 버튼 스타일: 인기 플랜 노란색 강조, 일반 플랜 반투명 스타일 기능 추가: - 에디터에서 로고 클릭 시 작업 손실 경고 및 홈 이동 - 완전한 인증 플로우 UI 구성 - 반응형 가격 정보 표시
This commit is contained in:
413
src/App.tsx
413
src/App.tsx
@@ -1,98 +1,379 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Button } from "./components/ui/button";
|
import { Button } from "./components/ui/button";
|
||||||
import HomeButton from "./components/ui/homeButton";
|
import { TopBar } from "./components/ui/topbar";
|
||||||
import { lazy, Suspense } from "react";
|
import { lazy, Suspense } from "react";
|
||||||
import LandingPage from "./components/LandingPage";
|
import LandingPage from "./components/LandingPage";
|
||||||
|
import { SignUpPage } from "./components/auth/SignUpPage";
|
||||||
|
import { SignInPage } from "./components/auth/SignInPage";
|
||||||
|
import { useAppStore } from "./stores/useAppStore";
|
||||||
|
|
||||||
// 동적 import로 EditSheetViewer 로드 (필요할 때만)
|
// 동적 import로 EditSheetViewer 로드 (필요할 때만)
|
||||||
const EditSheetViewer = lazy(
|
const EditSheetViewer = lazy(
|
||||||
() => import("./components/sheet/EditSheetViewer"),
|
() => import("./components/sheet/EditSheetViewer"),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 앱 상태 타입 정의
|
||||||
|
type AppView = "landing" | "signUp" | "signIn" | "editor" | "account";
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const [showTestViewer, setShowTestViewer] = useState(false);
|
const [currentView, setCurrentView] = useState<AppView>("landing");
|
||||||
|
const { isAuthenticated, setAuthenticated, setUser, user, currentFile } =
|
||||||
|
useAppStore();
|
||||||
|
|
||||||
|
// CTA 버튼 클릭 핸들러 - 인증 상태에 따른 분기 처리
|
||||||
const handleGetStarted = () => {
|
const handleGetStarted = () => {
|
||||||
setShowTestViewer(true);
|
if (isAuthenticated) {
|
||||||
|
// 이미 로그인된 사용자는 바로 에디터로 이동
|
||||||
|
setCurrentView("editor");
|
||||||
|
} else {
|
||||||
|
// 미인증 사용자는 가입 페이지로 이동
|
||||||
|
setCurrentView("signUp");
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 다운로드 클릭 핸들러
|
||||||
const handleDownloadClick = () => {
|
const handleDownloadClick = () => {
|
||||||
// TODO: 다운로드 기능 구현
|
if (currentFile?.xlsxBuffer) {
|
||||||
console.log("Download clicked");
|
// XLSX ArrayBuffer를 Blob으로 변환하여 다운로드
|
||||||
|
const blob = new Blob([currentFile.xlsxBuffer], {
|
||||||
|
type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||||
|
});
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.href = url;
|
||||||
|
link.download = currentFile.name || "sheet.xlsx";
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
} else {
|
||||||
|
alert("다운로드할 파일이 없습니다. 파일을 먼저 업로드해주세요.");
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 계정 클릭 핸들러
|
||||||
const handleAccountClick = () => {
|
const handleAccountClick = () => {
|
||||||
// TODO: 계정 페이지 이동 기능 구현
|
if (isAuthenticated) {
|
||||||
console.log("Account clicked");
|
setCurrentView("account");
|
||||||
|
} else {
|
||||||
|
setCurrentView("signIn");
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
// 데모보기 버튼 클릭 핸들러 - 에디터로 바로 이동
|
||||||
<div className="min-h-screen">
|
const handleDemoClick = () => {
|
||||||
{showTestViewer ? (
|
setCurrentView("editor");
|
||||||
// 에디트 뷰어 모드
|
};
|
||||||
<div className="min-h-screen bg-gray-50">
|
|
||||||
{/* 헤더 */}
|
// 가입 처리 핸들러
|
||||||
<header className="bg-white shadow-sm border-b">
|
const handleSignUp = (email: string, password: string) => {
|
||||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
// TODO: 실제 API 연동
|
||||||
<div className="flex justify-between items-center h-16">
|
console.log("가입 요청:", { email, password });
|
||||||
<div className="flex items-center">
|
|
||||||
<HomeButton
|
// 임시로 자동 로그인 처리
|
||||||
className="ml-0"
|
setUser({
|
||||||
style={{ position: "absolute", left: "1%" }}
|
id: "temp-user-id",
|
||||||
onClick={() => {
|
email,
|
||||||
if (window.confirm("랜딩페이지로 돌아가시겠습니까?")) {
|
name: email.split("@")[0],
|
||||||
setShowTestViewer(false);
|
createdAt: new Date(),
|
||||||
}
|
lastLoginAt: new Date(),
|
||||||
}}
|
subscription: {
|
||||||
>
|
plan: "free",
|
||||||
sheetEasy AI
|
status: "active",
|
||||||
</HomeButton>
|
currentPeriodStart: new Date(),
|
||||||
|
currentPeriodEnd: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), // 30일 후
|
||||||
|
usage: {
|
||||||
|
aiQueries: 0,
|
||||||
|
cellCount: 0,
|
||||||
|
resetDate: new Date(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
preferences: {
|
||||||
|
language: "ko",
|
||||||
|
historyPanelPosition: "right",
|
||||||
|
autoSave: true,
|
||||||
|
showAnimations: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
setAuthenticated(true);
|
||||||
|
setCurrentView("editor");
|
||||||
|
};
|
||||||
|
|
||||||
|
// 로그인 처리 핸들러
|
||||||
|
const handleSignIn = (email: string, password: string) => {
|
||||||
|
// TODO: 실제 API 연동
|
||||||
|
console.log("로그인 요청:", { email, password });
|
||||||
|
|
||||||
|
// 임시로 자동 로그인 처리
|
||||||
|
setUser({
|
||||||
|
id: "temp-user-id",
|
||||||
|
email,
|
||||||
|
name: email.split("@")[0],
|
||||||
|
createdAt: new Date(),
|
||||||
|
lastLoginAt: new Date(),
|
||||||
|
subscription: {
|
||||||
|
plan: "lite",
|
||||||
|
status: "active",
|
||||||
|
currentPeriodStart: new Date(),
|
||||||
|
currentPeriodEnd: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), // 30일 후
|
||||||
|
usage: {
|
||||||
|
aiQueries: 15,
|
||||||
|
cellCount: 234,
|
||||||
|
resetDate: new Date(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
preferences: {
|
||||||
|
language: "ko",
|
||||||
|
historyPanelPosition: "right",
|
||||||
|
autoSave: true,
|
||||||
|
showAnimations: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
setAuthenticated(true);
|
||||||
|
setCurrentView("editor");
|
||||||
|
};
|
||||||
|
|
||||||
|
// 로그아웃 핸들러
|
||||||
|
const handleLogout = () => {
|
||||||
|
setUser(null);
|
||||||
|
setAuthenticated(false);
|
||||||
|
setCurrentView("landing");
|
||||||
|
};
|
||||||
|
|
||||||
|
// 뷰 전환 핸들러들
|
||||||
|
const handleBackToLanding = () => setCurrentView("landing");
|
||||||
|
const handleGoToSignIn = () => setCurrentView("signIn");
|
||||||
|
const handleGoToSignUp = () => setCurrentView("signUp");
|
||||||
|
const handleGoToEditor = () => setCurrentView("editor");
|
||||||
|
|
||||||
|
// 에디터에서 홈으로 돌아가기 핸들러 (워닝 포함)
|
||||||
|
const handleEditorLogoClick = () => {
|
||||||
|
const confirmed = window.confirm(
|
||||||
|
"편집 중인 작업이 있습니다. 홈으로 돌아가면 저장되지 않은 작업이 손실될 수 있습니다.\n\n정말로 홈으로 돌아가시겠습니까?",
|
||||||
|
);
|
||||||
|
|
||||||
|
if (confirmed) {
|
||||||
|
setCurrentView("landing");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 현재 뷰에 따른 렌더링
|
||||||
|
const renderCurrentView = () => {
|
||||||
|
switch (currentView) {
|
||||||
|
case "signUp":
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50">
|
||||||
|
<TopBar
|
||||||
|
showDownload={false}
|
||||||
|
showAccount={false}
|
||||||
|
showNavigation={false}
|
||||||
|
showAuthButtons={false}
|
||||||
|
onAccountClick={handleAccountClick}
|
||||||
|
/>
|
||||||
|
<main className="container mx-auto py-8">
|
||||||
|
<SignUpPage
|
||||||
|
onSignUp={handleSignUp}
|
||||||
|
onSignInClick={handleGoToSignIn}
|
||||||
|
onBack={handleBackToLanding}
|
||||||
|
/>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
case "signIn":
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50">
|
||||||
|
<TopBar
|
||||||
|
showDownload={false}
|
||||||
|
showAccount={false}
|
||||||
|
showNavigation={false}
|
||||||
|
showAuthButtons={false}
|
||||||
|
onAccountClick={handleAccountClick}
|
||||||
|
/>
|
||||||
|
<main className="container mx-auto py-8">
|
||||||
|
<SignInPage
|
||||||
|
onSignIn={handleSignIn}
|
||||||
|
onSignUpClick={handleGoToSignUp}
|
||||||
|
onBack={handleBackToLanding}
|
||||||
|
/>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
case "account":
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50">
|
||||||
|
<TopBar
|
||||||
|
showDownload={false}
|
||||||
|
showAccount={true}
|
||||||
|
showNavigation={false}
|
||||||
|
showAuthButtons={false}
|
||||||
|
onAccountClick={handleAccountClick}
|
||||||
|
/>
|
||||||
|
<main className="container mx-auto py-8">
|
||||||
|
<div className="max-w-2xl mx-auto bg-white rounded-lg shadow-md p-6">
|
||||||
|
<h1 className="text-2xl font-bold mb-6">계정 정보</h1>
|
||||||
|
|
||||||
|
{/* 사용자 정보 */}
|
||||||
|
<div className="mb-6">
|
||||||
|
<h2 className="text-lg font-semibold mb-3">사용자 정보</h2>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p>
|
||||||
|
<strong>이메일:</strong> {user?.email}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>이름:</strong> {user?.name}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>가입일:</strong>{" "}
|
||||||
|
{user?.createdAt?.toLocaleDateString()}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>최근 로그인:</strong>{" "}
|
||||||
|
{user?.lastLoginAt?.toLocaleDateString()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center space-x-4">
|
|
||||||
|
{/* 구독 정보 */}
|
||||||
|
<div className="mb-6">
|
||||||
|
<h2 className="text-lg font-semibold mb-3">구독 정보</h2>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p>
|
||||||
|
<strong>플랜:</strong>{" "}
|
||||||
|
{user?.subscription?.plan?.toUpperCase()}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>상태:</strong> {user?.subscription?.status}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>다음 결제일:</strong>{" "}
|
||||||
|
{user?.subscription?.currentPeriodEnd?.toLocaleDateString()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 사용량 */}
|
||||||
|
<div className="mb-6">
|
||||||
|
<h2 className="text-lg font-semibold mb-3">사용량</h2>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p>
|
||||||
|
<strong>AI 쿼리:</strong>{" "}
|
||||||
|
{user?.subscription?.usage?.aiQueries} /{" "}
|
||||||
|
{user?.subscription?.plan === "free"
|
||||||
|
? "30"
|
||||||
|
: user?.subscription?.plan === "lite"
|
||||||
|
? "100"
|
||||||
|
: "500"}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>셀 카운트:</strong>{" "}
|
||||||
|
{user?.subscription?.usage?.cellCount} /{" "}
|
||||||
|
{user?.subscription?.plan === "free"
|
||||||
|
? "300"
|
||||||
|
: user?.subscription?.plan === "lite"
|
||||||
|
? "1000"
|
||||||
|
: "5000"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 설정 */}
|
||||||
|
<div className="mb-6">
|
||||||
|
<h2 className="text-lg font-semibold mb-3">설정</h2>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p>
|
||||||
|
<strong>언어:</strong>{" "}
|
||||||
|
{user?.preferences?.language === "ko"
|
||||||
|
? "한국어"
|
||||||
|
: "English"}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>히스토리 패널 위치:</strong>{" "}
|
||||||
|
{user?.preferences?.historyPanelPosition === "right"
|
||||||
|
? "우측"
|
||||||
|
: "좌측"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 액션 버튼들 */}
|
||||||
|
<div className="flex space-x-4">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
onClick={handleGoToEditor}
|
||||||
size="sm"
|
className="bg-blue-600 hover:bg-blue-700 text-white"
|
||||||
onClick={() => setShowTestViewer(false)}
|
|
||||||
className="bg-green-500 hover:bg-green-600 text-white border-green-500"
|
|
||||||
>
|
>
|
||||||
🏠 홈으로
|
에디터로 이동
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleLogout}
|
||||||
|
variant="outline"
|
||||||
|
className="border-red-500 text-red-500 hover:bg-red-50"
|
||||||
|
>
|
||||||
|
로그아웃
|
||||||
</Button>
|
</Button>
|
||||||
<span className="text-sm text-gray-600">
|
|
||||||
Univer CE 에디트 모드
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</main>
|
||||||
</header>
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
{/* 메인 콘텐츠 */}
|
case "editor":
|
||||||
<main className="h-[calc(100vh-4rem)]">
|
return (
|
||||||
<div className="h-full">
|
<div className="min-h-screen bg-gray-50">
|
||||||
<Suspense
|
<TopBar
|
||||||
fallback={
|
showDownload={true}
|
||||||
<div className="h-full flex items-center justify-center">
|
showAccount={true}
|
||||||
<div className="text-center">
|
showNavigation={false}
|
||||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4"></div>
|
showAuthButtons={false}
|
||||||
<p className="text-gray-600">🚀 그리드 그리는 중...</p>
|
onDownloadClick={handleDownloadClick}
|
||||||
|
onAccountClick={handleAccountClick}
|
||||||
|
onLogoClick={handleEditorLogoClick}
|
||||||
|
/>
|
||||||
|
<main className="h-[calc(100vh-4rem)]">
|
||||||
|
<div className="h-full">
|
||||||
|
<Suspense
|
||||||
|
fallback={
|
||||||
|
<div className="h-full flex items-center justify-center">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4"></div>
|
||||||
|
<p className="text-gray-600">🚀 그리드 그리는 중...</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
}
|
||||||
}
|
>
|
||||||
>
|
<EditSheetViewer />
|
||||||
<EditSheetViewer />
|
</Suspense>
|
||||||
</Suspense>
|
</div>
|
||||||
</div>
|
</main>
|
||||||
</main>
|
</div>
|
||||||
</div>
|
);
|
||||||
) : (
|
|
||||||
// 랜딩페이지 모드
|
case "landing":
|
||||||
<LandingPage
|
default:
|
||||||
onGetStarted={handleGetStarted}
|
return (
|
||||||
onDownloadClick={handleDownloadClick}
|
<div className="min-h-screen">
|
||||||
onAccountClick={handleAccountClick}
|
<TopBar
|
||||||
/>
|
showDownload={false}
|
||||||
)}
|
showAccount={false}
|
||||||
</div>
|
showNavigation={true}
|
||||||
);
|
showAuthButtons={true}
|
||||||
|
onSignInClick={handleGoToSignIn}
|
||||||
|
onGetStartedClick={handleGetStarted}
|
||||||
|
onAccountClick={handleAccountClick}
|
||||||
|
/>
|
||||||
|
<LandingPage
|
||||||
|
onGetStarted={handleGetStarted}
|
||||||
|
onDownloadClick={handleDownloadClick}
|
||||||
|
onAccountClick={handleAccountClick}
|
||||||
|
onDemoClick={handleDemoClick}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return <div className="min-h-screen">{renderCurrentView()}</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default App;
|
export default App;
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { TopBar } from "./ui/topbar";
|
|
||||||
import { HeroSection } from "./ui/hero-section";
|
import { HeroSection } from "./ui/hero-section";
|
||||||
import { FeaturesSection } from "./ui/features-section";
|
import { FeaturesSection } from "./ui/features-section";
|
||||||
import { CTASection } from "./ui/cta-section";
|
import { FAQSection } from "./ui/faq-section";
|
||||||
|
import { PricingSection } from "./ui/pricing-section";
|
||||||
import { Footer } from "./ui/footer";
|
import { Footer } from "./ui/footer";
|
||||||
|
|
||||||
interface LandingPageProps {
|
interface LandingPageProps {
|
||||||
onGetStarted?: () => void;
|
onGetStarted?: () => void;
|
||||||
onDownloadClick?: () => void;
|
onDownloadClick?: () => void;
|
||||||
onAccountClick?: () => void;
|
onAccountClick?: () => void;
|
||||||
|
onDemoClick?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -22,25 +23,23 @@ const LandingPage: React.FC<LandingPageProps> = ({
|
|||||||
onGetStarted,
|
onGetStarted,
|
||||||
onDownloadClick,
|
onDownloadClick,
|
||||||
onAccountClick,
|
onAccountClick,
|
||||||
|
onDemoClick,
|
||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-white">
|
<div className="min-h-screen bg-white">
|
||||||
{/* TopBar - 상단 고정 네비게이션 */}
|
|
||||||
<TopBar
|
|
||||||
onDownloadClick={onDownloadClick}
|
|
||||||
onAccountClick={onAccountClick}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Main Content */}
|
{/* Main Content */}
|
||||||
<main role="main">
|
<main role="main">
|
||||||
{/* Hero Section - 메인 소개 및 CTA */}
|
{/* Hero Section - 메인 소개 및 CTA */}
|
||||||
<HeroSection onGetStarted={onGetStarted} />
|
<HeroSection onGetStarted={onGetStarted} onDemoClick={onDemoClick} />
|
||||||
|
|
||||||
{/* Features Section - 주요 기능 소개 */}
|
{/* Features Section - 주요 기능 소개 */}
|
||||||
<FeaturesSection />
|
<FeaturesSection />
|
||||||
|
|
||||||
{/* CTA Section - 최종 행동 유도 */}
|
{/* FAQ Section - 자주 묻는 질문 */}
|
||||||
<CTASection onGetStarted={onGetStarted} />
|
<FAQSection />
|
||||||
|
|
||||||
|
{/* Pricing Section - 가격 정보 */}
|
||||||
|
<PricingSection onGetStarted={onGetStarted} />
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
{/* Footer - 푸터 정보 */}
|
{/* Footer - 푸터 정보 */}
|
||||||
|
|||||||
269
src/components/auth/SignInPage.tsx
Normal file
269
src/components/auth/SignInPage.tsx
Normal file
@@ -0,0 +1,269 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Button } from "../ui/button";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "../ui/card";
|
||||||
|
import { cn } from "../../lib/utils";
|
||||||
|
|
||||||
|
interface SignInPageProps {
|
||||||
|
className?: string;
|
||||||
|
onSignIn?: (email: string, password: string) => void;
|
||||||
|
onSignUpClick?: () => void;
|
||||||
|
onBack?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 로그인 화면 컴포넌트
|
||||||
|
* Vooster.ai 스타일의 세련된 디자인
|
||||||
|
* 실제 API 연동은 나중에 구현
|
||||||
|
*/
|
||||||
|
const SignInPage = React.forwardRef<HTMLDivElement, SignInPageProps>(
|
||||||
|
({ className, onSignIn, onSignUpClick, onBack, ...props }, ref) => {
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [errors, setErrors] = useState<{
|
||||||
|
email?: string;
|
||||||
|
password?: string;
|
||||||
|
general?: string;
|
||||||
|
}>({});
|
||||||
|
|
||||||
|
// 이메일 유효성 검사
|
||||||
|
const validateEmail = (email: string) => {
|
||||||
|
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||||
|
return emailRegex.test(email);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 폼 제출 핸들러
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
// 유효성 검사
|
||||||
|
const newErrors: typeof errors = {};
|
||||||
|
|
||||||
|
if (!email) {
|
||||||
|
newErrors.email = "이메일을 입력해주세요.";
|
||||||
|
} else if (!validateEmail(email)) {
|
||||||
|
newErrors.email = "올바른 이메일 형식을 입력해주세요.";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!password) {
|
||||||
|
newErrors.password = "비밀번호를 입력해주세요.";
|
||||||
|
}
|
||||||
|
|
||||||
|
setErrors(newErrors);
|
||||||
|
|
||||||
|
if (Object.keys(newErrors).length > 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 로그인 처리 (실제 API 호출은 나중에)
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 1500)); // 시뮬레이션
|
||||||
|
onSignIn?.(email, password);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("로그인 실패:", error);
|
||||||
|
setErrors({
|
||||||
|
general: "로그인에 실패했습니다. 이메일과 비밀번호를 확인해주세요.",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"min-h-screen bg-gradient-to-br from-slate-50 to-blue-50 flex items-center justify-center p-4",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{/* Background decoration */}
|
||||||
|
<div className="absolute inset-0 bg-grid-slate-100 [mask-image:linear-gradient(0deg,transparent,black)]" />
|
||||||
|
|
||||||
|
<div className="relative w-full max-w-md">
|
||||||
|
{/* 로고 및 헤더 */}
|
||||||
|
<div className="text-center mb-8">
|
||||||
|
<h1 className="text-3xl font-bold text-gray-900 mb-2">
|
||||||
|
sheetEasy AI
|
||||||
|
</h1>
|
||||||
|
<p className="text-gray-600">
|
||||||
|
계정에 로그인하여 AI 편집을 계속하세요
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 로그인 폼 */}
|
||||||
|
<Card className="shadow-xl border-0 bg-white/95 backdrop-blur-sm">
|
||||||
|
<CardHeader className="text-center pb-6">
|
||||||
|
<CardTitle className="text-2xl font-semibold text-gray-900">
|
||||||
|
로그인
|
||||||
|
</CardTitle>
|
||||||
|
<p className="text-sm text-gray-600 mt-2">
|
||||||
|
계정에 다시 오신 것을 환영합니다
|
||||||
|
</p>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent className="space-y-6">
|
||||||
|
{/* 일반 오류 메시지 */}
|
||||||
|
{errors.general && (
|
||||||
|
<div className="p-3 bg-red-50 border border-red-200 rounded-lg">
|
||||||
|
<p className="text-sm text-red-600">{errors.general}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
{/* 이메일 입력 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label
|
||||||
|
htmlFor="email"
|
||||||
|
className="text-sm font-medium text-gray-700"
|
||||||
|
>
|
||||||
|
이메일 주소
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="email"
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
className={cn(
|
||||||
|
"w-full px-3 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-colors",
|
||||||
|
errors.email ? "border-red-500" : "border-gray-300",
|
||||||
|
)}
|
||||||
|
placeholder="your@email.com"
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
{errors.email && (
|
||||||
|
<p className="text-sm text-red-600">{errors.email}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 비밀번호 입력 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label
|
||||||
|
htmlFor="password"
|
||||||
|
className="text-sm font-medium text-gray-700"
|
||||||
|
>
|
||||||
|
비밀번호
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="password"
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
className={cn(
|
||||||
|
"w-full px-3 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-colors",
|
||||||
|
errors.password ? "border-red-500" : "border-gray-300",
|
||||||
|
)}
|
||||||
|
placeholder="비밀번호를 입력해주세요"
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
{errors.password && (
|
||||||
|
<p className="text-sm text-red-600">{errors.password}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 비밀번호 찾기 링크 */}
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="text-sm text-blue-600 hover:text-blue-700 hover:underline"
|
||||||
|
disabled={true} // 나중에 구현
|
||||||
|
>
|
||||||
|
비밀번호를 잊으셨나요? (준비중)
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 로그인 버튼 */}
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
className="w-full bg-gradient-to-r from-green-500 to-blue-600 hover:from-green-600 hover:to-blue-700 text-white py-3 text-lg font-semibold shadow-lg hover:shadow-xl transition-all duration-300"
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex items-center justify-center">
|
||||||
|
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-white mr-2"></div>
|
||||||
|
로그인 중...
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
"로그인"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{/* 소셜 로그인 (나중에 구현) */}
|
||||||
|
<div className="relative">
|
||||||
|
<div className="absolute inset-0 flex items-center">
|
||||||
|
<div className="w-full border-t border-gray-300" />
|
||||||
|
</div>
|
||||||
|
<div className="relative flex justify-center text-sm">
|
||||||
|
<span className="px-2 bg-white text-gray-500">또는</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
className="w-full py-3 text-gray-700 border-gray-300 hover:bg-gray-50"
|
||||||
|
disabled={true} // 나중에 구현
|
||||||
|
>
|
||||||
|
<svg className="w-5 h-5 mr-2" viewBox="0 0 24 24">
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
Google로 로그인 (준비중)
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 가입 링크 */}
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-sm text-gray-600">
|
||||||
|
아직 계정이 없으신가요?{" "}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onSignUpClick}
|
||||||
|
className="text-blue-600 hover:text-blue-700 font-medium hover:underline"
|
||||||
|
>
|
||||||
|
무료로 가입하기
|
||||||
|
</button>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 뒤로가기 버튼 */}
|
||||||
|
<div className="text-center mt-6">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onBack}
|
||||||
|
className="text-gray-600 hover:text-gray-700 text-sm hover:underline"
|
||||||
|
>
|
||||||
|
← 메인화면으로 돌아가기
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
SignInPage.displayName = "SignInPage";
|
||||||
|
|
||||||
|
export { SignInPage };
|
||||||
291
src/components/auth/SignUpPage.tsx
Normal file
291
src/components/auth/SignUpPage.tsx
Normal file
@@ -0,0 +1,291 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Button } from "../ui/button";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "../ui/card";
|
||||||
|
import { cn } from "../../lib/utils";
|
||||||
|
|
||||||
|
interface SignUpPageProps {
|
||||||
|
className?: string;
|
||||||
|
onSignUp?: (email: string, password: string) => void;
|
||||||
|
onSignInClick?: () => void;
|
||||||
|
onBack?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 가입화면 컴포넌트
|
||||||
|
* Vooster.ai 스타일의 세련된 디자인
|
||||||
|
* 실제 API 연동은 나중에 구현
|
||||||
|
*/
|
||||||
|
const SignUpPage = React.forwardRef<HTMLDivElement, SignUpPageProps>(
|
||||||
|
({ className, onSignUp, onSignInClick, onBack, ...props }, ref) => {
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [confirmPassword, setConfirmPassword] = useState("");
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [errors, setErrors] = useState<{
|
||||||
|
email?: string;
|
||||||
|
password?: string;
|
||||||
|
confirmPassword?: string;
|
||||||
|
}>({});
|
||||||
|
|
||||||
|
// 이메일 유효성 검사
|
||||||
|
const validateEmail = (email: string) => {
|
||||||
|
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||||
|
return emailRegex.test(email);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 비밀번호 유효성 검사
|
||||||
|
const validatePassword = (password: string) => {
|
||||||
|
return password.length >= 8;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 폼 제출 핸들러
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
// 유효성 검사
|
||||||
|
const newErrors: typeof errors = {};
|
||||||
|
|
||||||
|
if (!email) {
|
||||||
|
newErrors.email = "이메일을 입력해주세요.";
|
||||||
|
} else if (!validateEmail(email)) {
|
||||||
|
newErrors.email = "올바른 이메일 형식을 입력해주세요.";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!password) {
|
||||||
|
newErrors.password = "비밀번호를 입력해주세요.";
|
||||||
|
} else if (!validatePassword(password)) {
|
||||||
|
newErrors.password = "비밀번호는 8자 이상이어야 합니다.";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!confirmPassword) {
|
||||||
|
newErrors.confirmPassword = "비밀번호 확인을 입력해주세요.";
|
||||||
|
} else if (password !== confirmPassword) {
|
||||||
|
newErrors.confirmPassword = "비밀번호가 일치하지 않습니다.";
|
||||||
|
}
|
||||||
|
|
||||||
|
setErrors(newErrors);
|
||||||
|
|
||||||
|
if (Object.keys(newErrors).length > 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 가입 처리 (실제 API 호출은 나중에)
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 1500)); // 시뮬레이션
|
||||||
|
onSignUp?.(email, password);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("가입 실패:", error);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"min-h-screen bg-gradient-to-br from-slate-50 to-blue-50 flex items-center justify-center p-4",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{/* Background decoration */}
|
||||||
|
<div className="absolute inset-0 bg-grid-slate-100 [mask-image:linear-gradient(0deg,transparent,black)]" />
|
||||||
|
|
||||||
|
<div className="relative w-full max-w-md">
|
||||||
|
{/* 로고 및 헤더 */}
|
||||||
|
<div className="text-center mb-8">
|
||||||
|
<h1 className="text-3xl font-bold text-gray-900 mb-2">
|
||||||
|
sheetEasy AI
|
||||||
|
</h1>
|
||||||
|
<p className="text-gray-600">
|
||||||
|
AI 기반 Excel 편집 도구에 오신 것을 환영합니다
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 가입 폼 */}
|
||||||
|
<Card className="shadow-xl border-0 bg-white/95 backdrop-blur-sm">
|
||||||
|
<CardHeader className="text-center pb-6">
|
||||||
|
<CardTitle className="text-2xl font-semibold text-gray-900">
|
||||||
|
계정 만들기
|
||||||
|
</CardTitle>
|
||||||
|
<p className="text-sm text-gray-600 mt-2">
|
||||||
|
무료로 시작하고 AI의 힘을 경험해보세요
|
||||||
|
</p>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent className="space-y-6">
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
{/* 이메일 입력 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label
|
||||||
|
htmlFor="email"
|
||||||
|
className="text-sm font-medium text-gray-700"
|
||||||
|
>
|
||||||
|
이메일 주소
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="email"
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
className={cn(
|
||||||
|
"w-full px-3 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-colors",
|
||||||
|
errors.email ? "border-red-500" : "border-gray-300",
|
||||||
|
)}
|
||||||
|
placeholder="your@email.com"
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
{errors.email && (
|
||||||
|
<p className="text-sm text-red-600">{errors.email}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 비밀번호 입력 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label
|
||||||
|
htmlFor="password"
|
||||||
|
className="text-sm font-medium text-gray-700"
|
||||||
|
>
|
||||||
|
비밀번호
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="password"
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
className={cn(
|
||||||
|
"w-full px-3 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-colors",
|
||||||
|
errors.password ? "border-red-500" : "border-gray-300",
|
||||||
|
)}
|
||||||
|
placeholder="8자 이상 입력해주세요"
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
{errors.password && (
|
||||||
|
<p className="text-sm text-red-600">{errors.password}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 비밀번호 확인 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label
|
||||||
|
htmlFor="confirmPassword"
|
||||||
|
className="text-sm font-medium text-gray-700"
|
||||||
|
>
|
||||||
|
비밀번호 확인
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="confirmPassword"
|
||||||
|
type="password"
|
||||||
|
value={confirmPassword}
|
||||||
|
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||||
|
className={cn(
|
||||||
|
"w-full px-3 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-colors",
|
||||||
|
errors.confirmPassword
|
||||||
|
? "border-red-500"
|
||||||
|
: "border-gray-300",
|
||||||
|
)}
|
||||||
|
placeholder="비밀번호를 다시 입력해주세요"
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
{errors.confirmPassword && (
|
||||||
|
<p className="text-sm text-red-600">
|
||||||
|
{errors.confirmPassword}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 가입 버튼 */}
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
className="w-full bg-gradient-to-r from-green-500 to-blue-600 hover:from-green-600 hover:to-blue-700 text-white py-3 text-lg font-semibold shadow-lg hover:shadow-xl transition-all duration-300"
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex items-center justify-center">
|
||||||
|
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-white mr-2"></div>
|
||||||
|
계정 생성 중...
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
"무료로 시작하기"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{/* 소셜 로그인 (나중에 구현) */}
|
||||||
|
<div className="relative">
|
||||||
|
<div className="absolute inset-0 flex items-center">
|
||||||
|
<div className="w-full border-t border-gray-300" />
|
||||||
|
</div>
|
||||||
|
<div className="relative flex justify-center text-sm">
|
||||||
|
<span className="px-2 bg-white text-gray-500">또는</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
className="w-full py-3 text-gray-700 border-gray-300 hover:bg-gray-50"
|
||||||
|
disabled={true} // 나중에 구현
|
||||||
|
>
|
||||||
|
<svg className="w-5 h-5 mr-2" viewBox="0 0 24 24">
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
Google로 계속하기 (준비중)
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 로그인 링크 */}
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-sm text-gray-600">
|
||||||
|
이미 계정이 있으신가요?{" "}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onSignInClick}
|
||||||
|
className="text-blue-600 hover:text-blue-700 font-medium hover:underline"
|
||||||
|
>
|
||||||
|
로그인
|
||||||
|
</button>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 뒤로가기 버튼 */}
|
||||||
|
<div className="text-center mt-6">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onBack}
|
||||||
|
className="text-gray-600 hover:text-gray-700 text-sm hover:underline"
|
||||||
|
>
|
||||||
|
← 메인화면으로 돌아가기
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
SignUpPage.displayName = "SignUpPage";
|
||||||
|
|
||||||
|
export { SignUpPage };
|
||||||
@@ -914,175 +914,161 @@ const TestSheetViewer: React.FC = () => {
|
|||||||
|
|
||||||
{/* 파일 업로드 오버레이 - 레이어 분리 */}
|
{/* 파일 업로드 오버레이 - 레이어 분리 */}
|
||||||
{showUploadOverlay && (
|
{showUploadOverlay && (
|
||||||
<>
|
<div
|
||||||
{/* 1. Univer CE 영역만 흐리게 하는 반투명 레이어 */}
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
zIndex: 40,
|
||||||
|
backgroundColor: "rgba(255, 255, 255, 0.01)",
|
||||||
|
backdropFilter: "blur(8px)",
|
||||||
|
WebkitBackdropFilter: "blur(8px)",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
pointerEvents: "auto",
|
||||||
|
}}
|
||||||
|
onClick={() => setShowUploadOverlay(false)}
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
style={{
|
className="max-w-2xl w-full"
|
||||||
position: "absolute",
|
style={{ transform: "scale(0.8)" }}
|
||||||
top: "0px", // 헤더 높이만큼 아래로 (헤더는 약 80px)
|
onClick={(e) => e.stopPropagation()}
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
bottom: 0,
|
|
||||||
zIndex: 40,
|
|
||||||
backgroundColor: "rgba(255, 255, 255, 0.01)",
|
|
||||||
backdropFilter: "blur(8px)",
|
|
||||||
WebkitBackdropFilter: "blur(8px)", // Safari 지원
|
|
||||||
pointerEvents: "auto",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* 2. Univer 영역 중앙의 업로드 UI */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
top: "-100px", // 헤더 높이만큼 아래로
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
bottom: 0,
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
padding: "1rem",
|
|
||||||
zIndex: 50,
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="max-w-2xl w-full"
|
className="bg-white rounded-lg shadow-xl border p-8 md:p-12"
|
||||||
style={{ transform: "scale(0.8)" }}
|
style={{
|
||||||
|
backgroundColor: "#ffffff",
|
||||||
|
boxShadow: "0 25px 50px -12px rgba(0, 0, 0, 0.25)",
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<div className="text-center">
|
||||||
className="bg-white rounded-lg shadow-xl border p-8 md:p-12"
|
{/* 아이콘 및 제목 */}
|
||||||
style={{
|
<div className="mb-8">
|
||||||
backgroundColor: "#ffffff",
|
|
||||||
boxShadow: "0 25px 50px -12px rgba(0, 0, 0, 0.25)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="text-center">
|
|
||||||
{/* 아이콘 및 제목 */}
|
|
||||||
<div className="mb-8">
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"mx-auto h-20 w-20 md:h-24 md:w-24 rounded-full flex items-center justify-center mb-4",
|
|
||||||
isProcessing ? "bg-blue-100" : "bg-blue-50",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{isProcessing ? (
|
|
||||||
<svg
|
|
||||||
className="h-10 w-10 md:h-12 md:w-12 text-blue-600 animate-spin"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<circle
|
|
||||||
className="opacity-25"
|
|
||||||
cx="12"
|
|
||||||
cy="12"
|
|
||||||
r="10"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="4"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
className="opacity-75"
|
|
||||||
fill="currentColor"
|
|
||||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
) : (
|
|
||||||
<svg
|
|
||||||
className="h-10 w-10 md:h-12 md:w-12 text-blue-600"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeWidth={2}
|
|
||||||
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<h2 className="text-xl md:text-2xl font-semibold mb-2 text-gray-900">
|
|
||||||
{isProcessing
|
|
||||||
? "파일 처리 중..."
|
|
||||||
: "Excel 파일을 업로드하세요"}
|
|
||||||
</h2>
|
|
||||||
<p className="text-sm md:text-base text-gray-600 mb-6">
|
|
||||||
{isProcessing ? (
|
|
||||||
<span className="text-blue-600">
|
|
||||||
잠시만 기다려주세요...
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<span className="font-medium text-gray-900">
|
|
||||||
.xlsx
|
|
||||||
</span>{" "}
|
|
||||||
파일을 드래그 앤 드롭하거나 클릭하여 업로드
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 드래그 앤 드롭 영역 */}
|
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"border-2 border-dashed rounded-lg p-8 md:p-12 transition-all duration-200 cursor-pointer",
|
"mx-auto h-20 w-20 md:h-24 md:w-24 rounded-full flex items-center justify-center mb-4",
|
||||||
"hover:border-blue-400 hover:bg-blue-50",
|
isProcessing ? "bg-blue-100" : "bg-blue-50",
|
||||||
"focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2",
|
|
||||||
isDragOver
|
|
||||||
? "border-blue-500 bg-blue-100 scale-105"
|
|
||||||
: "border-gray-300",
|
|
||||||
isProcessing && "opacity-50 cursor-not-allowed",
|
|
||||||
)}
|
)}
|
||||||
onDragEnter={handleDragEnter}
|
|
||||||
onDragLeave={handleDragLeave}
|
|
||||||
onDragOver={handleDragOver}
|
|
||||||
onDrop={handleDrop}
|
|
||||||
onClick={handleFilePickerClick}
|
|
||||||
tabIndex={isProcessing ? -1 : 0}
|
|
||||||
role="button"
|
|
||||||
>
|
>
|
||||||
<div className="flex flex-col items-center justify-center space-y-4">
|
{isProcessing ? (
|
||||||
<div className="text-4xl md:text-6xl">
|
<svg
|
||||||
{isDragOver ? "📂" : "📄"}
|
className="h-10 w-10 md:h-12 md:w-12 text-blue-600 animate-spin"
|
||||||
</div>
|
fill="none"
|
||||||
<div className="text-center">
|
viewBox="0 0 24 24"
|
||||||
<p className="text-base md:text-lg font-medium mb-2 text-gray-900">
|
>
|
||||||
{isDragOver
|
<circle
|
||||||
? "파일을 여기에 놓으세요"
|
className="opacity-25"
|
||||||
: "파일을 드래그하거나 클릭하세요"}
|
cx="12"
|
||||||
</p>
|
cy="12"
|
||||||
<p className="text-sm text-gray-600">
|
r="10"
|
||||||
최대 50MB까지 업로드 가능
|
stroke="currentColor"
|
||||||
</p>
|
strokeWidth="4"
|
||||||
</div>
|
/>
|
||||||
|
<path
|
||||||
|
className="opacity-75"
|
||||||
|
fill="currentColor"
|
||||||
|
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
<svg
|
||||||
|
className="h-10 w-10 md:h-12 md:w-12 text-blue-600"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<h2 className="text-xl md:text-2xl font-semibold mb-2 text-gray-900">
|
||||||
|
{isProcessing
|
||||||
|
? "파일 처리 중..."
|
||||||
|
: "Excel 파일을 업로드하세요"}
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm md:text-base text-gray-600 mb-6">
|
||||||
|
{isProcessing ? (
|
||||||
|
<span className="text-blue-600">
|
||||||
|
잠시만 기다려주세요...
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<span className="font-medium text-gray-900">
|
||||||
|
.xlsx
|
||||||
|
</span>{" "}
|
||||||
|
파일을 드래그 앤 드롭하거나 클릭하여 업로드
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 드래그 앤 드롭 영역 */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"border-2 border-dashed rounded-lg p-8 md:p-12 transition-all duration-200 cursor-pointer",
|
||||||
|
"hover:border-blue-400 hover:bg-blue-50",
|
||||||
|
"focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2",
|
||||||
|
isDragOver
|
||||||
|
? "border-blue-500 bg-blue-100 scale-105"
|
||||||
|
: "border-gray-300",
|
||||||
|
isProcessing && "opacity-50 cursor-not-allowed",
|
||||||
|
)}
|
||||||
|
onDragEnter={handleDragEnter}
|
||||||
|
onDragLeave={handleDragLeave}
|
||||||
|
onDragOver={handleDragOver}
|
||||||
|
onDrop={handleDrop}
|
||||||
|
onClick={handleFilePickerClick}
|
||||||
|
tabIndex={isProcessing ? -1 : 0}
|
||||||
|
role="button"
|
||||||
|
>
|
||||||
|
<div className="flex flex-col items-center justify-center space-y-4">
|
||||||
|
<div className="text-4xl md:text-6xl">
|
||||||
|
{isDragOver ? "📂" : "📄"}
|
||||||
|
</div>
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-base md:text-lg font-medium mb-2 text-gray-900">
|
||||||
|
{isDragOver
|
||||||
|
? "파일을 여기에 놓으세요"
|
||||||
|
: "파일을 드래그하거나 클릭하세요"}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-gray-600">
|
||||||
|
최대 50MB까지 업로드 가능
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* 숨겨진 파일 입력 */}
|
{/* 숨겨진 파일 입력 */}
|
||||||
<input
|
<input
|
||||||
ref={fileInputRef}
|
ref={fileInputRef}
|
||||||
type="file"
|
type="file"
|
||||||
accept=".xlsx"
|
accept=".xlsx"
|
||||||
onChange={handleFileInputChange}
|
onChange={handleFileInputChange}
|
||||||
className="hidden"
|
className="hidden"
|
||||||
disabled={isProcessing}
|
disabled={isProcessing}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* 지원 형식 안내 */}
|
{/* 지원 형식 안내 */}
|
||||||
<div className="mt-6 text-xs text-gray-500">
|
<div className="mt-6 text-xs text-gray-500">
|
||||||
<p>지원 형식: Excel (.xlsx)</p>
|
<p>지원 형식: Excel (.xlsx)</p>
|
||||||
<p>최대 파일 크기: 50MB</p>
|
<p>최대 파일 크기: 50MB</p>
|
||||||
<p className="mt-2 text-blue-600">
|
<p className="mt-2 text-blue-600">
|
||||||
💡 브라우저 콘솔에서 window.__UNIVER_DEBUG__ 로 디버깅
|
💡 브라우저 콘솔에서 window.__UNIVER_DEBUG__ 로 디버깅
|
||||||
가능
|
가능
|
||||||
</p>
|
</p>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 프롬프트 입력창 - Univer 위 오버레이 */}
|
{/* 프롬프트 입력창 - Univer 위 오버레이 */}
|
||||||
|
|||||||
@@ -1,160 +0,0 @@
|
|||||||
import * as React from "react";
|
|
||||||
import { Button } from "./button";
|
|
||||||
import { Card, CardContent } from "./card";
|
|
||||||
import { cn } from "../../lib/utils";
|
|
||||||
|
|
||||||
interface CTASectionProps {
|
|
||||||
className?: string;
|
|
||||||
onGetStarted?: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const CTASection = React.forwardRef<HTMLElement, CTASectionProps>(
|
|
||||||
({ className, onGetStarted, ...props }, ref) => {
|
|
||||||
return (
|
|
||||||
<section
|
|
||||||
ref={ref}
|
|
||||||
className={cn(
|
|
||||||
"relative py-20 sm:py-32 bg-gradient-to-br from-blue-600 to-purple-700 overflow-hidden",
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{/* Background decoration */}
|
|
||||||
<div className="absolute inset-0 bg-grid-white/[0.05] [mask-image:linear-gradient(0deg,transparent,black)]" />
|
|
||||||
|
|
||||||
<div className="container relative">
|
|
||||||
<div className="mx-auto max-w-4xl text-center">
|
|
||||||
<h2 className="mb-6 text-3xl font-bold tracking-tight text-white sm:text-4xl">
|
|
||||||
지금 바로 시작하세요
|
|
||||||
</h2>
|
|
||||||
<p className="mx-auto mb-10 max-w-2xl text-lg leading-8 text-blue-100">
|
|
||||||
복잡한 Excel 작업을 AI로 간단하게. <br />
|
|
||||||
무료로 체험해보고 생산성을 혁신하세요.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{/* Pricing preview */}
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-12">
|
|
||||||
<Card className="border-0 bg-white/10 backdrop-blur-sm text-white">
|
|
||||||
<CardContent className="p-6 text-center">
|
|
||||||
<h3 className="mb-2 text-xl font-semibold">Free</h3>
|
|
||||||
<div className="mb-4">
|
|
||||||
<span className="text-3xl font-bold">₩0</span>
|
|
||||||
<span className="text-blue-200">/월</span>
|
|
||||||
</div>
|
|
||||||
<ul className="space-y-2 text-sm text-blue-100">
|
|
||||||
<li>• AI 쿼리 30회</li>
|
|
||||||
<li>• 셀 카운트 300개</li>
|
|
||||||
<li>• 10MB 파일 업로드</li>
|
|
||||||
<li>• 뷰어 모드만</li>
|
|
||||||
</ul>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card className="border-2 border-yellow-400 bg-white/15 backdrop-blur-sm text-white scale-105 shadow-2xl">
|
|
||||||
<CardContent className="p-6 text-center relative">
|
|
||||||
<div className="absolute -top-3 left-1/2 transform -translate-x-1/2 bg-yellow-400 text-gray-900 px-3 py-1 rounded-full text-xs font-semibold">
|
|
||||||
추천
|
|
||||||
</div>
|
|
||||||
<h3 className="mb-2 text-xl font-semibold">Lite</h3>
|
|
||||||
<div className="mb-4">
|
|
||||||
<span className="text-3xl font-bold">₩5,900</span>
|
|
||||||
<span className="text-blue-200">/월</span>
|
|
||||||
</div>
|
|
||||||
<ul className="space-y-2 text-sm text-blue-100">
|
|
||||||
<li>• AI 쿼리 100회</li>
|
|
||||||
<li>• 셀 카운트 1,000개</li>
|
|
||||||
<li>• 10MB 파일 업로드</li>
|
|
||||||
<li>• 다운로드/복사 가능</li>
|
|
||||||
</ul>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card className="border-0 bg-white/10 backdrop-blur-sm text-white">
|
|
||||||
<CardContent className="p-6 text-center">
|
|
||||||
<h3 className="mb-2 text-xl font-semibold">Pro</h3>
|
|
||||||
<div className="mb-4">
|
|
||||||
<span className="text-3xl font-bold">₩14,900</span>
|
|
||||||
<span className="text-blue-200">/월</span>
|
|
||||||
</div>
|
|
||||||
<ul className="space-y-2 text-sm text-blue-100">
|
|
||||||
<li>• AI 쿼리 500회</li>
|
|
||||||
<li>• 셀 카운트 5,000개</li>
|
|
||||||
<li>• 50MB 파일 업로드</li>
|
|
||||||
<li>• 모든 기능 포함</li>
|
|
||||||
</ul>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* CTA buttons */}
|
|
||||||
<div className="flex flex-col sm:flex-row items-center justify-center gap-4">
|
|
||||||
<Button
|
|
||||||
size="lg"
|
|
||||||
onClick={onGetStarted}
|
|
||||||
className="bg-white text-blue-600 hover:bg-gray-50 px-8 py-3 text-lg font-semibold shadow-lg hover:shadow-xl transition-all duration-300"
|
|
||||||
aria-label="무료로 시작하기"
|
|
||||||
>
|
|
||||||
무료로 시작하기 →
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="lg"
|
|
||||||
className="border-white text-white hover:bg-white hover:text-blue-600 px-8 py-3 text-lg font-semibold"
|
|
||||||
aria-label="데모 보기"
|
|
||||||
>
|
|
||||||
데모 보기
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Trust indicators */}
|
|
||||||
<div className="mt-12 flex flex-col sm:flex-row items-center justify-center gap-8 text-blue-200">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<svg
|
|
||||||
className="w-5 h-5 text-yellow-400"
|
|
||||||
fill="currentColor"
|
|
||||||
viewBox="0 0 20 20"
|
|
||||||
>
|
|
||||||
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z" />
|
|
||||||
</svg>
|
|
||||||
<span>완전 무료 시작</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<svg
|
|
||||||
className="w-5 h-5 text-green-400"
|
|
||||||
fill="currentColor"
|
|
||||||
viewBox="0 0 20 20"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
fillRule="evenodd"
|
|
||||||
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
|
|
||||||
clipRule="evenodd"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
<span>설치 불필요</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<svg
|
|
||||||
className="w-5 h-5 text-blue-400"
|
|
||||||
fill="currentColor"
|
|
||||||
viewBox="0 0 20 20"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
fillRule="evenodd"
|
|
||||||
d="M5 9V7a5 5 0 0110 0v2a2 2 0 012 2v5a2 2 0 01-2 2H5a2 2 0 01-2-2v-5a2 2 0 012-2zm8-2v2H7V7a3 3 0 016 0z"
|
|
||||||
clipRule="evenodd"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
<span>완전 프라이버시</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
CTASection.displayName = "CTASection";
|
|
||||||
|
|
||||||
export { CTASection };
|
|
||||||
122
src/components/ui/faq-section.tsx
Normal file
122
src/components/ui/faq-section.tsx
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { Card, CardContent } from "./card";
|
||||||
|
import { cn } from "../../lib/utils";
|
||||||
|
|
||||||
|
interface FAQSectionProps {
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FAQSection = React.forwardRef<HTMLElement, FAQSectionProps>(
|
||||||
|
({ className, ...props }, ref) => {
|
||||||
|
const [openIndex, setOpenIndex] = React.useState<number | null>(null);
|
||||||
|
|
||||||
|
const faqs = [
|
||||||
|
{
|
||||||
|
question: "sheetEasy AI는 어떤 서비스인가요?",
|
||||||
|
answer:
|
||||||
|
"AI 기반 Excel 편집 도구로, 자연어 명령으로 수식, 차트, 데이터 분석을 자동 생성합니다. 모든 처리는 브라우저에서 안전하게 이루어집니다.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
question: "파일이 서버로 전송되나요?",
|
||||||
|
answer:
|
||||||
|
"아니요! 모든 파일 처리는 브라우저에서 로컬로 이루어집니다. 귀하의 데이터는 외부로 전송되지 않아 완전한 프라이버시를 보장합니다.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
question: "어떤 파일 형식을 지원하나요?",
|
||||||
|
answer:
|
||||||
|
"Excel(.xlsx, .xls)과 CSV 파일을 지원합니다. 업로드된 파일은 자동으로 최적화되어 처리됩니다.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
question: "AI 기능은 어떻게 작동하나요?",
|
||||||
|
answer:
|
||||||
|
"GPT-4, Claude 등 최신 AI 모델을 활용하여 자연어 명령을 Excel 수식과 차트로 변환합니다. '매출 상위 10개 항목 강조해줘' 같은 명령으로 쉽게 사용할 수 있습니다.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
question: "무료 플랜의 제한사항은 무엇인가요?",
|
||||||
|
answer:
|
||||||
|
"무료 플랜은 월 30회 AI 쿼리, 300개 셀 편집, 10MB 파일 크기 제한이 있습니다. 다운로드와 복사는 제한됩니다.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
question: "데이터는 안전하게 보호되나요?",
|
||||||
|
answer:
|
||||||
|
"네! 모든 데이터는 브라우저에서만 처리되며, 서버 전송 없이 완전한 로컬 처리로 최고 수준의 보안을 제공합니다.",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const toggleFAQ = (index: number) => {
|
||||||
|
setOpenIndex(openIndex === index ? null : index);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
ref={ref}
|
||||||
|
id="faq"
|
||||||
|
className={cn("py-20 sm:py-32 bg-gray-50", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<div className="container">
|
||||||
|
<div className="mx-auto max-w-4xl">
|
||||||
|
{/* Section Header */}
|
||||||
|
<div className="text-center mb-16">
|
||||||
|
<h2 className="text-3xl font-bold tracking-tight text-gray-900 sm:text-4xl mb-4">
|
||||||
|
자주 묻는 질문
|
||||||
|
</h2>
|
||||||
|
<p className="text-lg text-gray-600">
|
||||||
|
sheetEasy AI에 대해 궁금한 점을 해결해 드립니다
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* FAQ Items */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
{faqs.map((faq, index) => (
|
||||||
|
<Card key={index} className="border border-gray-200 shadow-sm">
|
||||||
|
<CardContent className="p-0">
|
||||||
|
<button
|
||||||
|
onClick={() => toggleFAQ(index)}
|
||||||
|
className="w-full text-left p-6 focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-inset"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900">
|
||||||
|
{faq.question}
|
||||||
|
</h3>
|
||||||
|
<div className="ml-6 flex-shrink-0">
|
||||||
|
<svg
|
||||||
|
className={cn(
|
||||||
|
"h-5 w-5 text-gray-500 transition-transform duration-200",
|
||||||
|
openIndex === index ? "rotate-180" : "",
|
||||||
|
)}
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M19 9l-7 7-7-7"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
{openIndex === index && (
|
||||||
|
<div className="px-6 pb-6">
|
||||||
|
<p className="text-gray-600 leading-relaxed">
|
||||||
|
{faq.answer}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
FAQSection.displayName = "FAQSection";
|
||||||
|
|
||||||
|
export { FAQSection };
|
||||||
@@ -74,6 +74,7 @@ const FeaturesSection = React.forwardRef<HTMLElement, FeaturesSectionProps>(
|
|||||||
return (
|
return (
|
||||||
<section
|
<section
|
||||||
ref={ref}
|
ref={ref}
|
||||||
|
id="features"
|
||||||
className={cn("py-20 sm:py-32 bg-white", className)}
|
className={cn("py-20 sm:py-32 bg-white", className)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
@@ -92,28 +93,36 @@ const FeaturesSection = React.forwardRef<HTMLElement, FeaturesSectionProps>(
|
|||||||
{features.map((feature, index) => (
|
{features.map((feature, index) => (
|
||||||
<Card
|
<Card
|
||||||
key={index}
|
key={index}
|
||||||
className="border-0 shadow-lg hover:shadow-xl transition-all duration-300 hover:-translate-y-1 bg-gradient-to-br from-white to-gray-50"
|
className="group border-0 shadow-lg hover:shadow-2xl transition-all duration-500 hover:-translate-y-2 bg-gradient-to-br from-white to-gray-50 hover:from-blue-50 hover:to-purple-50 cursor-pointer overflow-hidden relative"
|
||||||
>
|
>
|
||||||
<CardHeader className="pb-4">
|
{/* Hover 효과를 위한 배경 그라데이션 */}
|
||||||
<div className="mb-4 inline-flex h-12 w-12 items-center justify-center rounded-xl bg-gradient-to-r from-blue-500 to-purple-600">
|
<div className="absolute inset-0 bg-gradient-to-r from-blue-500/5 to-purple-500/5 opacity-0 group-hover:opacity-100 transition-opacity duration-500" />
|
||||||
<span className="text-2xl">{feature.icon}</span>
|
|
||||||
|
<CardHeader className="pb-4 relative z-10">
|
||||||
|
<div className="mb-4 inline-flex h-12 w-12 items-center justify-center rounded-xl bg-gradient-to-r from-blue-500 to-purple-600 group-hover:scale-110 group-hover:from-blue-600 group-hover:to-purple-700 transition-all duration-300 shadow-md group-hover:shadow-lg">
|
||||||
|
<span className="text-2xl group-hover:scale-110 transition-transform duration-300">
|
||||||
|
{feature.icon}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<CardTitle className="text-xl font-semibold text-gray-900">
|
<CardTitle className="text-xl font-semibold text-gray-900 group-hover:text-blue-700 transition-colors duration-300">
|
||||||
{feature.title}
|
{feature.title}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="pt-0">
|
<CardContent className="pt-0 relative z-10">
|
||||||
<p className="text-gray-600 mb-4 leading-relaxed">
|
<p className="text-gray-600 group-hover:text-gray-700 mb-4 leading-relaxed transition-colors duration-300">
|
||||||
{feature.description}
|
{feature.description}
|
||||||
</p>
|
</p>
|
||||||
<ul className="space-y-2">
|
<ul className="space-y-2">
|
||||||
{feature.highlights.map((highlight, highlightIndex) => (
|
{feature.highlights.map((highlight, highlightIndex) => (
|
||||||
<li
|
<li
|
||||||
key={highlightIndex}
|
key={highlightIndex}
|
||||||
className="flex items-start text-sm text-gray-700"
|
className="flex items-start text-sm text-gray-700 group-hover:text-gray-800 transition-colors duration-300"
|
||||||
|
style={{
|
||||||
|
animationDelay: `${highlightIndex * 100}ms`,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
className="mr-2 mt-0.5 h-4 w-4 text-green-500 flex-shrink-0"
|
className="mr-2 mt-0.5 h-4 w-4 text-green-500 group-hover:text-green-600 flex-shrink-0 transition-colors duration-300 group-hover:scale-110"
|
||||||
fill="currentColor"
|
fill="currentColor"
|
||||||
viewBox="0 0 20 20"
|
viewBox="0 0 20 20"
|
||||||
>
|
>
|
||||||
@@ -123,7 +132,9 @@ const FeaturesSection = React.forwardRef<HTMLElement, FeaturesSectionProps>(
|
|||||||
clipRule="evenodd"
|
clipRule="evenodd"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
{highlight}
|
<span className="group-hover:translate-x-1 transition-transform duration-300">
|
||||||
|
{highlight}
|
||||||
|
</span>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@@ -6,13 +6,15 @@ import { cn } from "../../lib/utils";
|
|||||||
interface HeroSectionProps {
|
interface HeroSectionProps {
|
||||||
className?: string;
|
className?: string;
|
||||||
onGetStarted?: () => void;
|
onGetStarted?: () => void;
|
||||||
|
onDemoClick?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const HeroSection = React.forwardRef<HTMLElement, HeroSectionProps>(
|
const HeroSection = React.forwardRef<HTMLElement, HeroSectionProps>(
|
||||||
({ className, onGetStarted, ...props }, ref) => {
|
({ className, onGetStarted, onDemoClick, ...props }, ref) => {
|
||||||
return (
|
return (
|
||||||
<section
|
<section
|
||||||
ref={ref}
|
ref={ref}
|
||||||
|
id="home"
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative overflow-hidden bg-gradient-to-br from-slate-50 to-blue-50 py-20 sm:py-32",
|
"relative overflow-hidden bg-gradient-to-br from-slate-50 to-blue-50 py-20 sm:py-32",
|
||||||
className,
|
className,
|
||||||
@@ -61,10 +63,11 @@ const HeroSection = React.forwardRef<HTMLElement, HeroSectionProps>(
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="lg"
|
size="lg"
|
||||||
|
onClick={onDemoClick}
|
||||||
className="px-8 py-3 text-lg font-semibold"
|
className="px-8 py-3 text-lg font-semibold"
|
||||||
aria-label="기능 둘러보기"
|
aria-label="데모보기"
|
||||||
>
|
>
|
||||||
기능 둘러보기
|
데모보기
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -120,9 +120,9 @@ const HistoryPanel: React.FC<HistoryPanelProps> = ({
|
|||||||
)}
|
)}
|
||||||
style={{
|
style={{
|
||||||
position: "fixed",
|
position: "fixed",
|
||||||
top: 65,
|
top: 64, // App.tsx 헤더와 일치 (h-16 = 64px)
|
||||||
right: 0,
|
right: 0,
|
||||||
height: "100vh",
|
height: "calc(100vh - 64px)", // 헤더 높이만큼 조정
|
||||||
width: "384px", // w-96 = 384px
|
width: "384px", // w-96 = 384px
|
||||||
backgroundColor: "#ffffff",
|
backgroundColor: "#ffffff",
|
||||||
zIndex: 50,
|
zIndex: 50,
|
||||||
|
|||||||
207
src/components/ui/pricing-section.tsx
Normal file
207
src/components/ui/pricing-section.tsx
Normal file
@@ -0,0 +1,207 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { Button } from "./button";
|
||||||
|
import { Card, CardContent } from "./card";
|
||||||
|
import { cn } from "../../lib/utils";
|
||||||
|
|
||||||
|
interface PricingSectionProps {
|
||||||
|
className?: string;
|
||||||
|
onGetStarted?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PricingSection = React.forwardRef<HTMLElement, PricingSectionProps>(
|
||||||
|
({ className, onGetStarted, ...props }, ref) => {
|
||||||
|
const plans = [
|
||||||
|
{
|
||||||
|
name: "Free",
|
||||||
|
price: "0원",
|
||||||
|
period: "월",
|
||||||
|
description: "개인 사용자를 위한 무료 플랜",
|
||||||
|
features: [
|
||||||
|
"월 30회 AI 쿼리",
|
||||||
|
"300개 셀 편집",
|
||||||
|
"10MB 파일 크기 제한",
|
||||||
|
"다운로드 불가",
|
||||||
|
"복사 불가",
|
||||||
|
],
|
||||||
|
buttonText: "무료로 시작하기",
|
||||||
|
buttonVariant: "outline" as const,
|
||||||
|
popular: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Lite",
|
||||||
|
price: "5,900원",
|
||||||
|
period: "월",
|
||||||
|
description: "소규모 팀을 위한 기본 플랜",
|
||||||
|
features: [
|
||||||
|
"월 100회 AI 쿼리",
|
||||||
|
"1,000개 셀 편집",
|
||||||
|
"10MB 파일 크기 제한",
|
||||||
|
"다운로드 가능",
|
||||||
|
"복사 가능",
|
||||||
|
],
|
||||||
|
buttonText: "Lite 시작하기",
|
||||||
|
buttonVariant: "default" as const,
|
||||||
|
popular: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Pro",
|
||||||
|
price: "14,900원",
|
||||||
|
period: "월",
|
||||||
|
description: "전문가를 위한 고급 플랜",
|
||||||
|
features: [
|
||||||
|
"월 500회 AI 쿼리",
|
||||||
|
"5,000개 셀 편집",
|
||||||
|
"50MB 파일 크기 제한",
|
||||||
|
"다운로드 가능",
|
||||||
|
"복사 가능",
|
||||||
|
],
|
||||||
|
buttonText: "Pro 시작하기",
|
||||||
|
buttonVariant: "outline" as const,
|
||||||
|
popular: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Enterprise",
|
||||||
|
price: "협의",
|
||||||
|
period: "",
|
||||||
|
description: "대기업을 위한 맞춤형 플랜",
|
||||||
|
features: [
|
||||||
|
"무제한 AI 쿼리",
|
||||||
|
"무제한 셀 편집",
|
||||||
|
"무제한 파일 크기",
|
||||||
|
"다운로드 가능",
|
||||||
|
"복사 가능",
|
||||||
|
],
|
||||||
|
buttonText: "문의하기",
|
||||||
|
buttonVariant: "outline" as const,
|
||||||
|
popular: false,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
ref={ref}
|
||||||
|
id="pricing"
|
||||||
|
className={cn(
|
||||||
|
"relative py-20 sm:py-32 bg-gradient-to-br from-blue-600 to-purple-700 overflow-hidden",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{/* Background decoration */}
|
||||||
|
<div className="absolute inset-0 bg-grid-white/[0.05] [mask-image:linear-gradient(0deg,transparent,black)]" />
|
||||||
|
|
||||||
|
<div className="container relative">
|
||||||
|
<div className="mx-auto max-w-6xl">
|
||||||
|
{/* Section Header */}
|
||||||
|
<div className="text-center mb-16">
|
||||||
|
<h2 className="text-3xl font-bold tracking-tight text-white sm:text-4xl mb-4">
|
||||||
|
심플하고 투명한 가격
|
||||||
|
</h2>
|
||||||
|
<p className="text-lg text-blue-100">
|
||||||
|
모든 기능을 제한 없이 사용하세요
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pricing Cards */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-8">
|
||||||
|
{plans.map((plan, index) => (
|
||||||
|
<Card
|
||||||
|
key={index}
|
||||||
|
className={cn(
|
||||||
|
"relative shadow-lg hover:shadow-xl transition-all duration-300 bg-white/10 backdrop-blur-sm text-white",
|
||||||
|
plan.popular
|
||||||
|
? "border-2 border-yellow-400 scale-105 shadow-2xl bg-white/15"
|
||||||
|
: "border-0",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{plan.popular && (
|
||||||
|
<div className="absolute -top-3 left-1/2 transform -translate-x-1/2">
|
||||||
|
<span className="bg-yellow-400 text-gray-900 px-4 py-1 rounded-full text-sm font-medium">
|
||||||
|
인기
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<CardContent className="p-6">
|
||||||
|
{/* Plan Header */}
|
||||||
|
<div className="text-center mb-6">
|
||||||
|
<h3 className="text-xl font-bold text-white mb-2">
|
||||||
|
{plan.name}
|
||||||
|
</h3>
|
||||||
|
<div className="mb-2">
|
||||||
|
<span className="text-3xl font-bold text-white">
|
||||||
|
{plan.price}
|
||||||
|
</span>
|
||||||
|
{plan.period && (
|
||||||
|
<span className="text-blue-200">/{plan.period}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-blue-100">
|
||||||
|
{plan.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Features */}
|
||||||
|
<div className="mb-8">
|
||||||
|
<ul className="space-y-3">
|
||||||
|
{plan.features.map((feature, featureIndex) => (
|
||||||
|
<li key={featureIndex} className="flex items-start">
|
||||||
|
<div className="flex-shrink-0 mr-3 mt-1">
|
||||||
|
<svg
|
||||||
|
className="h-4 w-4 text-yellow-400"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M5 13l4 4L19 7"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<span className="text-sm text-blue-100">
|
||||||
|
{feature}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* CTA Button */}
|
||||||
|
<Button
|
||||||
|
variant={plan.buttonVariant}
|
||||||
|
className={cn(
|
||||||
|
"w-full",
|
||||||
|
plan.popular
|
||||||
|
? "bg-yellow-400 hover:bg-yellow-500 text-gray-900 font-semibold"
|
||||||
|
: "bg-white/20 hover:bg-white/30 text-white border-white/30",
|
||||||
|
)}
|
||||||
|
onClick={onGetStarted}
|
||||||
|
>
|
||||||
|
{plan.buttonText}
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Additional Info */}
|
||||||
|
<div className="mt-12 text-center">
|
||||||
|
<p className="text-sm text-blue-200">
|
||||||
|
• 모든 플랜은 언제든지 취소 가능합니다 • AI 쿼리 또는 셀 카운트
|
||||||
|
중 하나라도 도달하면 제한됩니다 • 결제는 한국 원화(KRW)로
|
||||||
|
진행됩니다
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
PricingSection.displayName = "PricingSection";
|
||||||
|
|
||||||
|
export { PricingSection };
|
||||||
@@ -1,15 +1,81 @@
|
|||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { Button } from "./button";
|
import { Button } from "./button";
|
||||||
import { cn } from "../../lib/utils";
|
import { cn } from "../../lib/utils";
|
||||||
|
import { useAppStore } from "../../stores/useAppStore";
|
||||||
|
|
||||||
interface TopBarProps {
|
interface TopBarProps {
|
||||||
className?: string;
|
className?: string;
|
||||||
onDownloadClick?: () => void;
|
onDownloadClick?: () => void;
|
||||||
onAccountClick?: () => void;
|
onAccountClick?: () => void;
|
||||||
|
onSignInClick?: () => void;
|
||||||
|
onGetStartedClick?: () => void;
|
||||||
|
onLogoClick?: () => void;
|
||||||
|
showDownload?: boolean;
|
||||||
|
showAccount?: boolean;
|
||||||
|
showNavigation?: boolean;
|
||||||
|
showAuthButtons?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const TopBar = React.forwardRef<HTMLElement, TopBarProps>(
|
const TopBar = React.forwardRef<HTMLElement, TopBarProps>(
|
||||||
({ className, onDownloadClick, onAccountClick, ...props }, ref) => {
|
(
|
||||||
|
{
|
||||||
|
className,
|
||||||
|
onDownloadClick,
|
||||||
|
onAccountClick,
|
||||||
|
onSignInClick,
|
||||||
|
onGetStartedClick,
|
||||||
|
onLogoClick,
|
||||||
|
showDownload = true,
|
||||||
|
showAccount = true,
|
||||||
|
showNavigation = false,
|
||||||
|
showAuthButtons = false,
|
||||||
|
...props
|
||||||
|
},
|
||||||
|
ref,
|
||||||
|
) => {
|
||||||
|
const { currentFile, user, isAuthenticated } = useAppStore();
|
||||||
|
|
||||||
|
// 기본 다운로드 핸들러 - 현재 파일을 XLSX로 다운로드
|
||||||
|
const handleDownload = () => {
|
||||||
|
if (onDownloadClick) {
|
||||||
|
onDownloadClick();
|
||||||
|
} else if (currentFile?.xlsxBuffer) {
|
||||||
|
// XLSX ArrayBuffer를 Blob으로 변환하여 다운로드
|
||||||
|
const blob = new Blob([currentFile.xlsxBuffer], {
|
||||||
|
type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||||
|
});
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.href = url;
|
||||||
|
link.download = currentFile.name || "sheet.xlsx";
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
} else {
|
||||||
|
alert("다운로드할 파일이 없습니다. 파일을 먼저 업로드해주세요.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 기본 계정 핸들러
|
||||||
|
const handleAccount = () => {
|
||||||
|
if (onAccountClick) {
|
||||||
|
onAccountClick();
|
||||||
|
} else {
|
||||||
|
// 기본 동작 - 계정 페이지로 이동 (추후 구현)
|
||||||
|
console.log("계정 페이지로 이동");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 네비게이션 메뉴 핸들러들
|
||||||
|
const handleNavigation = (section: string) => {
|
||||||
|
// 랜딩 페이지의 해당 섹션으로 스크롤
|
||||||
|
const element = document.getElementById(section);
|
||||||
|
if (element) {
|
||||||
|
element.scrollIntoView({ behavior: "smooth" });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header
|
<header
|
||||||
ref={ref}
|
ref={ref}
|
||||||
@@ -22,56 +88,144 @@ const TopBar = React.forwardRef<HTMLElement, TopBarProps>(
|
|||||||
<div className="container flex h-16 items-center justify-between">
|
<div className="container flex h-16 items-center justify-between">
|
||||||
{/* Logo */}
|
{/* Logo */}
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<div className="flex items-center space-x-2">
|
<button
|
||||||
|
className="flex items-center space-x-2 hover:opacity-80 transition-opacity"
|
||||||
|
onClick={onLogoClick}
|
||||||
|
aria-label="홈으로 이동"
|
||||||
|
>
|
||||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-gradient-to-r from-green-500 to-blue-600">
|
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-gradient-to-r from-green-500 to-blue-600">
|
||||||
<span className="text-sm font-bold text-white">📊</span>
|
<span className="text-sm font-bold text-white">📊</span>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-xl font-bold bg-gradient-to-r from-green-600 to-blue-600 bg-clip-text text-transparent">
|
<span className="text-xl font-bold bg-gradient-to-r from-green-600 to-blue-600 bg-clip-text text-transparent">
|
||||||
sheetEasy AI
|
sheetEasy AI
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Navigation Menu - Vooster.ai 스타일 */}
|
||||||
|
{showNavigation && (
|
||||||
|
<nav className="hidden md:flex items-center space-x-8">
|
||||||
|
<button
|
||||||
|
onClick={() => handleNavigation("home")}
|
||||||
|
className="text-sm font-medium text-gray-700 hover:text-green-600 transition-colors"
|
||||||
|
>
|
||||||
|
홈
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleNavigation("features")}
|
||||||
|
className="text-sm font-medium text-gray-700 hover:text-green-600 transition-colors"
|
||||||
|
>
|
||||||
|
기능소개
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleNavigation("faq")}
|
||||||
|
className="text-sm font-medium text-gray-700 hover:text-green-600 transition-colors"
|
||||||
|
>
|
||||||
|
FAQ
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleNavigation("pricing")}
|
||||||
|
className="text-sm font-medium text-gray-700 hover:text-green-600 transition-colors"
|
||||||
|
>
|
||||||
|
가격
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
<div className="flex items-center space-x-4">
|
<div className="flex items-center space-x-4">
|
||||||
<Button
|
{/* Auth Buttons - 랜딩 페이지용 */}
|
||||||
variant="outline"
|
{showAuthButtons && (
|
||||||
size="sm"
|
<>
|
||||||
onClick={onDownloadClick}
|
<Button
|
||||||
className="hidden sm:inline-flex"
|
variant="ghost"
|
||||||
aria-label="파일 다운로드"
|
size="sm"
|
||||||
>
|
onClick={onSignInClick}
|
||||||
<svg
|
className="hidden sm:inline-flex text-gray-700 hover:text-green-600"
|
||||||
className="mr-2 h-4 w-4"
|
>
|
||||||
fill="none"
|
로그인
|
||||||
stroke="currentColor"
|
</Button>
|
||||||
viewBox="0 0 24 24"
|
<Button
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
size="sm"
|
||||||
>
|
onClick={onGetStartedClick}
|
||||||
<path
|
className="bg-green-600 hover:bg-green-700 text-white"
|
||||||
strokeLinecap="round"
|
>
|
||||||
strokeLinejoin="round"
|
무료로 시작하기
|
||||||
strokeWidth={2}
|
</Button>
|
||||||
d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
|
</>
|
||||||
/>
|
)}
|
||||||
</svg>
|
|
||||||
Download
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
{/* Download 버튼 - 에디터용 */}
|
||||||
variant="ghost"
|
{showDownload && (
|
||||||
size="sm"
|
<Button
|
||||||
onClick={onAccountClick}
|
variant="outline"
|
||||||
className="flex items-center space-x-2"
|
size="sm"
|
||||||
aria-label="계정 설정"
|
onClick={handleDownload}
|
||||||
>
|
className="hidden sm:inline-flex hover:bg-green-50 hover:border-green-300"
|
||||||
<div className="h-8 w-8 rounded-full bg-gradient-to-r from-purple-500 to-pink-500 flex items-center justify-center">
|
aria-label="파일 다운로드"
|
||||||
<span className="text-xs font-medium text-white">User</span>
|
disabled={!currentFile?.xlsxBuffer}
|
||||||
</div>
|
>
|
||||||
<span className="hidden sm:inline-block text-sm font-medium">
|
<svg
|
||||||
Account
|
className="mr-2 h-4 w-4"
|
||||||
</span>
|
fill="none"
|
||||||
</Button>
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
다운로드
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Account 버튼 - 로그인 시 */}
|
||||||
|
{showAccount && isAuthenticated && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleAccount}
|
||||||
|
className="flex items-center space-x-2 hover:bg-purple-50"
|
||||||
|
aria-label="계정 설정"
|
||||||
|
>
|
||||||
|
<div className="h-8 w-8 rounded-full bg-gradient-to-r from-purple-500 to-pink-500 flex items-center justify-center">
|
||||||
|
<span className="text-xs font-medium text-white">
|
||||||
|
{user?.name?.charAt(0).toUpperCase() || "U"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span className="hidden sm:inline-block text-sm font-medium">
|
||||||
|
{user?.name || "계정"}
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Mobile Menu Button - 모바일용 */}
|
||||||
|
{showNavigation && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="md:hidden"
|
||||||
|
aria-label="메뉴 열기"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className="h-5 w-5"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M4 6h16M4 12h16M4 18h16"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
Reference in New Issue
Block a user