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
6 changes: 6 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -46,5 +46,11 @@ CONTACT_EMAIL=your-email@example.com
# Set to "계약서 지킴이 <noreply@contract-guardian.kr>" after domain verification
RESEND_FROM_EMAIL=

# Google Analytics (optional)
NEXT_PUBLIC_GA_MEASUREMENT_ID=G-XXXXXXXXXX

# Google Search Console (optional)
NEXT_PUBLIC_GOOGLE_SITE_VERIFICATION=

# Sentry (optional)
NEXT_PUBLIC_SENTRY_DSN=
9 changes: 9 additions & 0 deletions apps/web/src/_pages/analysis-result/analysis-result-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import { useAnalysisResult, useDeleteAnalysis } from "@/features/analysis/hooks"
import { AnalysisProgress } from "@/features/analysis";
import { ReportSummary, ClauseCard } from "@/entities/analysis/ui";
import { LoadingSpinner } from "@/widgets/loading";
import { trackAnalysisComplete, trackReportDownload } from "@/shared/lib/analytics";

export function AnalysisResultPage({
params,
Expand All @@ -45,6 +46,13 @@ export function AnalysisResultPage({
setFilePreviewUrl(`/api/analyses/${id}/file`);
}, [analysis?.status, id]);

// Track analysis completion
useEffect(() => {
if (analysis?.status === "completed" && analysis.overall_risk_score != null) {
trackAnalysisComplete(analysis.overall_risk_score);
}
}, [analysis?.status, analysis?.overall_risk_score]);

if (loading) {
return <LoadingSpinner message="분석 결과를 불러오는 중..." />;
}
Expand Down Expand Up @@ -195,6 +203,7 @@ export function AnalysisResultPage({
download
target="_blank"
rel="noopener noreferrer"
onClick={() => trackReportDownload()}
>
<Download className="h-4 w-4" />
리포트 다운로드
Expand Down
26 changes: 26 additions & 0 deletions apps/web/src/_pages/landing/landing-page.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import Script from "next/script";
import { Header } from "@/widgets/header";
import { Footer } from "@/widgets/footer";
import {
Expand All @@ -7,11 +8,36 @@ import {
TrustSignals,
FaqSection,
CtaSection,
FAQ_ITEMS,
} from "@/widgets/landing";
import {
organizationJsonLd,
productJsonLd,
faqJsonLd,
} from "@/shared/lib/seo";

export function LandingPage() {
const orgLd = organizationJsonLd();
const productLd = productJsonLd();
const faqLd = faqJsonLd(FAQ_ITEMS);

return (
<div className="flex min-h-screen flex-col">
<Script
id="ld-organization"
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(orgLd) }}
/>
<Script
id="ld-product"
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(productLd) }}
/>
<Script
id="ld-faq"
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(faqLd) }}
/>
<Header />
<main className="flex-1">
<HeroSection />
Expand Down
2 changes: 2 additions & 0 deletions apps/web/src/_pages/payment-confirm/payment-confirm-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { Loader2, CheckCircle2, XCircle } from "lucide-react";
import { Button, Card, CardContent } from "@cg/ui";
import { API_ROUTES } from "@cg/shared";
import { apiClient } from "@/shared/lib/api-client";
import { trackPaymentComplete } from "@/shared/lib/analytics";

type ConfirmState = "confirming" | "analyzing" | "success" | "error";

Expand Down Expand Up @@ -39,6 +40,7 @@ export function PaymentConfirmPage() {
paymentKey,
amount: Number(amount),
});
trackPaymentComplete(Number(amount));

