Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 20 additions & 11 deletions frontend/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,17 @@
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { useAuthStore } from '@/src/features/auth/store';
import { Landing } from '@/src/shared/components/Landing';

/**
* 루트 진입점. 별도 랜딩 화면 없이 인증 상태에 따라 리다이렉트한다.
* - 로그인 상태(토큰 보유) → /dashboard
* - 비로그인 → /login
* 루트 진입점. 인증 상태에 따라 화면을 분기한다.
* - 로그인 상태(토큰 보유) → /dashboard 로 리다이렉트
* - 비로그인 → 랜딩 페이지(<Landing/>)를 렌더 (리다이렉트하지 않음)
*
* 토큰은 zustand persist(localStorage)에서 복원되지만, SSR/CSR 간
* 하이드레이션 미스매치를 피하려고 mount 이후에만 판별·이동한다.
* 하이드레이션 미스매치를 피하려고 mount 이후에만 판별한다.
* 마운트 전에는 깜빡임을 막기 위해 빈 화면을 잠깐 보여주고,
* 마운트 후 토큰이 없으면 랜딩을, 토큰이 있으면 대시보드로 보낸다.
*/
export default function Home() {
const router = useRouter();
Expand All @@ -22,14 +25,20 @@ export default function Home() {
}, []);

useEffect(() => {
if (mounted) {
router.replace(token ? '/dashboard' : '/login');
if (mounted && token) {
router.replace('/dashboard');
}
}, [mounted, token, router]);

return (
<div className="flex min-h-screen items-center justify-center bg-gray-50">
<span className="text-sm text-gray-500">로딩 중...</span>
</div>
);
// 인증된 사용자는 대시보드로 이동 중이므로 랜딩 대신 빈 화면을 잠깐 노출.
if (mounted && token) {
return (
<div className="flex min-h-screen items-center justify-center bg-gray-50">
<span className="text-sm text-gray-500">로딩 중...</span>
</div>
);
}

// 미인증 방문자(또는 마운트 전)에게는 랜딩 페이지를 보여준다.
return <Landing />;
}
73 changes: 73 additions & 0 deletions frontend/app/privacy/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import Link from 'next/link';
import { Container } from '@/src/shared/components/ui';
import { Footer } from '@/src/shared/components/Footer';

export default function PrivacyPage() {
return (
<div className="min-h-screen bg-white">
<Container className="py-12">
<h1 className="text-3xl font-bold text-gray-900">개인정보 처리방침</h1>
<p className="mt-2 text-sm text-gray-500">시행일: 2026년 6월 14일</p>

<div className="mt-8 space-y-6 text-gray-700 leading-relaxed">
<p>
본 개인정보 처리방침은 현재 서비스 준비 중인 상태로, 정식 출시 전
임시 플레이스홀더 문서입니다. 실제 내용은 서비스 정식 출시 시점에
업데이트될 예정입니다.
</p>

<section>
<h2 className="text-xl font-semibold text-gray-900">
1. 수집하는 개인정보 항목
</h2>
<p className="mt-2">
서비스는 회원 가입 및 서비스 제공을 위해 이메일 주소 등 최소한의
정보를 수집할 수 있습니다. 구체적인 수집 항목은 정식 출시 시점에
명시됩니다.
</p>
</section>

<section>
<h2 className="text-xl font-semibold text-gray-900">
2. 개인정보의 이용 목적
</h2>
<p className="mt-2">
수집한 개인정보는 회원 식별, 서비스 제공 및 운영, 고객 문의 응대
등의 목적으로만 이용되며, 명시된 목적 외의 용도로는 사용되지
않습니다.
</p>
</section>

<section>
<h2 className="text-xl font-semibold text-gray-900">
3. 개인정보의 보관 기간
</h2>
<p className="mt-2">
개인정보는 수집 및 이용 목적이 달성된 후 관련 법령에 따른 보관
기간을 준수하여 지체 없이 파기합니다. 구체적인 보관 기간은 정식
방침에서 안내됩니다.
</p>
</section>

<section>
<h2 className="text-xl font-semibold text-gray-900">
4. 이용자의 권리
</h2>
<p className="mt-2">
이용자는 언제든지 자신의 개인정보에 대한 열람·정정·삭제 및 처리
정지를 요청할 수 있습니다. 요청 방법은 정식 출시 시점에
안내됩니다.
</p>
</section>
</div>

<div className="mt-12">
<Link href="/" className="text-brand-600 hover:text-brand-700">
← 홈으로 돌아가기
</Link>
</div>
</Container>
<Footer />
</div>
);
}
61 changes: 61 additions & 0 deletions frontend/app/terms/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import Link from 'next/link';
import { Container } from '@/src/shared/components/ui';
import { Footer } from '@/src/shared/components/Footer';

