feat: 랜딩페이지 완성 및 에디터 홈 버튼 기능 구현
주요 변경사항: - 🎨 가격 섹션을 CTA 섹션과 동일한 그라디언트 스타일로 변경 - 🗑️ 중복된 CTA 섹션 제거 및 페이지 구성 최적화 - 🏠 에디터 상단 로고 클릭 시 홈 이동 기능 추가 (저장 경고 포함) - 📱 인증 페이지 컴포넌트 추가 (SignIn/SignUp) - 💰 가격 정보 섹션 및 FAQ 섹션 추가 - 🔧 TopBar 컴포넌트에 로고 클릭 핸들러 추가 UI/UX 개선: - 가격 섹션: 파란색-보라색 그라디언트 배경 적용 - 카드 스타일: 반투명 배경 및 backdrop-blur 효과 - 텍스트 색상: 그라디언트 배경에 맞는 흰색/파란색 톤 적용 - 버튼 스타일: 인기 플랜 노란색 강조, 일반 플랜 반투명 스타일 기능 추가: - 에디터에서 로고 클릭 시 작업 손실 경고 및 홈 이동 - 완전한 인증 플로우 UI 구성 - 반응형 가격 정보 표시
This commit is contained in:
393
src/App.tsx
393
src/App.tsx
@@ -1,71 +1,335 @@
|
||||
import { useState } from "react";
|
||||
import { Button } from "./components/ui/button";
|
||||
import HomeButton from "./components/ui/homeButton";
|
||||
import { TopBar } from "./components/ui/topbar";
|
||||
import { lazy, Suspense } from "react";
|
||||
import LandingPage from "./components/LandingPage";
|
||||
import { SignUpPage } from "./components/auth/SignUpPage";
|
||||
import { SignInPage } from "./components/auth/SignInPage";
|
||||
import { useAppStore } from "./stores/useAppStore";
|
||||
|
||||
// 동적 import로 EditSheetViewer 로드 (필요할 때만)
|
||||
const EditSheetViewer = lazy(
|
||||
() => import("./components/sheet/EditSheetViewer"),
|
||||
);
|
||||
|
||||
// 앱 상태 타입 정의
|
||||
type AppView = "landing" | "signUp" | "signIn" | "editor" | "account";
|
||||
|
||||
function App() {
|
||||
const [showTestViewer, setShowTestViewer] = useState(false);
|
||||
const [currentView, setCurrentView] = useState<AppView>("landing");
|
||||
const { isAuthenticated, setAuthenticated, setUser, user, currentFile } =
|
||||
useAppStore();
|
||||
|
||||
// CTA 버튼 클릭 핸들러 - 인증 상태에 따른 분기 처리
|
||||
const handleGetStarted = () => {
|
||||
setShowTestViewer(true);
|
||||
};
|
||||
|
||||
const handleDownloadClick = () => {
|
||||
// TODO: 다운로드 기능 구현
|
||||
console.log("Download clicked");
|
||||
};
|
||||
|
||||
const handleAccountClick = () => {
|
||||
// TODO: 계정 페이지 이동 기능 구현
|
||||
console.log("Account clicked");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
{showTestViewer ? (
|
||||
// 에디트 뷰어 모드
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
{/* 헤더 */}
|
||||
<header className="bg-white shadow-sm border-b">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex justify-between items-center h-16">
|
||||
<div className="flex items-center">
|
||||
<HomeButton
|
||||
className="ml-0"
|
||||
style={{ position: "absolute", left: "1%" }}
|
||||
onClick={() => {
|
||||
if (window.confirm("랜딩페이지로 돌아가시겠습니까?")) {
|
||||
setShowTestViewer(false);
|
||||
if (isAuthenticated) {
|
||||
// 이미 로그인된 사용자는 바로 에디터로 이동
|
||||
setCurrentView("editor");
|
||||
} else {
|
||||
// 미인증 사용자는 가입 페이지로 이동
|
||||
setCurrentView("signUp");
|
||||
}
|
||||
}}
|
||||
>
|
||||
sheetEasy AI
|
||||
</HomeButton>
|
||||
</div>
|
||||
<div className="flex items-center space-x-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setShowTestViewer(false)}
|
||||
className="bg-green-500 hover:bg-green-600 text-white border-green-500"
|
||||
>
|
||||
🏠 홈으로
|
||||
</Button>
|
||||
<span className="text-sm text-gray-600">
|
||||
Univer CE 에디트 모드
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
};
|
||||
|
||||
{/* 메인 콘텐츠 */}
|
||||
// 다운로드 클릭 핸들러
|
||||
const handleDownloadClick = () => {
|
||||
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 handleAccountClick = () => {
|
||||
if (isAuthenticated) {
|
||||
setCurrentView("account");
|
||||
} else {
|
||||
setCurrentView("signIn");
|
||||
}
|
||||
};
|
||||
|
||||
// 데모보기 버튼 클릭 핸들러 - 에디터로 바로 이동
|
||||
const handleDemoClick = () => {
|
||||
setCurrentView("editor");
|
||||
};
|
||||
|
||||
// 가입 처리 핸들러
|
||||
const handleSignUp = (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: "free",
|
||||
status: "active",
|
||||
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 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
|
||||
onClick={handleGoToEditor}
|
||||
className="bg-blue-600 hover:bg-blue-700 text-white"
|
||||
>
|
||||
에디터로 이동
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleLogout}
|
||||
variant="outline"
|
||||
className="border-red-500 text-red-500 hover:bg-red-50"
|
||||
>
|
||||
로그아웃
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
|
||||
case "editor":
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<TopBar
|
||||
showDownload={true}
|
||||
showAccount={true}
|
||||
showNavigation={false}
|
||||
showAuthButtons={false}
|
||||
onDownloadClick={handleDownloadClick}
|
||||
onAccountClick={handleAccountClick}
|
||||
onLogoClick={handleEditorLogoClick}
|
||||
/>
|
||||
<main className="h-[calc(100vh-4rem)]">
|
||||
<div className="h-full">
|
||||
<Suspense
|
||||
@@ -83,16 +347,33 @@ function App() {
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
) : (
|
||||
// 랜딩페이지 모드
|
||||
);
|
||||
|
||||
case "landing":
|
||||
default:
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
<TopBar
|
||||
showDownload={false}
|
||||
showAccount={false}
|
||||
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;
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
import * as React from "react";
|
||||
import { TopBar } from "./ui/topbar";
|
||||
import { HeroSection } from "./ui/hero-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";
|
||||
|
||||
interface LandingPageProps {
|
||||
onGetStarted?: () => void;
|
||||
onDownloadClick?: () => void;
|
||||
onAccountClick?: () => void;
|
||||
onDemoClick?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -22,25 +23,23 @@ const LandingPage: React.FC<LandingPageProps> = ({
|
||||
onGetStarted,
|
||||
onDownloadClick,
|
||||
onAccountClick,
|
||||
onDemoClick,
|
||||
}) => {
|
||||
return (
|
||||
<div className="min-h-screen bg-white">
|
||||
{/* TopBar - 상단 고정 네비게이션 */}
|
||||
<TopBar
|
||||
onDownloadClick={onDownloadClick}
|
||||
onAccountClick={onAccountClick}
|
||||
/>
|
||||
|
||||
{/* Main Content */}
|
||||
<main role="main">
|
||||
{/* Hero Section - 메인 소개 및 CTA */}
|
||||
<HeroSection onGetStarted={onGetStarted} />
|
||||
<HeroSection onGetStarted={onGetStarted} onDemoClick={onDemoClick} />
|
||||
|
||||
{/* Features Section - 주요 기능 소개 */}
|
||||
<FeaturesSection />
|
||||
|
||||
{/* CTA Section - 최종 행동 유도 */}
|
||||
<CTASection onGetStarted={onGetStarted} />
|
||||
{/* FAQ Section - 자주 묻는 질문 */}
|
||||
<FAQSection />
|
||||
|
||||
{/* Pricing Section - 가격 정보 */}
|
||||
<PricingSection onGetStarted={onGetStarted} />
|
||||
</main>
|
||||
|
||||
{/* 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,41 +914,28 @@ const TestSheetViewer: React.FC = () => {
|
||||
|
||||
{/* 파일 업로드 오버레이 - 레이어 분리 */}
|
||||
{showUploadOverlay && (
|
||||
<>
|
||||
{/* 1. Univer CE 영역만 흐리게 하는 반투명 레이어 */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "0px", // 헤더 높이만큼 아래로 (헤더는 약 80px)
|
||||
top: 0,
|
||||
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,
|
||||
WebkitBackdropFilter: "blur(8px)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
padding: "1rem",
|
||||
zIndex: 50,
|
||||
pointerEvents: "auto",
|
||||
}}
|
||||
onClick={() => setShowUploadOverlay(false)}
|
||||
>
|
||||
<div
|
||||
className="max-w-2xl w-full"
|
||||
style={{ transform: "scale(0.8)" }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div
|
||||
className="bg-white rounded-lg shadow-xl border p-8 md:p-12"
|
||||
@@ -1082,7 +1069,6 @@ const TestSheetViewer: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 프롬프트 입력창 - 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 (
|
||||
<section
|
||||
ref={ref}
|
||||
id="features"
|
||||
className={cn("py-20 sm:py-32 bg-white", className)}
|
||||
{...props}
|
||||
>
|
||||
@@ -92,28 +93,36 @@ const FeaturesSection = React.forwardRef<HTMLElement, FeaturesSectionProps>(
|
||||
{features.map((feature, index) => (
|
||||
<Card
|
||||
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">
|
||||
<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">
|
||||
<span className="text-2xl">{feature.icon}</span>
|
||||
{/* Hover 효과를 위한 배경 그라데이션 */}
|
||||
<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" />
|
||||
|
||||
<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>
|
||||
<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}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0">
|
||||
<p className="text-gray-600 mb-4 leading-relaxed">
|
||||
<CardContent className="pt-0 relative z-10">
|
||||
<p className="text-gray-600 group-hover:text-gray-700 mb-4 leading-relaxed transition-colors duration-300">
|
||||
{feature.description}
|
||||
</p>
|
||||
<ul className="space-y-2">
|
||||
{feature.highlights.map((highlight, highlightIndex) => (
|
||||
<li
|
||||
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
|
||||
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"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
@@ -123,7 +132,9 @@ const FeaturesSection = React.forwardRef<HTMLElement, FeaturesSectionProps>(
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<span className="group-hover:translate-x-1 transition-transform duration-300">
|
||||
{highlight}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
@@ -6,13 +6,15 @@ import { cn } from "../../lib/utils";
|
||||
interface HeroSectionProps {
|
||||
className?: string;
|
||||
onGetStarted?: () => void;
|
||||
onDemoClick?: () => void;
|
||||
}
|
||||
|
||||
const HeroSection = React.forwardRef<HTMLElement, HeroSectionProps>(
|
||||
({ className, onGetStarted, ...props }, ref) => {
|
||||
({ className, onGetStarted, onDemoClick, ...props }, ref) => {
|
||||
return (
|
||||
<section
|
||||
ref={ref}
|
||||
id="home"
|
||||
className={cn(
|
||||
"relative overflow-hidden bg-gradient-to-br from-slate-50 to-blue-50 py-20 sm:py-32",
|
||||
className,
|
||||
@@ -61,10 +63,11 @@ const HeroSection = React.forwardRef<HTMLElement, HeroSectionProps>(
|
||||
<Button
|
||||
variant="outline"
|
||||
size="lg"
|
||||
onClick={onDemoClick}
|
||||
className="px-8 py-3 text-lg font-semibold"
|
||||
aria-label="기능 둘러보기"
|
||||
aria-label="데모보기"
|
||||
>
|
||||
기능 둘러보기
|
||||
데모보기
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -120,9 +120,9 @@ const HistoryPanel: React.FC<HistoryPanelProps> = ({
|
||||
)}
|
||||
style={{
|
||||
position: "fixed",
|
||||
top: 65,
|
||||
top: 64, // App.tsx 헤더와 일치 (h-16 = 64px)
|
||||
right: 0,
|
||||
height: "100vh",
|
||||
height: "calc(100vh - 64px)", // 헤더 높이만큼 조정
|
||||
width: "384px", // w-96 = 384px
|
||||
backgroundColor: "#ffffff",
|
||||
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 { Button } from "./button";
|
||||
import { cn } from "../../lib/utils";
|
||||
import { useAppStore } from "../../stores/useAppStore";
|
||||
|
||||
interface TopBarProps {
|
||||
className?: string;
|
||||
onDownloadClick?: () => void;
|
||||
onAccountClick?: () => void;
|
||||
onSignInClick?: () => void;
|
||||
onGetStartedClick?: () => void;
|
||||
onLogoClick?: () => void;
|
||||
showDownload?: boolean;
|
||||
showAccount?: boolean;
|
||||
showNavigation?: boolean;
|
||||
showAuthButtons?: boolean;
|
||||
}
|
||||
|
||||
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 (
|
||||
<header
|
||||
ref={ref}
|
||||
@@ -22,24 +88,82 @@ const TopBar = React.forwardRef<HTMLElement, TopBarProps>(
|
||||
<div className="container flex h-16 items-center justify-between">
|
||||
{/* Logo */}
|
||||
<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">
|
||||
<span className="text-sm font-bold text-white">📊</span>
|
||||
</div>
|
||||
<span className="text-xl font-bold bg-gradient-to-r from-green-600 to-blue-600 bg-clip-text text-transparent">
|
||||
sheetEasy AI
|
||||
</span>
|
||||
</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 */}
|
||||
<div className="flex items-center space-x-4">
|
||||
{/* Auth Buttons - 랜딩 페이지용 */}
|
||||
{showAuthButtons && (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onSignInClick}
|
||||
className="hidden sm:inline-flex text-gray-700 hover:text-green-600"
|
||||
>
|
||||
로그인
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={onGetStartedClick}
|
||||
className="bg-green-600 hover:bg-green-700 text-white"
|
||||
>
|
||||
무료로 시작하기
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Download 버튼 - 에디터용 */}
|
||||
{showDownload && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onDownloadClick}
|
||||
className="hidden sm:inline-flex"
|
||||
onClick={handleDownload}
|
||||
className="hidden sm:inline-flex hover:bg-green-50 hover:border-green-300"
|
||||
aria-label="파일 다운로드"
|
||||
disabled={!currentFile?.xlsxBuffer}
|
||||
>
|
||||
<svg
|
||||
className="mr-2 h-4 w-4"
|
||||
@@ -55,23 +179,53 @@ const TopBar = React.forwardRef<HTMLElement, TopBarProps>(
|
||||
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>
|
||||
)}
|
||||
|
||||
{/* Account 버튼 - 로그인 시 */}
|
||||
{showAccount && isAuthenticated && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onAccountClick}
|
||||
className="flex items-center space-x-2"
|
||||
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</span>
|
||||
<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">
|
||||
Account
|
||||
{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>
|
||||
</header>
|
||||
|
||||
Reference in New Issue
Block a user