if (analysisId) {
setState("analyzing");
Expand Down
11 changes: 10 additions & 1 deletion apps/web/src/app/(auth)/login/page.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,14 @@
import { redirect } from "next/navigation";
import { createClient } from "@/shared/api/supabase/server";
import { LoginPage } from "@/_pages/login";

export default function Page() {
export default async function Page() {
const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser();

if (user) {
redirect("/dashboard");
}

return <LoginPage />;
}
18 changes: 15 additions & 3 deletions apps/web/src/app/(marketing)/about/page.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,21 @@
import { AboutPage } from "@/_pages/about";

export const metadata = {
title: "서비스 소개 - 계약서 지킴이",
import type { Metadata } from "next";

const SITE_URL = process.env.NEXT_PUBLIC_APP_URL || "https://contract-guardian.kr";

export const metadata: Metadata = {
title: "서비스 소개",
description:
"프리랜서와 1인 사업자를 위한 AI 계약서 분석 서비스를 소개합니다.",
"계약서 지킴이는 AI로 계약서의 8대 위험 카테고리를 자동 분석합니다. 프리랜서·1인 사업자의 계약 리스크를 빠르게 파악하세요.",
openGraph: {
title: "서비스 소개 | 계약서 지킴이",
description:
"AI가 계약서의 독소 조항을 찾아 수정 방향을 제안합니다. 프리랜서·1인 사업자를 위한 계약서 검토 서비스.",
},
alternates: {
canonical: `${SITE_URL}/about`,
},
};

export default function Page() {
Expand Down
12 changes: 12 additions & 0 deletions apps/web/src/app/(marketing)/help/page.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,17 @@
import type { Metadata } from "next";
import { HelpPage } from "@/_pages/help";

const SITE_URL = process.env.NEXT_PUBLIC_APP_URL || "https://contract-guardian.kr";

export const metadata: Metadata = {
title: "도움말",
description:
"계약서 지킴이 사용 방법, 지원 형식, 분석 과정 등 자주 묻는 질문과 가이드를 확인하세요.",
alternates: {
canonical: `${SITE_URL}/help`,
},
};

export default function Page() {
return <HelpPage />;
}
18 changes: 15 additions & 3 deletions apps/web/src/app/(marketing)/pricing/page.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,20 @@
import { PricingPage } from "@/_pages/pricing";

export const metadata = {
title: "가격 정책 - 계약서 지킴이",
description: "투명한 건당 결제. 첫 1건 무료 분석.",
import type { Metadata } from "next";

const SITE_URL = process.env.NEXT_PUBLIC_APP_URL || "https://contract-guardian.kr";

export const metadata: Metadata = {
title: "가격 정책",
description:
"계약서 지킴이 가격 안내. 첫 1건 무료 체험, 일반 분석 3,900원, 확장 분석 5,900원. 건당 결제, 숨은 비용 없음.",
openGraph: {
title: "가격 정책 | 계약서 지킴이",
description: "첫 1건 무료, 3,900원부터. 투명한 건당 결제 AI 계약서 분석.",
},
alternates: {
canonical: `${SITE_URL}/pricing`,
},
};

export default function Page() {
Expand Down
68 changes: 64 additions & 4 deletions apps/web/src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { Metadata } from "next";
import { Inter } from "next/font/google";
import { Toaster } from "sonner";

import { GoogleAnalytics } from "@/shared/ui/google-analytics";
import { Providers } from "./providers";
import "./globals.css";

Expand All @@ -11,15 +12,73 @@ const inter = Inter({
variable: "--font-sans",
});

const SITE_URL = process.env.NEXT_PUBLIC_APP_URL || "https://contract-guardian.kr";
const SITE_NAME = "계약서 지킴이";
const SITE_DESCRIPTION =
"프리랜서·1인 사업자를 위한 AI 계약서 분석 서비스. 8대 위험 카테고리 자동 검토, 독소 조항 식별, 수정 제안, PDF 리포트까지. 3,900원부터.";

export const metadata: Metadata = {
title: "계약서 지킴이 - AI 계약서 검토 서비스",
description:
"프리랜서와 1인 사업자를 위한 AI 계약서 분석 서비스. 계약서의 위험 조항을 자동으로 찾아드립니다.",
keywords: ["계약서", "AI", "프리랜서", "계약서 검토", "위험 조항"],
metadataBase: new URL(SITE_URL),
title: {
default: `${SITE_NAME} - AI 계약서 검토 서비스`,
template: `%s | ${SITE_NAME}`,
},
description: SITE_DESCRIPTION,
keywords: [
"계약서 검토",
"AI 계약서 분석",
"프리랜서 계약서",
"용역 계약서 검토",
"외주 계약서 위험",
"독소 조항",
"계약서 위험 분석",
"1인 사업자 계약서",
"NDA 검토",
"계약서 리스크",
],
icons: {
icon: "/logo.svg",
apple: "/logo.svg",
},
openGraph: {
type: "website",
locale: "ko_KR",
url: SITE_URL,
siteName: SITE_NAME,
title: `${SITE_NAME} - AI가 계약서의 위험을 찾아드립니다`,
description: SITE_DESCRIPTION,
images: [
{
url: "/og-image.png",
width: 1200,
height: 630,
alt: "계약서 지킴이 - AI 계약서 검토 서비스",
},
],
},
twitter: {
card: "summary_large_image",
title: `${SITE_NAME} - AI 계약서 검토 서비스`,
description: SITE_DESCRIPTION,
images: ["/og-image.png"],
},
alternates: {
canonical: SITE_URL,
},
robots: {
index: true,
follow: true,
googleBot: {
index: true,
follow: true,
"max-video-preview": -1,
"max-image-preview": "large",
"max-snippet": -1,
},
},
verification: {
google: process.env.NEXT_PUBLIC_GOOGLE_SITE_VERIFICATION,
},
};

export default function RootLayout({
Expand All @@ -32,6 +91,7 @@ export default function RootLayout({
<body
className={`${inter.variable} min-h-screen font-sans antialiased`}
>
<GoogleAnalytics />
<Providers>
{children}
<Toaster position="top-right" richColors closeButton />
Expand Down
17 changes: 17 additions & 0 deletions apps/web/src/app/robots.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import type { MetadataRoute } from "next";

const SITE_URL =
process.env.NEXT_PUBLIC_APP_URL || "https://contract-guardian.kr";

export default function robots(): MetadataRoute.Robots {
return {
rules: [
{
userAgent: "*",
allow: "/",
disallow: ["/api/", "/dashboard/", "/analyze/", "/settings/", "/payment/"],
},
],
sitemap: `${SITE_URL}/sitemap.xml`,
};
}
45 changes: 45 additions & 0 deletions apps/web/src/app/sitemap.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import type { MetadataRoute } from "next";

const SITE_URL =
process.env.NEXT_PUBLIC_APP_URL || "https://contract-guardian.kr";

export default function sitemap(): MetadataRoute.Sitemap {
return [
{
url: SITE_URL,
lastModified: new Date(),
changeFrequency: "weekly",
priority: 1,
},
{
url: `${SITE_URL}/about`,
lastModified: new Date(),
changeFrequency: "monthly",
priority: 0.8,
},
{
url: `${SITE_URL}/pricing`,
lastModified: new Date(),
changeFrequency: "monthly",
priority: 0.8,
},
{
url: `${SITE_URL}/help`,
lastModified: new Date(),
changeFrequency: "monthly",
priority: 0.6,
},
{
url: `${SITE_URL}/terms`,
lastModified: new Date(),
changeFrequency: "yearly",
priority: 0.3,
},
{
url: `${SITE_URL}/privacy`,
lastModified: new Date(),
changeFrequency: "yearly",
priority: 0.3,
},
];
}
5 changes: 5 additions & 0 deletions apps/web/src/features/auth/ui/login-content.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,12 +23,14 @@ export default function LoginContent() {
}
}, [error]);

// 로그인 상태면 즉시 리다이렉트
useEffect(() => {
if (!loading && user) {
router.replace(redirect);
}
}, [user, loading, router, redirect]);


const handleGoogleLogin = async () => {
try {
await signIn("google");
Expand All @@ -53,6 +55,9 @@ export default function LoginContent() {
);
}

// 이미 로그인된 상태 → useEffect에서 리다이렉트 처리 중, 폼 노출 방지
if (user) return null;

return (
<div className="flex min-h-screen items-center justify-center bg-muted/30 p-4">
<Card className="w-full max-w-md">
Expand Down
2 changes: 2 additions & 0 deletions apps/web/src/features/payment/hooks/use-payment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { useState, useCallback } from "react";
import { usePayment as usePaymentHook } from "@cg/api";
import { loadTossPayments, ANONYMOUS } from "@tosspayments/tosspayments-sdk";
import { apiClient } from "@/shared/lib/api-client";
import { trackPaymentStart } from "@/shared/lib/analytics";

interface UsePaymentFlowReturn {
showPaymentModal: boolean;
Expand All @@ -28,6 +29,7 @@ export function usePaymentFlow(): UsePaymentFlowReturn {
amount: number,
options?: { userId?: string; provider?: string; customerEmail?: string; customerName?: string }
) => {
trackPaymentStart(amount);
const result = await initiatePayment({ analysisId, amount });

const clientKey = process.env.NEXT_PUBLIC_TOSS_CLIENT_KEY;
Expand Down
Loading
Loading