feat: 랜딩페이지 완성 및 에디터 홈 버튼 기능 구현

주요 변경사항:
- 🎨 가격 섹션을 CTA 섹션과 동일한 그라디언트 스타일로 변경
- 🗑️ 중복된 CTA 섹션 제거 및 페이지 구성 최적화
- 🏠 에디터 상단 로고 클릭 시 홈 이동 기능 추가 (저장 경고 포함)
- 📱 인증 페이지 컴포넌트 추가 (SignIn/SignUp)
- 💰 가격 정보 섹션 및 FAQ 섹션 추가
- 🔧 TopBar 컴포넌트에 로고 클릭 핸들러 추가

UI/UX 개선:
- 가격 섹션: 파란색-보라색 그라디언트 배경 적용
- 카드 스타일: 반투명 배경 및 backdrop-blur 효과
- 텍스트 색상: 그라디언트 배경에 맞는 흰색/파란색 톤 적용
- 버튼 스타일: 인기 플랜 노란색 강조, 일반 플랜 반투명 스타일

기능 추가:
- 에디터에서 로고 클릭 시 작업 손실 경고 및 홈 이동
- 완전한 인증 플로우 UI 구성
- 반응형 가격 정보 표시
This commit is contained in:
sheetEasy AI Team
2025-06-30 15:30:21 +09:00
parent 3d0a5799ff
commit 535281f0fb
12 changed files with 1608 additions and 445 deletions

View File

@@ -1,71 +1,335 @@
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");
const handleDownloadClick = () => { } else {
// TODO: 다운로드 기능 구현 // 미인증 사용자는 가입 페이지로 이동
console.log("Download clicked"); setCurrentView("signUp");
};
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);
} }
}} };
>
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)]"> <main className="h-[calc(100vh-4rem)]">
<div className="h-full"> <div className="h-full">
<Suspense <Suspense
@@ -83,16 +347,33 @@ function App() {
</div> </div>
</main> </main>
</div> </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 <LandingPage
onGetStarted={handleGetStarted} onGetStarted={handleGetStarted}
onDownloadClick={handleDownloadClick} onDownloadClick={handleDownloadClick}
onAccountClick={handleAccountClick} onAccountClick={handleAccountClick}
onDemoClick={handleDemoClick}
/> />
)}
</div> </div>
); );
}
};
return <div className="min-h-screen">{renderCurrentView()}</div>;
} }
export default App; export default App;

View File

@@ -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 - 푸터 정보 */}

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

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

View File

@@ -914,41 +914,28 @@ const TestSheetViewer: React.FC = () => {
{/* 파일 업로드 오버레이 - 레이어 분리 */} {/* 파일 업로드 오버레이 - 레이어 분리 */}
{showUploadOverlay && ( {showUploadOverlay && (
<>
{/* 1. Univer CE 영역만 흐리게 하는 반투명 레이어 */}
<div <div
style={{ style={{
position: "absolute", position: "absolute",
top: "0px", // 헤더 높이만큼 아래로 (헤더는 약 80px) top: 0,
left: 0, left: 0,
right: 0, right: 0,
bottom: 0, bottom: 0,
zIndex: 40, zIndex: 40,
backgroundColor: "rgba(255, 255, 255, 0.01)", backgroundColor: "rgba(255, 255, 255, 0.01)",
backdropFilter: "blur(8px)", backdropFilter: "blur(8px)",
WebkitBackdropFilter: "blur(8px)", // Safari 지원 WebkitBackdropFilter: "blur(8px)",
pointerEvents: "auto",
}}
/>
{/* 2. Univer 영역 중앙의 업로드 UI */}
<div
style={{
position: "absolute",
top: "-100px", // 헤더 높이만큼 아래로
left: 0,
right: 0,
bottom: 0,
display: "flex", display: "flex",
alignItems: "center", alignItems: "center",
justifyContent: "center", justifyContent: "center",
padding: "1rem", pointerEvents: "auto",
zIndex: 50,
}} }}
onClick={() => setShowUploadOverlay(false)}
> >
<div <div
className="max-w-2xl w-full" className="max-w-2xl w-full"
style={{ transform: "scale(0.8)" }} style={{ transform: "scale(0.8)" }}
onClick={(e) => e.stopPropagation()}
> >
<div <div
className="bg-white rounded-lg shadow-xl border p-8 md:p-12" className="bg-white rounded-lg shadow-xl border p-8 md:p-12"
@@ -1082,7 +1069,6 @@ const TestSheetViewer: React.FC = () => {
</div> </div>
</div> </div>
</div> </div>
</>
)} )}
{/* 프롬프트 입력창 - Univer 위 오버레이 */} {/* 프롬프트 입력창 - Univer 위 오버레이 */}

View File

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

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

View File

@@ -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>
<span className="group-hover:translate-x-1 transition-transform duration-300">
{highlight} {highlight}
</span>
</li> </li>
))} ))}
</ul> </ul>

View File

@@ -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>

View File

@@ -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,

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

View File

@@ -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,24 +88,82 @@ 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>
</button>
</div> </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">
{/* 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 <Button
variant="outline" variant="outline"
size="sm" size="sm"
onClick={onDownloadClick} onClick={handleDownload}
className="hidden sm:inline-flex" className="hidden sm:inline-flex hover:bg-green-50 hover:border-green-300"
aria-label="파일 다운로드" aria-label="파일 다운로드"
disabled={!currentFile?.xlsxBuffer}
> >
<svg <svg
className="mr-2 h-4 w-4" 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" 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> </svg>
Download
</Button> </Button>
)}
{/* Account 버튼 - 로그인 시 */}
{showAccount && isAuthenticated && (
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
onClick={onAccountClick} onClick={handleAccount}
className="flex items-center space-x-2" className="flex items-center space-x-2 hover:bg-purple-50"
aria-label="계정 설정" 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"> <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> </div>
<span className="hidden sm:inline-block text-sm font-medium"> <span className="hidden sm:inline-block text-sm font-medium">
Account {user?.name || "계정"}
</span> </span>
</Button> </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>