export default function TermsPage() {
return (
<div className="min-h-screen bg-white">
<Container className="py-12">
<h1 className="text-3xl font-bold text-gray-900">이용약관</h1>
<p className="mt-2 text-sm text-gray-500">시행일: 2026년 6월 14일</p>

<div className="mt-8 space-y-6 text-gray-700 leading-relaxed">
<p>
본 이용약관은 현재 서비스 준비 중인 상태로, 정식 출시 전 임시
플레이스홀더 문서입니다. 실제 약관 내용은 서비스 정식 출시 시점에
업데이트될 예정입니다.
</p>

<section>
<h2 className="text-xl font-semibold text-gray-900">제1조 (목적)</h2>
<p className="mt-2">
본 약관은 Coupang Review AI(이하 &ldquo;서비스&rdquo;)가 제공하는
리뷰 분석 및 인사이트 제공 서비스의 이용 조건과 절차, 이용자와
서비스 운영자의 권리·의무 및 책임 사항을 규정함을 목적으로 합니다.
</p>
</section>

<section>
<h2 className="text-xl font-semibold text-gray-900">
제2조 (약관의 효력 및 변경)
</h2>
<p className="mt-2">
본 약관은 서비스 화면에 게시하거나 기타 방법으로 이용자에게
공지함으로써 효력이 발생합니다. 운영자는 관련 법령을 위반하지 않는
범위에서 약관을 변경할 수 있으며, 변경 시 적용일자 및 변경 사유를
명시하여 사전에 공지합니다.
</p>
</section>

<section>
<h2 className="text-xl font-semibold text-gray-900">
제3조 (서비스의 제공)
</h2>
<p className="mt-2">
서비스는 쿠팡 경쟁 상품의 공개 리뷰 데이터를 분석하여 개선
인사이트를 제공합니다. 구체적인 기능 및 이용 범위는 추후 정식
약관에서 상세히 안내됩니다.
</p>
</section>
</div>

<div className="mt-12">
<Link href="/" className="text-brand-600 hover:text-brand-700">
← 홈으로 돌아가기
</Link>
</div>
</Container>
<Footer />
</div>
);
}
33 changes: 33 additions & 0 deletions frontend/src/shared/components/Footer.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import { Footer } from './Footer';

describe('Footer', () => {
it('renders copyright text', () => {
render(<Footer />);
expect(
screen.getByText(/© 2026 Coupang Review AI. All rights reserved\./)
).toBeInTheDocument();
});

it('renders the one-line introduction', () => {
render(<Footer />);
expect(
screen.getByText(
'쿠팡 경쟁 상품 리뷰를 AI가 분석해 개선 인사이트를 제공합니다.'
)
).toBeInTheDocument();
});

it('renders legal links', () => {
render(<Footer />);

const terms = screen.getByRole('link', { name: '이용약관' });
expect(terms).toBeInTheDocument();
expect(terms).toHaveAttribute('href', '/terms');

const privacy = screen.getByRole('link', { name: '개인정보 처리방침' });
expect(privacy).toBeInTheDocument();
expect(privacy).toHaveAttribute('href', '/privacy');
});
});
79 changes: 79 additions & 0 deletions frontend/src/shared/components/Footer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import Link from 'next/link';
import { Container } from '@/src/shared/components/ui';

export function Footer() {
return (
<footer className="bg-brand-950 text-brand-100">
<Container className="py-12">
<div className="grid grid-cols-1 gap-8 md:grid-cols-2 lg:grid-cols-5">
<div className="lg:col-span-2">
<span className="text-lg font-semibold text-white">
Coupang Review AI
</span>
<p className="mt-3 max-w-xs text-sm text-brand-300">
쿠팡 경쟁 상품 리뷰를 AI가 분석해 개선 인사이트를 제공합니다.
</p>
</div>

<div>
<h2 className="text-sm font-semibold text-white">제품</h2>
<ul className="mt-3 space-y-2 text-sm">
<li>
<Link href="/#features" className="hover:text-white">
기능
</Link>
</li>
<li>
<Link href="/#pricing" className="hover:text-white">
요금
</Link>
</li>
</ul>
</div>

<div>
<h2 className="text-sm font-semibold text-white">회사</h2>
<ul className="mt-3 space-y-2 text-sm">
<li>
<Link href="/#about" className="hover:text-white">
소개
</Link>
</li>
</ul>
</div>

<div>
<h2 className="text-sm font-semibold text-white">법적</h2>
<ul className="mt-3 space-y-2 text-sm">
<li>
<Link href="/terms" className="hover:text-white">
이용약관
</Link>
</li>
<li>
<Link href="/privacy" className="hover:text-white">
개인정보 처리방침
</Link>
</li>
</ul>
</div>
</div>

<div className="mt-8 text-sm">
<a
href="mailto:support@coupang-review-ai.example"
className="hover:text-white"
>
문의: support@coupang-review-ai.example
</a>
</div>

<div className="mt-8 border-t border-brand-800 pt-6">
<p className="text-sm text-brand-300">
© 2026 Coupang Review AI. All rights reserved.
</p>
</div>
</Container>
</footer>
);
}
49 changes: 49 additions & 0 deletions frontend/src/shared/components/Landing.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import { Landing } from './Landing';

describe('Landing', () => {
it('renders the hero headline', () => {
render(<Landing />);
expect(
screen.getByRole('heading', {
level: 1,
name: '리뷰가 알려주는 경쟁사를 이기는 법',
})
).toBeInTheDocument();
});

it('renders register and login CTA links', () => {
render(<Landing />);

const registerLinks = screen.getAllByRole('link', {
name: /무료로 시작/,
});
expect(registerLinks.length).toBeGreaterThan(0);
expect(registerLinks[0]).toHaveAttribute('href', '/register');

const loginLinks = screen.getAllByRole('link', { name: '로그인' });
expect(loginLinks.length).toBeGreaterThan(0);
expect(loginLinks[0]).toHaveAttribute('href', '/login');
});

it('renders the three pricing plans', () => {
render(<Landing />);
expect(
screen.getByRole('heading', { level: 3, name: '무료' })
).toBeInTheDocument();
expect(
screen.getByRole('heading', { level: 3, name: '스타터' })
).toBeInTheDocument();
expect(
screen.getByRole('heading', { level: 3, name: '프로' })
).toBeInTheDocument();
});

it('renders the footer copyright', () => {
render(<Landing />);
expect(
screen.getByText(/© 2026 Coupang Review AI. All rights reserved\./)
).toBeInTheDocument();
});
});
Loading
Loading