diff --git a/app/[locale]/(auth)/forgot-password/forgot-password-client.tsx b/app/[locale]/(auth)/forgot-password/forgot-password-client.tsx new file mode 100644 index 0000000..598b0d9 --- /dev/null +++ b/app/[locale]/(auth)/forgot-password/forgot-password-client.tsx @@ -0,0 +1,118 @@ +'use client' + +import { useState, type FormEvent } from 'react' +import { Link } from '@/i18n/navigation' +import { useTranslations } from 'next-intl' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { Card, CardContent, CardDescription, CardFooter, CardHeader } from '@/components/ui/card' +import { sendResetEmail } from './actions' + +export default function ForgotPasswordPage() { + const t = useTranslations('auth.forgotPassword') + const tCommon = useTranslations('common') + + const [email, setEmail] = useState('') + const [error, setError] = useState(null) + const [success, setSuccess] = useState(false) + const [isLoading, setIsLoading] = useState(false) + + async function handleSubmit(e: FormEvent) { + e.preventDefault() + if (isLoading) return + + setError(null) + setSuccess(false) + setIsLoading(true) + + const result = await sendResetEmail({ email: email.trim() }) + if (result?.error) { + setError(result.error) + } else { + setSuccess(true) + } + + setIsLoading(false) + } + + return ( +
+ {/* Left: Branding panel */} +
+
+
+
+
+ + {tCommon('brandName')} + +
+

+ {t('title')} +

+

+ {t('description')} +

+
+
+
+ + {/* Right: Form */} +
+ + +

{t('title')}

+ {t('description')} +
+ + {success ? ( +
+

{t('successTitle')}

+

{t('successDescription', { email })}

+ + {t('backToLogin')} + +
+ ) : ( +
+
+ + setEmail(e.target.value)} + required + placeholder="you@example.com" + /> +
+ {error && ( +

+ {error} +

+ )} + +
+ )} +
+ + + {t('backToLogin')} + + +
+
+
+ ) +} diff --git a/app/[locale]/(auth)/forgot-password/page.tsx b/app/[locale]/(auth)/forgot-password/page.tsx index 83a168b..61bc60d 100644 --- a/app/[locale]/(auth)/forgot-password/page.tsx +++ b/app/[locale]/(auth)/forgot-password/page.tsx @@ -1,108 +1,24 @@ -'use client' - -import { useState, type FormEvent } from 'react' -import { Link } from '@/i18n/navigation' -import { useTranslations } from 'next-intl' -import { Button } from '@/components/ui/button' -import { Input } from '@/components/ui/input' -import { Label } from '@/components/ui/label' -import { Card, CardContent, CardDescription, CardFooter, CardHeader } from '@/components/ui/card' -import { sendResetEmail } from './actions' +import type { Metadata } from 'next' +import { getTranslations } from 'next-intl/server' +import { generateNoIndexMetadata } from '@/lib/seo/metadata' +import ForgotPasswordPageClient from './forgot-password-client' + +export async function generateMetadata({ + params, +}: { + params: Promise<{ locale: string }> +}): Promise { + const { locale } = await params + const t = await getTranslations({ locale, namespace: 'metadata' }) + + return generateNoIndexMetadata({ + locale, + path: '/forgot-password', + title: t('forgotPassword.title'), + description: t('forgotPassword.description'), + }) +} export default function ForgotPasswordPage() { - const t = useTranslations('auth.forgotPassword') - const tCommon = useTranslations('common') - - const [email, setEmail] = useState('') - const [error, setError] = useState(null) - const [success, setSuccess] = useState(false) - const [isLoading, setIsLoading] = useState(false) - - async function handleSubmit(e: FormEvent) { - e.preventDefault() - if (isLoading) return - - setError(null) - setSuccess(false) - setIsLoading(true) - - const result = await sendResetEmail({ email: email.trim() }) - if (result?.error) { - setError(result.error) - } else { - setSuccess(true) - } - - setIsLoading(false) - } - - return ( -
- {/* Left: Branding panel */} -
-
-
-
-
- - {tCommon('brandName')} - -
-

- {t('title')} -

-

- {t('description')} -

-
-
-
- - {/* Right: Form */} -
- - -

{t('title')}

- {t('description')} -
- - {success ? ( -
-

{t('successTitle')}

-

{t('successDescription', { email })}

- - {t('backToLogin')} - -
- ) : ( -
-
- - setEmail(e.target.value)} - required - placeholder="you@example.com" - /> -
- {error && ( -

{error}

- )} - -
- )} -
- - - {t('backToLogin')} - - -
-
-
- ) + return } diff --git a/app/[locale]/(auth)/login/login-client.tsx b/app/[locale]/(auth)/login/login-client.tsx new file mode 100644 index 0000000..60db618 --- /dev/null +++ b/app/[locale]/(auth)/login/login-client.tsx @@ -0,0 +1,241 @@ +'use client' + +import { useState } from 'react' +import { Eye, EyeOff } from 'lucide-react' +import { Link } from '@/i18n/navigation' +import { useTranslations } from 'next-intl' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from '@/components/ui/card' +import { AnalyticsEvents, trackEvent } from '@/lib/analytics/events' +import { signIn, signInWithOAuth } from './actions' + +export default function LoginPage() { + const t = useTranslations('auth.login') + const tAuth = useTranslations('auth') + const tCommon = useTranslations('common') + + const [email, setEmail] = useState('') + const [password, setPassword] = useState('') + const [showPassword, setShowPassword] = useState(false) + const [error, setError] = useState(null) + const [isLoading, setIsLoading] = useState(false) + const [oauthLoading, setOauthLoading] = useState<'google' | 'github' | null>(null) + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault() + if (isLoading) return + setError(null) + setIsLoading(true) + trackEvent(AnalyticsEvents.LOGIN_START, { method: 'email' }) + + const result = await signIn({ email, password }) + if (result?.error) { + setError(result.error) + setIsLoading(false) + } else { + trackEvent(AnalyticsEvents.LOGIN_COMPLETE, { method: 'email' }) + } + } + + async function handleOAuth(provider: 'google' | 'github') { + if (oauthLoading) return + setError(null) + setOauthLoading(provider) + trackEvent(AnalyticsEvents.LOGIN_START, { method: provider }) + const result = await signInWithOAuth(provider) + if (result?.error) { + setError(result.error) + setOauthLoading(null) + } + } + + return ( +
+ {/* Left: Branding panel */} +
+
+
+
+
+ + {tCommon('brandName')} + +
+

+ {t('branding.headline')} +
+ {t('branding.headlineSub')} +

+

+ {t('branding.description')} +

+
+
+
+
12+
+
+ {t('branding.stats.assets')} +
+
+
+
+ <60s +
+
+ {t('branding.stats.speed')} +
+
+
+
+
+ + {/* Right: Form */} +
+ + +
+ + {tCommon('brandName')} + +
+ {t('title')} + {t('subtitle')} +
+ +
+
+ + setEmail(e.target.value)} + required + /> +
+
+
+ + + {t('forgotPassword')} + +
+
+ setPassword(e.target.value)} + required + className="pr-10" + /> + +
+
+ {error && ( +

+ {error} +

+ )} + +
+ +
+
+ +
+
+ + {tCommon('or')} + +
+
+ +
+ + +
+
+ +

+ {t('noAccount')} + + {t('signupLink')} + +

+
+
+
+
+ ) +} diff --git a/app/[locale]/(auth)/login/page.tsx b/app/[locale]/(auth)/login/page.tsx index 05a9033..35b863c 100644 --- a/app/[locale]/(auth)/login/page.tsx +++ b/app/[locale]/(auth)/login/page.tsx @@ -1,195 +1,24 @@ -'use client' - -import { useState } from 'react' -import { Eye, EyeOff } from 'lucide-react' -import { Link } from '@/i18n/navigation' -import { useTranslations } from 'next-intl' -import { Button } from '@/components/ui/button' -import { Input } from '@/components/ui/input' -import { Label } from '@/components/ui/label' -import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card' -import { signIn, signInWithOAuth } from './actions' +import type { Metadata } from 'next' +import { getTranslations } from 'next-intl/server' +import { generateNoIndexMetadata } from '@/lib/seo/metadata' +import LoginPageClient from './login-client' + +export async function generateMetadata({ + params, +}: { + params: Promise<{ locale: string }> +}): Promise { + const { locale } = await params + const t = await getTranslations({ locale, namespace: 'metadata' }) + + return generateNoIndexMetadata({ + locale, + path: '/login', + title: t('login.title'), + description: t('login.description'), + }) +} export default function LoginPage() { - const t = useTranslations('auth.login') - const tAuth = useTranslations('auth') - const tCommon = useTranslations('common') - - const [email, setEmail] = useState('') - const [password, setPassword] = useState('') - const [showPassword, setShowPassword] = useState(false) - const [error, setError] = useState(null) - const [isLoading, setIsLoading] = useState(false) - const [oauthLoading, setOauthLoading] = useState<'google' | 'github' | null>(null) - - async function handleSubmit(e: React.FormEvent) { - e.preventDefault() - if (isLoading) return - setError(null) - setIsLoading(true) - - const result = await signIn({ email, password }) - if (result?.error) { - setError(result.error) - setIsLoading(false) - } - } - - async function handleOAuth(provider: 'google' | 'github') { - if (oauthLoading) return - setError(null) - setOauthLoading(provider) - const result = await signInWithOAuth(provider) - if (result?.error) { - setError(result.error) - setOauthLoading(null) - } - } - - return ( -
- {/* Left: Branding panel */} -
-
-
-
-
- - {tCommon('brandName')} - -
-

- {t('branding.headline')} -
- {t('branding.headlineSub')} -

-

- {t('branding.description')} -

-
-
-
-
12+
-
{t('branding.stats.assets')}
-
-
-
<60s
-
{t('branding.stats.speed')}
-
-
-
-
- - {/* Right: Form */} -
- - -
- {tCommon('brandName')} -
- {t('title')} - {t('subtitle')} -
- -
-
- - setEmail(e.target.value)} - required - /> -
-
-
- - - {t('forgotPassword')} - -
-
- setPassword(e.target.value)} - required - className="pr-10" - /> - -
-
- {error && ( -

{error}

- )} - -
- -
-
- -
-
- {tCommon('or')} -
-
- -
- - -
-
- -

- {t('noAccount')} - - {t('signupLink')} - -

-
-
-
-
- ) + return } diff --git a/app/[locale]/(auth)/signup/page.tsx b/app/[locale]/(auth)/signup/page.tsx index cc0bab2..6c72604 100644 --- a/app/[locale]/(auth)/signup/page.tsx +++ b/app/[locale]/(auth)/signup/page.tsx @@ -1,273 +1,24 @@ -'use client' - -import { useState } from 'react' -import { Eye, EyeOff, Check, X } from 'lucide-react' -import { Link } from '@/i18n/navigation' -import { useTranslations } from 'next-intl' -import { Button } from '@/components/ui/button' -import { Input } from '@/components/ui/input' -import { Label } from '@/components/ui/label' -import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card' -import { signUp, signUpWithOAuth } from './actions' -import { AnalyticsEvents, trackEvent } from '@/lib/analytics/events' +import type { Metadata } from 'next' +import { getTranslations } from 'next-intl/server' +import { generateNoIndexMetadata } from '@/lib/seo/metadata' +import SignupPageClient from './signup-client' + +export async function generateMetadata({ + params, +}: { + params: Promise<{ locale: string }> +}): Promise { + const { locale } = await params + const t = await getTranslations({ locale, namespace: 'metadata' }) + + return generateNoIndexMetadata({ + locale, + path: '/signup', + title: t('signup.title'), + description: t('signup.description'), + }) +} export default function SignupPage() { - const t = useTranslations('auth.signup') - const tAuth = useTranslations('auth') - const tCommon = useTranslations('common') - - const [email, setEmail] = useState('') - const [password, setPassword] = useState('') - const [confirmPassword, setConfirmPassword] = useState('') - const [showPassword, setShowPassword] = useState(false) - const [showConfirmPassword, setShowConfirmPassword] = useState(false) - const [error, setError] = useState(null) - const [isLoading, setIsLoading] = useState(false) - const [oauthLoading, setOauthLoading] = useState<'google' | 'github' | null>(null) - const [isSuccess, setIsSuccess] = useState(false) - - // Password validation - const passwordChecks = { - length: password.length >= 6, - match: password === confirmPassword && confirmPassword.length > 0, - } - - async function handleSubmit(e: React.FormEvent) { - e.preventDefault() - if (isLoading) return - setError(null) - - if (password !== confirmPassword) { - setError(t('validation.passwordsDoNotMatch', { defaultValue: tAuth('validation.passwordsDoNotMatch') })) - return - } - - if (password.length < 6) { - setError(t('validation.passwordTooShort', { defaultValue: tAuth('validation.passwordTooShort') })) - return - } - - setIsLoading(true) - trackEvent(AnalyticsEvents.SIGNUP_START, { method: 'email' }) - - const result = await signUp({ email, password }) - - if (result?.error) { - setError(result.error) - setIsLoading(false) - } else { - trackEvent(AnalyticsEvents.SIGNUP_COMPLETE, { method: 'email' }) - setIsSuccess(true) - setIsLoading(false) - } - } - - async function handleOAuth(provider: 'google' | 'github') { - if (oauthLoading) return - setError(null) - setOauthLoading(provider) - trackEvent(AnalyticsEvents.SIGNUP_METHOD_SELECT, { method: provider }) - trackEvent(AnalyticsEvents.SIGNUP_START, { method: provider }) - const result = await signUpWithOAuth(provider) - if (result?.error) { - setError(result.error) - setOauthLoading(null) - } - } - - if (isSuccess) { - return ( -
- - - {t('success.title')} - - - {t('success.description', { email })} - - - {t('success.backToLogin')} - - - - - - - - - -
- ) - } - - return ( -
- {/* Left: Branding panel */} -
-
-
-
-
- - BrandKit - -
-

- {t('branding.headline')} -
- {t('branding.headlineSub')} -

-

- {t('branding.description')} -

-
-
-
-
12+
-
{t('branding.stats.start')}
-
-
-
3/Month
-
{t('branding.stats.perMonth')}
-
-
-
-
- - {/* Right: Form */} -
- - -
- BrandKit -
- {t('title')} - {t('subtitle')} -
- -
-
- - setEmail(e.target.value)} - required - /> -
-
- -
- setPassword(e.target.value)} - required - minLength={6} - className="pr-10" - /> - -
- {password.length > 0 && ( -
- {passwordChecks.length ? ( - {t('passwordChecks.length')} - ) : ( - {t('passwordChecks.length')} - )} -
- )} -
-
- -
- setConfirmPassword(e.target.value)} - required - className="pr-10" - /> - -
- {confirmPassword.length > 0 && ( -
- {passwordChecks.match ? ( - {t('passwordChecks.match')} - ) : ( - {t('passwordChecks.mismatch')} - )} -
- )} -
- {error && ( -

{error}

- )} - -
- -
-
- -
-
- {tCommon('or')} -
-
- -
- - -
-
- -

- {t('hasAccount')}? - - {t('loginLink')} - -

-
-
-
-
- ) + return } diff --git a/app/[locale]/(auth)/signup/signup-client.tsx b/app/[locale]/(auth)/signup/signup-client.tsx new file mode 100644 index 0000000..50b99a6 --- /dev/null +++ b/app/[locale]/(auth)/signup/signup-client.tsx @@ -0,0 +1,315 @@ +'use client' + +import { useState } from 'react' +import { Eye, EyeOff, Check, X } from 'lucide-react' +import { Link } from '@/i18n/navigation' +import { useTranslations } from 'next-intl' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from '@/components/ui/card' +import { signUp, signUpWithOAuth } from './actions' +import { AnalyticsEvents, trackEvent } from '@/lib/analytics/events' + +export default function SignupPage() { + const t = useTranslations('auth.signup') + const tAuth = useTranslations('auth') + const tCommon = useTranslations('common') + + const [email, setEmail] = useState('') + const [password, setPassword] = useState('') + const [confirmPassword, setConfirmPassword] = useState('') + const [showPassword, setShowPassword] = useState(false) + const [showConfirmPassword, setShowConfirmPassword] = useState(false) + const [error, setError] = useState(null) + const [isLoading, setIsLoading] = useState(false) + const [oauthLoading, setOauthLoading] = useState<'google' | 'github' | null>(null) + const [isSuccess, setIsSuccess] = useState(false) + + // Password validation + const passwordChecks = { + length: password.length >= 6, + match: password === confirmPassword && confirmPassword.length > 0, + } + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault() + if (isLoading) return + setError(null) + + if (password !== confirmPassword) { + setError( + t('validation.passwordsDoNotMatch', { + defaultValue: tAuth('validation.passwordsDoNotMatch'), + }) + ) + return + } + + if (password.length < 6) { + setError( + t('validation.passwordTooShort', { defaultValue: tAuth('validation.passwordTooShort') }) + ) + return + } + + setIsLoading(true) + trackEvent(AnalyticsEvents.SIGNUP_START, { method: 'email' }) + + const result = await signUp({ email, password }) + + if (result?.error) { + setError(result.error) + setIsLoading(false) + } else { + trackEvent(AnalyticsEvents.SIGNUP_COMPLETE, { method: 'email' }) + setIsSuccess(true) + setIsLoading(false) + } + } + + async function handleOAuth(provider: 'google' | 'github') { + if (oauthLoading) return + setError(null) + setOauthLoading(provider) + trackEvent(AnalyticsEvents.SIGNUP_METHOD_SELECT, { method: provider }) + trackEvent(AnalyticsEvents.SIGNUP_START, { method: provider }) + const result = await signUpWithOAuth(provider) + if (result?.error) { + setError(result.error) + setOauthLoading(null) + } + } + + if (isSuccess) { + return ( +
+ + + {t('success.title')} + + {t('success.description', { email })} + {t('success.backToLogin')} + + + + + + + + +
+ ) + } + + return ( +
+ {/* Left: Branding panel */} +
+
+
+
+
+ + BrandKit + +
+

+ {t('branding.headline')} +
+ {t('branding.headlineSub')} +

+

+ {t('branding.description')} +

+
+
+
+
12+
+
+ {t('branding.stats.start')} +
+
+
+
+ 3/Month +
+
+ {t('branding.stats.perMonth')} +
+
+
+
+
+ + {/* Right: Form */} +
+ + +
+ + BrandKit + +
+ {t('title')} + {t('subtitle')} +
+ +
+
+ + setEmail(e.target.value)} + required + /> +
+
+ +
+ setPassword(e.target.value)} + required + minLength={6} + className="pr-10" + /> + +
+ {password.length > 0 && ( +
+ {passwordChecks.length ? ( + + {t('passwordChecks.length')} + + ) : ( + + {t('passwordChecks.length')} + + )} +
+ )} +
+
+ +
+ setConfirmPassword(e.target.value)} + required + className="pr-10" + /> + +
+ {confirmPassword.length > 0 && ( +
+ {passwordChecks.match ? ( + + {t('passwordChecks.match')} + + ) : ( + + {t('passwordChecks.mismatch')} + + )} +
+ )} +
+ {error && ( +

+ {error} +

+ )} + +
+ +
+
+ +
+
+ + {tCommon('or')} + +
+
+ +
+ + +
+
+ +

+ {t('hasAccount')}? + + {t('loginLink')} + +

+
+
+
+
+ ) +} diff --git a/app/[locale]/demo/page.tsx b/app/[locale]/demo/page.tsx index 201b2c8..22a1505 100644 --- a/app/[locale]/demo/page.tsx +++ b/app/[locale]/demo/page.tsx @@ -1,23 +1,42 @@ -import { setRequestLocale, getTranslations } from 'next-intl/server' +import type { Metadata } from 'next' +import { getTranslations, setRequestLocale } from 'next-intl/server' +import { JsonLd } from '@/components/seo/json-ld' +import { generateIndexablePageMetadata } from '@/lib/seo/metadata' +import { generateBreadcrumbSchema } from '@/lib/seo/json-ld' import { DemoClient } from './client' -export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }) { +export async function generateMetadata({ + params, +}: { + params: Promise<{ locale: string }> +}): Promise { const { locale } = await params const t = await getTranslations({ locale, namespace: 'demo' }) - return { + return generateIndexablePageMetadata({ + locale, title: t('meta.title'), description: t('meta.description'), - } + path: '/demo', + }) } -export default async function DemoPage({ - params, -}: { - params: Promise<{ locale: string }> -}) { +export default async function DemoPage({ params }: { params: Promise<{ locale: string }> }) { const { locale } = await params setRequestLocale(locale) - return + return ( + <> + + + + ) } diff --git a/app/[locale]/layout.tsx b/app/[locale]/layout.tsx index f09be7a..10bb6bb 100644 --- a/app/[locale]/layout.tsx +++ b/app/[locale]/layout.tsx @@ -9,6 +9,7 @@ import { routing } from '@/i18n/routing' import { JsonLd } from '@/components/seo/json-ld' import { GoogleAnalytics } from '@/components/analytics/google-analytics' import { generateOrganizationSchema, generateWebSiteSchema } from '@/lib/seo/json-ld' +import { getSiteUrl } from '@/lib/seo/site-url' import '../globals.css' const jetbrainsMono = JetBrains_Mono({ @@ -38,7 +39,7 @@ export async function generateMetadata({ }): Promise { const { locale } = await params const t = await getTranslations({ locale, namespace: 'metadata' }) - const baseUrl = process.env.NEXT_PUBLIC_APP_URL || (process.env.NODE_ENV === 'development' ? 'http://localhost:3000' : 'https://brandkit.app') + const baseUrl = getSiteUrl() return { metadataBase: new URL(baseUrl), @@ -52,52 +53,12 @@ export async function generateMetadata({ creator: 'BrandKit', publisher: 'BrandKit', applicationName: 'BrandKit', - 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, other: { 'naver-site-verification': process.env.NEXT_PUBLIC_NAVER_SITE_VERIFICATION || '', }, }, - openGraph: { - type: 'website', - locale: locale === 'ko' ? 'ko_KR' : 'en_US', - url: `${baseUrl}/${locale}`, - siteName: 'BrandKit', - title: t('title.default'), - description: t('description'), - images: [ - { - url: `/api/og?locale=${locale}`, - width: 1200, - height: 630, - alt: t('title.default'), - }, - ], - }, - twitter: { - card: 'summary_large_image', - title: t('title.default'), - description: t('description'), - images: [`/api/og?locale=${locale}`], - }, - alternates: { - canonical: `${baseUrl}/${locale}`, - languages: { - en: `${baseUrl}/en`, - ko: `${baseUrl}/ko`, - }, - }, } } @@ -123,34 +84,19 @@ export default async function LocaleLayout({ {/* PWA Manifest */} - {/* DNS Prefetch for external resources */} - - - {/* Pretendard Variable Font - 한국어/영어 최적화 */} - - {/* Skip Link for Accessibility */} {locale === 'ko' ? '본문으로 건너뛰기' : 'Skip to main content'} - - {children} - + {children} diff --git a/app/[locale]/page.tsx b/app/[locale]/page.tsx index 92d368e..4cc0088 100644 --- a/app/[locale]/page.tsx +++ b/app/[locale]/page.tsx @@ -1,4 +1,5 @@ -import { setRequestLocale } from 'next-intl/server' +import type { Metadata } from 'next' +import { getTranslations, setRequestLocale } from 'next-intl/server' import { Hero } from '@/components/landing/hero' import { Features } from '@/components/landing/features' import { HowItWorks } from '@/components/landing/how-it-works' @@ -8,18 +9,40 @@ import { FAQ } from '@/components/landing/faq' import { CTA } from '@/components/landing/cta' import { Footer } from '@/components/landing/footer' import { JsonLd } from '@/components/seo/json-ld' -import { generateSoftwareApplicationSchema } from '@/lib/seo/json-ld' +import { + generateBreadcrumbSchema, + generateFaqPageSchema, + generateSoftwareApplicationSchema, +} from '@/lib/seo/json-ld' +import { getLandingFaqItems } from '@/lib/seo/faq' +import { generateIndexablePageMetadata } from '@/lib/seo/metadata' -export default async function Home({ +export async function generateMetadata({ params, }: { params: Promise<{ locale: string }> -}) { +}): Promise { + const { locale } = await params + const t = await getTranslations({ locale, namespace: 'metadata' }) + + return generateIndexablePageMetadata({ + locale, + title: t('title.default'), + description: t('description'), + }) +} + +export default async function Home({ params }: { params: Promise<{ locale: string }> }) { const { locale } = await params setRequestLocale(locale) + const t = await getTranslations({ locale, namespace: 'landing' }) + const faqItems = getLandingFaqItems(t) + return ( -
+
+ + diff --git a/app/[locale]/privacy/page.tsx b/app/[locale]/privacy/page.tsx index 8a85199..8fa35a1 100644 --- a/app/[locale]/privacy/page.tsx +++ b/app/[locale]/privacy/page.tsx @@ -1,7 +1,8 @@ import type { Metadata } from 'next' import { getTranslations } from 'next-intl/server' -import { Link } from '@/i18n/navigation' -import { ArrowLeft } from 'lucide-react' +import { LegalDocumentPage } from '@/components/legal/legal-document' +import { getLegalDocument } from '@/lib/legal/documents' +import { generateIndexablePageMetadata } from '@/lib/seo/metadata' export async function generateMetadata({ params, @@ -11,87 +12,16 @@ export async function generateMetadata({ const { locale } = await params const t = await getTranslations({ locale, namespace: 'metadata' }) - return { + return generateIndexablePageMetadata({ title: t('privacy.title'), description: t('privacy.description'), - robots: { - index: false, - follow: false, - }, - } + locale, + path: '/privacy', + }) } -export default function PrivacyPage() { - return ( -
-
- - - 홈으로 돌아가기 - - -

- 개인정보처리방침 -

-

최종 수정일: 2024년 1월 1일

- -
-
-

1. 수집하는 개인정보

-

- BrandKit은 서비스 제공을 위해 다음과 같은 개인정보를 수집합니다: -

-
    -
  • 이메일 주소 (계정 생성 및 로그인)
  • -
  • 결제 정보 (유료 구독 시)
  • -
  • 서비스 이용 기록 (프로젝트 생성, 에셋 생성 등)
  • -
-
- -
-

2. 개인정보의 이용 목적

-

- 수집된 개인정보는 다음의 목적으로 이용됩니다: -

-
    -
  • 서비스 제공 및 계정 관리
  • -
  • 결제 처리 및 구독 관리
  • -
  • 서비스 개선 및 신규 기능 개발
  • -
  • 고객 지원 및 문의 응대
  • -
-
- -
-

3. 개인정보의 보관 및 파기

-

- 개인정보는 서비스 이용 기간 동안 보관되며, 계정 삭제 요청 시 30일 이내에 파기됩니다. - 단, 관련 법령에 따라 일정 기간 보관이 필요한 경우 해당 기간 동안 보관됩니다. -

-
- -
-

4. 개인정보의 제3자 제공

-

- BrandKit은 원칙적으로 이용자의 개인정보를 제3자에게 제공하지 않습니다. - 다만, 다음의 경우에는 예외로 합니다: -

-
    -
  • 이용자가 사전에 동의한 경우
  • -
  • 법령의 규정에 의거하거나, 수사 목적으로 법령에 정해진 절차와 방법에 따라 수사기관의 요구가 있는 경우
  • -
-
+export default async function PrivacyPage({ params }: { params: Promise<{ locale: string }> }) { + const { locale } = await params -
-

5. 문의

-

- 개인정보 처리에 관한 문의사항은 support@brandkit.app으로 연락해 주세요. -

-
-
-
-
- ) + return } diff --git a/app/[locale]/terms/page.tsx b/app/[locale]/terms/page.tsx index 98d3b01..f6dd43d 100644 --- a/app/[locale]/terms/page.tsx +++ b/app/[locale]/terms/page.tsx @@ -1,7 +1,8 @@ import type { Metadata } from 'next' import { getTranslations } from 'next-intl/server' -import { Link } from '@/i18n/navigation' -import { ArrowLeft } from 'lucide-react' +import { LegalDocumentPage } from '@/components/legal/legal-document' +import { getLegalDocument } from '@/lib/legal/documents' +import { generateIndexablePageMetadata } from '@/lib/seo/metadata' export async function generateMetadata({ params, @@ -11,106 +12,16 @@ export async function generateMetadata({ const { locale } = await params const t = await getTranslations({ locale, namespace: 'metadata' }) - return { + return generateIndexablePageMetadata({ title: t('terms.title'), description: t('terms.description'), - robots: { - index: false, - follow: false, - }, - } + locale, + path: '/terms', + }) } -export default function TermsPage() { - return ( -
-
- - - 홈으로 돌아가기 - - -

- 이용약관 -

-

최종 수정일: 2024년 1월 1일

- -
-
-

1. 서비스 개요

-

- BrandKit은 AI 기반 브랜드 에셋 생성 서비스입니다. 본 약관은 BrandKit 서비스 이용에 관한 - 조건 및 절차, 회사와 회원의 권리, 의무 및 책임사항을 규정합니다. -

-
- -
-

2. 회원가입 및 계정

-

- 회원가입은 이메일 주소 또는 소셜 로그인(Google, GitHub)을 통해 가능합니다. - 회원은 정확한 정보를 제공해야 하며, 계정 보안에 대한 책임이 있습니다. -

-
- -
-

3. 서비스 이용

-

- 회원은 다음 사항을 준수해야 합니다: -

-
    -
  • 서비스를 합법적인 목적으로만 사용
  • -
  • 타인의 지적재산권을 침해하지 않음
  • -
  • 서비스의 정상적인 운영을 방해하지 않음
  • -
  • 계정을 타인과 공유하지 않음
  • -
-
- -
-

4. 지적재산권

-

- BrandKit 서비스를 통해 생성된 에셋의 저작권은 해당 회원에게 귀속됩니다. - 단, BrandKit 서비스 자체의 소프트웨어, 디자인, 로고 등에 대한 지적재산권은 - BrandKit에 귀속됩니다. -

-
- -
-

5. 결제 및 환불

-

- 유료 서비스는 구독 형태로 제공되며, 결제일에 자동으로 갱신됩니다. - 환불은 결제 후 7일 이내에 요청 시 가능하며, 이미 사용한 서비스에 대해서는 - 환불이 제한될 수 있습니다. -

-
- -
-

6. 서비스 변경 및 중단

-

- BrandKit은 서비스 개선을 위해 서비스 내용을 변경할 수 있으며, - 중요한 변경 사항은 사전에 공지합니다. 불가피한 사유로 서비스가 중단될 경우 - 회원에게 통지합니다. -

-
- -
-

7. 면책조항

-

- BrandKit은 서비스 이용으로 발생하는 간접적, 부수적, 결과적 손해에 대해 - 책임지지 않습니다. AI 생성 콘텐츠의 정확성이나 적합성에 대한 보증을 제공하지 않습니다. -

-
+export default async function TermsPage({ params }: { params: Promise<{ locale: string }> }) { + const { locale } = await params -
-

8. 문의

-

- 이용약관에 관한 문의사항은 support@brandkit.app으로 연락해 주세요. -

-
-
-
-
- ) + return } diff --git a/app/sitemap.ts b/app/sitemap.ts index aebf64d..7ffbdd5 100644 --- a/app/sitemap.ts +++ b/app/sitemap.ts @@ -1,16 +1,16 @@ import type { MetadataRoute } from 'next' import { routing } from '@/i18n/routing' +import { getSiteUrl } from '@/lib/seo/site-url' export default function sitemap(): MetadataRoute.Sitemap { - const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'https://brandkit.app' + const baseUrl = getSiteUrl() const locales = routing.locales const publicPages = [ { path: '', changeFrequency: 'weekly' as const, priority: 1.0 }, - { path: '/login', changeFrequency: 'monthly' as const, priority: 0.6 }, - { path: '/signup', changeFrequency: 'monthly' as const, priority: 0.7 }, - { path: '/privacy', changeFrequency: 'yearly' as const, priority: 0.3 }, - { path: '/terms', changeFrequency: 'yearly' as const, priority: 0.3 }, + { path: '/demo', changeFrequency: 'weekly' as const, priority: 0.8 }, + { path: '/privacy', changeFrequency: 'monthly' as const, priority: 0.3 }, + { path: '/terms', changeFrequency: 'monthly' as const, priority: 0.3 }, ] const entries: MetadataRoute.Sitemap = [] diff --git a/components/landing/faq.tsx b/components/landing/faq.tsx index 97b87f1..020fb1c 100644 --- a/components/landing/faq.tsx +++ b/components/landing/faq.tsx @@ -3,30 +3,21 @@ import { useState } from 'react' import { useTranslations } from 'next-intl' import { ChevronDown, HelpCircle, Mail, ArrowRight } from 'lucide-react' - -const faqItems = [ - { id: '1', questionKey: 'faq.items.1.question', answerKey: 'faq.items.1.answer' }, - { id: '2', questionKey: 'faq.items.2.question', answerKey: 'faq.items.2.answer' }, - { id: '3', questionKey: 'faq.items.3.question', answerKey: 'faq.items.3.answer' }, - { id: '4', questionKey: 'faq.items.4.question', answerKey: 'faq.items.4.answer' }, - { id: '5', questionKey: 'faq.items.5.question', answerKey: 'faq.items.5.answer' }, - { id: '6', questionKey: 'faq.items.6.question', answerKey: 'faq.items.6.answer' }, -] +import { getLandingFaqItems } from '@/lib/seo/faq' function FAQItem({ - questionKey, - answerKey, + question, + answer, isOpen, onToggle, index, }: { - questionKey: string - answerKey: string + question: string + answer: string isOpen: boolean onToggle: () => void index: number }) { - const t = useTranslations('landing') const panelMaxHeightClass = isOpen ? 'max-h-[9999px] opacity-100' : 'max-h-0 opacity-0' return ( @@ -60,13 +51,8 @@ function FAQItem({ > {index + 1} - - {t(questionKey)} + + {question}
-

- {t(answerKey)} -

+

{answer}

@@ -104,6 +88,7 @@ function FAQItem({ export function FAQ() { const t = useTranslations('landing') const [openId, setOpenId] = useState(null) + const faqItems = getLandingFaqItems(t) const toggle = (id: string) => { setOpenId(openId === id ? null : id) @@ -136,8 +121,8 @@ export function FAQ() { {faqItems.map((item, idx) => ( toggle(item.id)} index={idx} diff --git a/components/legal/legal-document.tsx b/components/legal/legal-document.tsx new file mode 100644 index 0000000..2ba9a17 --- /dev/null +++ b/components/legal/legal-document.tsx @@ -0,0 +1,48 @@ +import { ArrowLeft } from 'lucide-react' +import { Link } from '@/i18n/navigation' +import type { LegalDocument } from '@/lib/legal/documents' + +export function LegalDocumentPage({ document }: { document: LegalDocument }) { + return ( +
+
+ + + {document.backLabel} + + +

+ {document.title} +

+

+ {document.lastUpdatedLabel} {document.lastUpdated} +

+ +
+ {document.sections.map((section) => ( +
+

+ {section.title} +

+ {section.paragraphs.map((paragraph) => ( +

+ {paragraph} +

+ ))} + {section.bullets ? ( +
    + {section.bullets.map((bullet) => ( +
  • {bullet}
  • + ))} +
+ ) : null} +
+ ))} +
+
+
+ ) +} diff --git a/lib/legal/documents.ts b/lib/legal/documents.ts new file mode 100644 index 0000000..f5c9018 --- /dev/null +++ b/lib/legal/documents.ts @@ -0,0 +1,260 @@ +export type LegalDocumentType = 'privacy' | 'terms' + +export interface LegalSection { + title: string + paragraphs: readonly string[] + bullets?: readonly string[] +} + +export interface LegalDocument { + backLabel: string + title: string + lastUpdatedLabel: string + lastUpdated: string + sections: readonly LegalSection[] +} + +const legalDocuments = { + en: { + privacy: { + backLabel: 'Back to home', + title: 'Privacy Policy', + lastUpdatedLabel: 'Last updated:', + lastUpdated: 'January 1, 2024', + sections: [ + { + title: '1. Information We Collect', + paragraphs: [ + 'BrandKit collects the following information to provide the service:', + ], + bullets: [ + 'Email address for account creation and login', + 'Billing information for paid subscriptions', + 'Usage data such as project creation and asset generation activity', + ], + }, + { + title: '2. How We Use Information', + paragraphs: [ + 'We use collected information for the following purposes:', + ], + bullets: [ + 'Delivering the service and managing accounts', + 'Processing payments and managing subscriptions', + 'Improving the product and developing new features', + 'Responding to support requests and customer inquiries', + ], + }, + { + title: '3. Retention and Deletion', + paragraphs: [ + 'Personal information is retained while your account is active and deleted within 30 days of an account deletion request, unless a longer retention period is required by law.', + ], + }, + { + title: '4. Sharing with Third Parties', + paragraphs: [ + 'BrandKit does not share personal information with third parties except in the following cases:', + ], + bullets: [ + 'When you give prior consent', + 'When disclosure is required by law or a lawful request from an investigative authority', + ], + }, + { + title: '5. Contact', + paragraphs: [ + 'For privacy-related questions, contact support@brandkit.app.', + ], + }, + ], + }, + terms: { + backLabel: 'Back to home', + title: 'Terms of Service', + lastUpdatedLabel: 'Last updated:', + lastUpdated: 'January 1, 2024', + sections: [ + { + title: '1. Service Overview', + paragraphs: [ + 'BrandKit is an AI-powered brand asset generation service. These terms describe the conditions, procedures, rights, obligations, and responsibilities related to using BrandKit.', + ], + }, + { + title: '2. Accounts and Registration', + paragraphs: [ + 'You can create an account using email or social login providers such as Google and GitHub. You are responsible for providing accurate information and maintaining account security.', + ], + }, + { + title: '3. Acceptable Use', + paragraphs: [ + 'You agree to the following while using the service:', + ], + bullets: [ + 'Use the service only for lawful purposes', + 'Do not infringe on the intellectual property rights of others', + 'Do not interfere with the normal operation of the service', + 'Do not share your account with other people', + ], + }, + { + title: '4. Intellectual Property', + paragraphs: [ + 'Assets generated through BrandKit belong to the member who created them. The BrandKit service itself, including its software, design system, and logos, remains the property of BrandKit.', + ], + }, + { + title: '5. Billing and Refunds', + paragraphs: [ + 'Paid services are offered as subscriptions and renew automatically on the billing date. Refund requests may be made within 7 days of payment, but refunds can be limited for services already used.', + ], + }, + { + title: '6. Service Changes and Interruptions', + paragraphs: [ + 'BrandKit may change the service to improve it. Material changes will be announced in advance when possible, and members will be notified if the service must be interrupted for unavoidable reasons.', + ], + }, + { + title: '7. Disclaimer', + paragraphs: [ + 'BrandKit is not liable for indirect, incidental, or consequential damages arising from use of the service. We do not guarantee the accuracy or fitness of AI-generated content.', + ], + }, + { + title: '8. Contact', + paragraphs: [ + 'For questions about these terms, contact support@brandkit.app.', + ], + }, + ], + }, + }, + ko: { + privacy: { + backLabel: '홈으로 돌아가기', + title: '개인정보처리방침', + lastUpdatedLabel: '최종 수정일:', + lastUpdated: '2024년 1월 1일', + sections: [ + { + title: '1. 수집하는 개인정보', + paragraphs: [ + 'BrandKit은 서비스 제공을 위해 다음과 같은 개인정보를 수집합니다:', + ], + bullets: [ + '이메일 주소 (계정 생성 및 로그인)', + '결제 정보 (유료 구독 시)', + '서비스 이용 기록 (프로젝트 생성, 에셋 생성 등)', + ], + }, + { + title: '2. 개인정보의 이용 목적', + paragraphs: [ + '수집된 개인정보는 다음의 목적으로 이용됩니다:', + ], + bullets: [ + '서비스 제공 및 계정 관리', + '결제 처리 및 구독 관리', + '서비스 개선 및 신규 기능 개발', + '고객 지원 및 문의 응대', + ], + }, + { + title: '3. 개인정보의 보관 및 파기', + paragraphs: [ + '개인정보는 서비스 이용 기간 동안 보관되며, 계정 삭제 요청 시 30일 이내에 파기됩니다. 단, 관련 법령에 따라 일정 기간 보관이 필요한 경우 해당 기간 동안 보관됩니다.', + ], + }, + { + title: '4. 개인정보의 제3자 제공', + paragraphs: [ + 'BrandKit은 원칙적으로 이용자의 개인정보를 제3자에게 제공하지 않습니다. 다만, 다음의 경우에는 예외로 합니다:', + ], + bullets: [ + '이용자가 사전에 동의한 경우', + '법령의 규정에 의거하거나, 수사 목적으로 법령에 정해진 절차와 방법에 따라 수사기관의 요구가 있는 경우', + ], + }, + { + title: '5. 문의', + paragraphs: [ + '개인정보 처리에 관한 문의사항은 support@brandkit.app으로 연락해 주세요.', + ], + }, + ], + }, + terms: { + backLabel: '홈으로 돌아가기', + title: '이용약관', + lastUpdatedLabel: '최종 수정일:', + lastUpdated: '2024년 1월 1일', + sections: [ + { + title: '1. 서비스 개요', + paragraphs: [ + 'BrandKit은 AI 기반 브랜드 에셋 생성 서비스입니다. 본 약관은 BrandKit 서비스 이용에 관한 조건 및 절차, 회사와 회원의 권리, 의무 및 책임사항을 규정합니다.', + ], + }, + { + title: '2. 회원가입 및 계정', + paragraphs: [ + '회원가입은 이메일 주소 또는 소셜 로그인(Google, GitHub)을 통해 가능합니다. 회원은 정확한 정보를 제공해야 하며, 계정 보안에 대한 책임이 있습니다.', + ], + }, + { + title: '3. 서비스 이용', + paragraphs: [ + '회원은 다음 사항을 준수해야 합니다:', + ], + bullets: [ + '서비스를 합법적인 목적으로만 사용', + '타인의 지적재산권을 침해하지 않음', + '서비스의 정상적인 운영을 방해하지 않음', + '계정을 타인과 공유하지 않음', + ], + }, + { + title: '4. 지적재산권', + paragraphs: [ + 'BrandKit 서비스를 통해 생성된 에셋의 저작권은 해당 회원에게 귀속됩니다. 단, BrandKit 서비스 자체의 소프트웨어, 디자인, 로고 등에 대한 지적재산권은 BrandKit에 귀속됩니다.', + ], + }, + { + title: '5. 결제 및 환불', + paragraphs: [ + '유료 서비스는 구독 형태로 제공되며, 결제일에 자동으로 갱신됩니다. 환불은 결제 후 7일 이내에 요청 시 가능하며, 이미 사용한 서비스에 대해서는 환불이 제한될 수 있습니다.', + ], + }, + { + title: '6. 서비스 변경 및 중단', + paragraphs: [ + 'BrandKit은 서비스 개선을 위해 서비스 내용을 변경할 수 있으며, 중요한 변경 사항은 사전에 공지합니다. 불가피한 사유로 서비스가 중단될 경우 회원에게 통지합니다.', + ], + }, + { + title: '7. 면책조항', + paragraphs: [ + 'BrandKit은 서비스 이용으로 발생하는 간접적, 부수적, 결과적 손해에 대해 책임지지 않습니다. AI 생성 콘텐츠의 정확성이나 적합성에 대한 보증을 제공하지 않습니다.', + ], + }, + { + title: '8. 문의', + paragraphs: [ + '이용약관에 관한 문의사항은 support@brandkit.app으로 연락해 주세요.', + ], + }, + ], + }, + }, +} as const + +export function getLegalDocument( + locale: string, + type: LegalDocumentType +): LegalDocument { + const normalizedLocale = locale === 'ko' ? 'ko' : 'en' + return legalDocuments[normalizedLocale][type] +} diff --git a/lib/seo/faq.ts b/lib/seo/faq.ts new file mode 100644 index 0000000..0202314 --- /dev/null +++ b/lib/seo/faq.ts @@ -0,0 +1,17 @@ +export interface LandingFaqItem { + id: string + question: string + answer: string +} + +const landingFaqIds = ['1', '2', '3', '4', '5', '6'] as const + +export function getLandingFaqItems( + translate: (key: string) => string +): LandingFaqItem[] { + return landingFaqIds.map((id) => ({ + id, + question: translate(`faq.items.${id}.question`), + answer: translate(`faq.items.${id}.answer`), + })) +} diff --git a/lib/seo/json-ld.ts b/lib/seo/json-ld.ts index a0ab484..ff1f449 100644 --- a/lib/seo/json-ld.ts +++ b/lib/seo/json-ld.ts @@ -1,21 +1,31 @@ -import type { Organization, WebSite, SoftwareApplication, BreadcrumbList, WithContext } from 'schema-dts' +import type { + BreadcrumbList, + FAQPage, + Organization, + SoftwareApplication, + WebSite, + WithContext, +} from 'schema-dts' +import { getSiteUrl, getStructuredDataLocale } from './site-url' -const BASE_URL = process.env.NEXT_PUBLIC_APP_URL || 'https://brandkit.app' +const BASE_URL = getSiteUrl() export function generateOrganizationSchema(): WithContext { + const sameAs: string[] = [] + return { '@context': 'https://schema.org', '@type': 'Organization', name: 'BrandKit', url: BASE_URL, logo: `${BASE_URL}/logo.png`, - sameAs: [], contactPoint: { '@type': 'ContactPoint', email: 'support@brandkit.app', contactType: 'customer service', availableLanguage: ['English', 'Korean'], }, + ...(sameAs.length > 0 ? { sameAs } : {}), } } @@ -26,55 +36,81 @@ export function generateWebSiteSchema(locale: string): WithContext { name: 'BrandKit', url: `${BASE_URL}/${locale}`, inLanguage: locale === 'ko' ? 'ko-KR' : 'en-US', - description: locale === 'ko' - ? 'AI 기반 브랜드 에셋 생성기' - : 'AI-powered brand asset generator', + description: + locale === 'ko' ? 'AI 기반 브랜드 에셋 생성기' : 'AI-powered brand asset generator', } } -export function generateSoftwareApplicationSchema(locale: string): WithContext { +export function generateSoftwareApplicationSchema( + locale: string +): WithContext { const isKorean = locale === 'ko' + const localizedUrl = `${BASE_URL}/${locale}` return { '@context': 'https://schema.org', '@type': 'SoftwareApplication', name: 'BrandKit', + url: localizedUrl, + inLanguage: getStructuredDataLocale(locale), applicationCategory: 'DesignApplication', + applicationSubCategory: isKorean + ? '브랜드 에셋 생성 워크플로우' + : 'Brand asset generation workflow', operatingSystem: 'Web', + image: `${BASE_URL}/api/og?locale=${locale}`, offers: { '@type': 'Offer', price: '0', priceCurrency: 'USD', + url: `${localizedUrl}/signup`, description: isKorean - ? '무료 플랜 - 월 3개 프로젝트, 1개 브랜드 프로필' - : 'Free plan - 3 projects/month, 1 brand profile', - }, - aggregateRating: { - '@type': 'AggregateRating', - ratingValue: '4.8', - ratingCount: '100', - bestRating: '5', - worstRating: '1', + ? '무료 플랜 - 월 3개 프로젝트, 브랜드 프로필 2개로 출시 준비를 시작하세요.' + : 'Free plan - launch up to 3 projects per month with 2 brand profiles before you upgrade.', }, description: isKorean - ? 'AI 기반 브랜드 에셋 생성기. Favicon, OG Image, App Icon 등 모든 플랫폼의 브랜드 에셋을 자동으로 생성합니다.' - : 'AI-powered brand asset generator. Automatically generate Favicon, OG Image, App Icon and all platform brand assets.', + ? '브랜드 프로필을 한 번 저장하면 Favicon, OG Image, 앱 아이콘, 소셜 미리보기까지 출시용 브랜드 에셋을 자동으로 생성하는 AI 브랜드 에셋 워크플로우입니다.' + : 'AI brand asset workflow that turns one brand profile into favicons, OG images, app icons, and social previews for your next launch.', featureList: isKorean ? [ - 'AI 기반 브랜드 카피 생성', - '플랫폼별 에셋 패키지', - '브랜드 프로필 관리', - '코드 스니펫 제공', + '브랜드 프로필 기반 에셋 생성', + 'Favicon, OG Image, 앱 아이콘 자동 생성', + '출시용 ZIP 다운로드와 코드 스니펫 제공', + '영문·국문 브랜드 워크플로우 지원', ] : [ - 'AI-powered brand copy generation', - 'Platform-specific asset packages', - 'Brand profile management', - 'Code snippets included', + 'Generate assets from a saved brand profile', + 'Auto-create favicons, OG images, and app icons', + 'Download launch-ready ZIP files with code snippets', + 'Support English and Korean brand workflows', ], } } +export interface FaqSchemaItem { + question: string + answer: string +} + +export function generateFaqPageSchema( + locale: string, + items: FaqSchemaItem[] +): WithContext { + return { + '@context': 'https://schema.org', + '@type': 'FAQPage', + inLanguage: getStructuredDataLocale(locale), + mainEntity: items.map((item) => ({ + '@type': 'Question', + name: item.question, + acceptedAnswer: { + '@type': 'Answer', + text: item.answer, + }, + })), + } +} + export interface BreadcrumbItem { name: string url: string diff --git a/lib/seo/metadata.ts b/lib/seo/metadata.ts new file mode 100644 index 0000000..cffe06d --- /dev/null +++ b/lib/seo/metadata.ts @@ -0,0 +1,97 @@ +import type { Metadata } from 'next' +import { buildLocalizedAlternates, buildLocalizedUrl, getOpenGraphLocale } from './site-url' + +interface IndexablePageMetadataOptions { + locale: string + title: string + description: string + path?: string +} + +interface NoIndexMetadataOptions { + locale: string + path: string + title: string + description: string +} + +export function generateIndexablePageMetadata({ + locale, + title, + description, + path = '', +}: IndexablePageMetadataOptions): Metadata { + return { + title, + description, + alternates: buildLocalizedAlternates(locale, path), + openGraph: { + type: 'website', + locale: getOpenGraphLocale(locale), + url: buildLocalizedUrl(locale, path), + siteName: 'BrandKit', + title, + description, + images: [ + { + url: `/api/og?locale=${locale}`, + width: 1200, + height: 630, + alt: title, + }, + ], + }, + twitter: { + card: 'summary_large_image', + title, + description, + images: [`/api/og?locale=${locale}`], + }, + } +} + +export function generateNoIndexMetadata({ + locale, + path, + title, + description, +}: NoIndexMetadataOptions): Metadata { + return { + title, + description, + alternates: buildLocalizedAlternates(locale, path), + openGraph: { + type: 'website', + locale: getOpenGraphLocale(locale), + url: buildLocalizedUrl(locale, path), + siteName: 'BrandKit', + title, + description, + images: [ + { + url: `/api/og?locale=${locale}`, + width: 1200, + height: 630, + alt: title, + }, + ], + }, + twitter: { + card: 'summary_large_image', + title, + description, + images: [`/api/og?locale=${locale}`], + }, + robots: { + index: false, + follow: false, + googleBot: { + index: false, + follow: false, + 'max-image-preview': 'none', + 'max-snippet': -1, + 'max-video-preview': -1, + }, + }, + } +} diff --git a/lib/seo/site-url.ts b/lib/seo/site-url.ts new file mode 100644 index 0000000..c337b67 --- /dev/null +++ b/lib/seo/site-url.ts @@ -0,0 +1,48 @@ +import { routing } from '@/i18n/routing' + +const DEFAULT_SITE_URL = 'https://brand-kit.jadru.com' + +function normalizePath(path: string): string { + if (!path) return '' + return path.startsWith('/') ? path : `/${path}` +} + +export function getSiteUrl(): string { + if (process.env.NEXT_PUBLIC_APP_URL) { + return process.env.NEXT_PUBLIC_APP_URL + } + + if (process.env.NODE_ENV === 'development') { + return 'http://localhost:3000' + } + + return DEFAULT_SITE_URL +} + +export function getOpenGraphLocale(locale: string): string { + return locale === 'ko' ? 'ko_KR' : 'en_US' +} + +export function getStructuredDataLocale(locale: string): string { + return locale === 'ko' ? 'ko-KR' : 'en-US' +} + +export function buildLocalizedUrl(locale: string, path = ''): string { + return `${getSiteUrl()}/${locale}${normalizePath(path)}` +} + +export function buildLocalizedAlternates(locale: string, path = '') { + const normalizedPath = normalizePath(path) + const defaultLocale = routing.defaultLocale ?? routing.locales[0] + + return { + canonical: buildLocalizedUrl(locale, normalizedPath), + languages: Object.fromEntries([ + ...routing.locales.map((entryLocale) => [ + entryLocale, + buildLocalizedUrl(entryLocale, normalizedPath), + ]), + ['x-default', buildLocalizedUrl(defaultLocale, normalizedPath)], + ]), + } +} diff --git a/messages/en.json b/messages/en.json index dd36b33..4f3ca92 100644 --- a/messages/en.json +++ b/messages/en.json @@ -729,26 +729,30 @@ "metadata": { "title": { - "default": "BrandKit - AI Brand Asset Generator", + "default": "BrandKit - AI Brand Asset Generator | Favicon, OG Image & App Icons", "template": "%s | BrandKit" }, - "description": "Save your brand style and auto-generate all brand assets", - "keywords": "brand assets, favicon generator, og image, app icon, brand identity, AI copywriting", + "description": "Save one brand profile and let BrandKit generate favicons, OG images, app icons, and social previews for your next launch. Start free and ship a consistent brand system faster.", + "keywords": "brand asset generator, favicon generator, og image, app icon, social preview, brand identity, AI copywriting", "privacy": { "title": "Privacy Policy", - "description": "Learn how BrandKit collects, uses, and protects your personal information" + "description": "Review how BrandKit handles your data, accounts, and generated assets." }, "terms": { "title": "Terms of Service", - "description": "Read the terms and conditions for using BrandKit services" + "description": "Read the terms for using BrandKit to create and manage launch-ready brand assets." }, "login": { "title": "Log in", - "description": "Sign in to your BrandKit account to manage your brand assets" + "description": "Sign in to keep your brand profiles, launch assets, and downloads in sync." }, "signup": { "title": "Sign up", - "description": "Create a free BrandKit account and start generating brand assets with AI" + "description": "Create a free BrandKit account and generate launch-ready brand assets from one saved brand profile." + }, + "forgotPassword": { + "title": "Reset password", + "description": "Send a secure reset link and get back to your saved brand profiles and launch assets." }, "dashboard": { "title": "Dashboard", diff --git a/messages/ko.json b/messages/ko.json index 9070444..132fe34 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -729,26 +729,30 @@ "metadata": { "title": { - "default": "BrandKit - AI 브랜드 에셋 생성기", + "default": "BrandKit - AI 브랜드 에셋 자동 생성 | Favicon, OG Image, 앱 아이콘", "template": "%s | BrandKit" }, - "description": "브랜드 스타일을 저장하고 모든 브랜드 에셋을 자동 생성하세요", - "keywords": "브랜드 에셋, 파비콘 생성기, OG 이미지, 앱 아이콘, 브랜드 아이덴티티, AI 카피라이팅", + "description": "브랜드 프로필을 한 번 저장하면 BrandKit이 Favicon, OG Image, 앱 아이콘, 소셜 미리보기까지 다음 출시용 브랜드 에셋을 자동 생성합니다. 무료로 시작하고 더 빠르게 일관된 브랜드를 출시하세요.", + "keywords": "브랜드 에셋 생성기, 파비콘 생성기, OG 이미지, 앱 아이콘, 소셜 미리보기, 브랜드 아이덴티티, AI 카피라이팅", "privacy": { "title": "개인정보처리방침", - "description": "BrandKit이 개인정보를 수집, 사용, 보호하는 방법을 알아보세요" + "description": "BrandKit이 데이터, 계정, 생성된 에셋을 어떻게 다루는지 확인하세요." }, "terms": { "title": "이용약관", - "description": "BrandKit 서비스 이용 약관을 확인하세요" + "description": "BrandKit으로 출시용 브랜드 에셋을 생성하고 관리할 때 적용되는 약관을 확인하세요." }, "login": { "title": "로그인", - "description": "BrandKit 계정에 로그인하여 브랜드 에셋을 관리하세요" + "description": "브랜드 프로필, 출시 에셋, 다운로드를 계속 관리하려면 로그인하세요." }, "signup": { "title": "회원가입", - "description": "무료 BrandKit 계정을 만들고 AI로 브랜드 에셋을 생성하세요" + "description": "무료 BrandKit 계정을 만들고 하나의 브랜드 프로필로 출시용 브랜드 에셋을 생성하세요." + }, + "forgotPassword": { + "title": "비밀번호 재설정", + "description": "안전한 재설정 링크를 받아 저장한 브랜드 프로필과 출시 에셋으로 다시 돌아오세요." }, "dashboard": { "title": "대시보드", diff --git a/tests/app/legal-pages.test.tsx b/tests/app/legal-pages.test.tsx new file mode 100644 index 0000000..3e9dc62 --- /dev/null +++ b/tests/app/legal-pages.test.tsx @@ -0,0 +1,43 @@ +import { renderToStaticMarkup } from 'react-dom/server' +import { describe, expect, it, vi } from 'vitest' + +vi.mock('@/i18n/navigation', () => ({ + Link: ({ + children, + href, + ...props + }: { + children: React.ReactNode + href: string + }) => ( + + {children} + + ), +})) + +describe('legal pages', () => { + it('renders english privacy content for the english locale', async () => { + const module = await import('@/app/[locale]/privacy/page') + const element = await module.default({ + params: Promise.resolve({ locale: 'en' }), + } as never) + const html = renderToStaticMarkup(element) + + expect(html).toContain('Privacy Policy') + expect(html).toContain('Last updated:') + expect(html).not.toContain('개인정보처리방침') + }) + + it('renders english terms content for the english locale', async () => { + const module = await import('@/app/[locale]/terms/page') + const element = await module.default({ + params: Promise.resolve({ locale: 'en' }), + } as never) + const html = renderToStaticMarkup(element) + + expect(html).toContain('Terms of Service') + expect(html).toContain('Last updated:') + expect(html).not.toContain('이용약관') + }) +}) diff --git a/tests/app/page-metadata.test.ts b/tests/app/page-metadata.test.ts new file mode 100644 index 0000000..b673660 --- /dev/null +++ b/tests/app/page-metadata.test.ts @@ -0,0 +1,323 @@ +import { createElement, type ReactNode } from 'react' +import { describe, expect, it, vi } from 'vitest' + +const dictionary = { + en: { + metadata: { + title: { + default: 'BrandKit - AI Brand Asset Generator | Favicon, OG Image & App Icons', + template: '%s | BrandKit', + }, + description: + 'Save one brand profile and let BrandKit generate favicons, OG images, app icons, and social previews for your next launch. Start free and ship a consistent brand system faster.', + keywords: + 'brand asset generator, favicon generator, og image, app icon, social preview, brand identity, AI copywriting', + login: { + title: 'Log in', + description: 'Sign in to keep your brand profiles, launch assets, and downloads in sync.', + }, + signup: { + title: 'Sign up', + description: + 'Create a free BrandKit account and generate launch-ready brand assets from one saved brand profile.', + }, + forgotPassword: { + title: 'Reset password', + description: + 'Send a secure reset link and get back to your saved brand profiles and launch assets.', + }, + privacy: { + title: 'Privacy Policy', + description: 'Review how BrandKit handles your data, accounts, and generated assets.', + }, + terms: { + title: 'Terms of Service', + description: + 'Read the terms for using BrandKit to create and manage launch-ready brand assets.', + }, + }, + demo: { + meta: { + title: 'Try the BrandKit demo', + description: + 'Preview how one brand profile turns into favicons, OG images, app icons, and code-ready brand assets. No signup required.', + }, + }, + }, + ko: { + metadata: { + title: { + default: 'BrandKit - AI 브랜드 에셋 자동 생성 | Favicon, OG Image, 앱 아이콘', + template: '%s | BrandKit', + }, + description: + '브랜드 프로필을 한 번 저장하면 BrandKit이 Favicon, OG Image, 앱 아이콘, 소셜 미리보기까지 다음 출시용 브랜드 에셋을 자동 생성합니다. 무료로 시작하고 더 빠르게 일관된 브랜드를 출시하세요.', + keywords: + '브랜드 에셋 생성기, 파비콘 생성기, OG 이미지, 앱 아이콘, 소셜 미리보기, 브랜드 아이덴티티, AI 카피라이팅', + login: { + title: '로그인', + description: '브랜드 프로필, 출시 에셋, 다운로드를 계속 관리하려면 로그인하세요.', + }, + signup: { + title: '회원가입', + description: + '무료 BrandKit 계정을 만들고 하나의 브랜드 프로필로 출시용 브랜드 에셋을 생성하세요.', + }, + forgotPassword: { + title: '비밀번호 재설정', + description: + '안전한 재설정 링크를 받아 저장한 브랜드 프로필과 출시 에셋으로 다시 돌아오세요.', + }, + privacy: { + title: '개인정보처리방침', + description: 'BrandKit이 데이터, 계정, 생성된 에셋을 어떻게 다루는지 확인하세요.', + }, + terms: { + title: '이용약관', + description: + 'BrandKit으로 출시용 브랜드 에셋을 생성하고 관리할 때 적용되는 약관을 확인하세요.', + }, + }, + demo: { + meta: { + title: 'BrandKit 데모 체험', + description: + '가입 없이 BrandKit을 체험해보세요. AI가 브랜드 에셋을 즉시 생성하는 과정을 확인하세요.', + }, + }, + }, +} as const + +function getTranslationValue(locale: 'en' | 'ko', namespace: string | undefined, key: string) { + const scoped = namespace + ? (dictionary[locale] as Record)[namespace] + : dictionary[locale] + + return key.split('.').reduce((value, segment) => { + if (value && typeof value === 'object') { + return (value as Record)[segment] + } + + return undefined + }, scoped) +} + +vi.mock('next-intl/server', () => ({ + getTranslations: vi.fn( + async ({ locale, namespace }: { locale: 'en' | 'ko'; namespace?: string }) => { + return (key: string) => { + const value = getTranslationValue(locale, namespace, key) + + if (typeof value !== 'string') { + throw new Error(`Missing translation for ${locale}.${namespace ?? 'root'}.${key}`) + } + + return value + } + } + ), + getMessages: vi.fn(async () => ({})), + setRequestLocale: vi.fn(), +})) + +vi.mock('next/font/google', () => ({ + JetBrains_Mono: () => ({ + variable: 'mock-font-variable', + }), +})) + +vi.mock('next-intl', () => ({ + NextIntlClientProvider: ({ children }: { children: ReactNode }) => children, +})) + +vi.mock('@/components/seo/json-ld', () => ({ + JsonLd: () => null, +})) + +vi.mock('@/components/analytics/google-analytics', () => ({ + GoogleAnalytics: () => null, +})) + +vi.mock('sonner', () => ({ + Toaster: () => null, +})) + +vi.mock('@/i18n/routing', () => ({ + routing: { + locales: ['en', 'ko'], + defaultLocale: 'en', + }, +})) + +vi.mock('@/i18n/navigation', () => ({ + Link: ({ children, href, ...props }: { children: ReactNode; href: string }) => + createElement('a', { href, ...props }, children), +})) + +vi.mock('@/app/[locale]/(auth)/login/actions', () => ({ + signIn: vi.fn(), + signInWithOAuth: vi.fn(), +})) + +vi.mock('@/app/[locale]/(auth)/signup/actions', () => ({ + signUp: vi.fn(), + signUpWithOAuth: vi.fn(), +})) + +vi.mock('@/app/[locale]/(auth)/forgot-password/actions', () => ({ + sendResetEmail: vi.fn(), +})) + +vi.mock('@/app/[locale]/demo/client', () => ({ + DemoClient: () => null, +})) + +describe('auth page metadata', () => { + const authPages = [ + { + name: 'login', + load: () => import('@/app/[locale]/(auth)/login/page'), + expected: { + title: 'Log in', + description: 'Sign in to keep your brand profiles, launch assets, and downloads in sync.', + }, + }, + { + name: 'signup', + load: () => import('@/app/[locale]/(auth)/signup/page'), + expected: { + title: 'Sign up', + description: + 'Create a free BrandKit account and generate launch-ready brand assets from one saved brand profile.', + }, + }, + { + name: 'forgot-password', + load: () => import('@/app/[locale]/(auth)/forgot-password/page'), + expected: { + title: 'Reset password', + description: + 'Send a secure reset link and get back to your saved brand profiles and launch assets.', + }, + }, + ] as const + + it.each(authPages)('exports noindex metadata for $name', async ({ name, load, expected }) => { + const module = await load() + + expect(module.generateMetadata).toBeTypeOf('function') + + const metadata = await module.generateMetadata?.({ + params: Promise.resolve({ locale: 'en' }), + }) + + expect(metadata).toMatchObject({ + title: expected.title, + description: expected.description, + robots: { + index: false, + follow: false, + }, + }) + }) +}) + +describe('homepage metadata', () => { + it('declares canonical, language alternates, and social metadata for the homepage', async () => { + const module = await import('@/app/[locale]/page') + + const metadata = await module.generateMetadata({ + params: Promise.resolve({ locale: 'en' }), + }) + + expect(metadata).toMatchObject({ + title: 'BrandKit - AI Brand Asset Generator | Favicon, OG Image & App Icons', + description: + 'Save one brand profile and let BrandKit generate favicons, OG images, app icons, and social previews for your next launch. Start free and ship a consistent brand system faster.', + alternates: { + canonical: 'https://brand-kit.jadru.com/en', + languages: { + en: 'https://brand-kit.jadru.com/en', + ko: 'https://brand-kit.jadru.com/ko', + 'x-default': 'https://brand-kit.jadru.com/en', + }, + }, + openGraph: { + url: 'https://brand-kit.jadru.com/en', + title: 'BrandKit - AI Brand Asset Generator | Favicon, OG Image & App Icons', + }, + twitter: { + card: 'summary_large_image', + }, + }) + }) +}) + +describe('legal page metadata', () => { + it('keeps privacy and terms pages indexable with explicit canonical metadata', async () => { + const privacy = await import('@/app/[locale]/privacy/page') + const terms = await import('@/app/[locale]/terms/page') + + const privacyMetadata = await privacy.generateMetadata({ + params: Promise.resolve({ locale: 'en' }), + }) + const termsMetadata = await terms.generateMetadata({ + params: Promise.resolve({ locale: 'en' }), + }) + + expect(privacyMetadata).toMatchObject({ + title: 'Privacy Policy', + description: 'Review how BrandKit handles your data, accounts, and generated assets.', + alternates: { + canonical: 'https://brand-kit.jadru.com/en/privacy', + }, + openGraph: { + url: 'https://brand-kit.jadru.com/en/privacy', + }, + }) + + expect(termsMetadata).toMatchObject({ + title: 'Terms of Service', + description: + 'Read the terms for using BrandKit to create and manage launch-ready brand assets.', + alternates: { + canonical: 'https://brand-kit.jadru.com/en/terms', + }, + openGraph: { + url: 'https://brand-kit.jadru.com/en/terms', + }, + }) + }) +}) + +describe('demo page metadata', () => { + it('declares explicit alternates and open graph metadata', async () => { + const module = await import('@/app/[locale]/demo/page') + + expect(module.generateMetadata).toBeTypeOf('function') + + const metadata = await module.generateMetadata({ + params: Promise.resolve({ locale: 'en' }), + }) + + expect(metadata).toMatchObject({ + title: 'Try the BrandKit demo', + description: + 'Preview how one brand profile turns into favicons, OG images, app icons, and code-ready brand assets. No signup required.', + alternates: { + canonical: 'https://brand-kit.jadru.com/en/demo', + languages: { + en: 'https://brand-kit.jadru.com/en/demo', + ko: 'https://brand-kit.jadru.com/ko/demo', + 'x-default': 'https://brand-kit.jadru.com/en/demo', + }, + }, + openGraph: { + url: 'https://brand-kit.jadru.com/en/demo', + title: 'Try the BrandKit demo', + description: + 'Preview how one brand profile turns into favicons, OG images, app icons, and code-ready brand assets. No signup required.', + }, + }) + }) +}) diff --git a/tests/app/sitemap.test.ts b/tests/app/sitemap.test.ts new file mode 100644 index 0000000..9047b6a --- /dev/null +++ b/tests/app/sitemap.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from 'vitest' +import sitemap from '@/app/sitemap' + +describe('sitemap', () => { + it('includes only indexable public pages for every locale', () => { + const entries = sitemap() + const paths = entries.map((entry) => new URL(entry.url).pathname).sort() + + expect(paths).toEqual([ + '/en', + '/en/demo', + '/en/privacy', + '/en/terms', + '/ko', + '/ko/demo', + '/ko/privacy', + '/ko/terms', + ]) + }) + + it('excludes auth pages from the sitemap', () => { + const paths = sitemap().map((entry) => new URL(entry.url).pathname) + + expect(paths).not.toContain('/en/login') + expect(paths).not.toContain('/en/signup') + expect(paths).not.toContain('/ko/login') + expect(paths).not.toContain('/ko/signup') + }) +}) diff --git a/tests/i18n/metadata-copy.test.ts b/tests/i18n/metadata-copy.test.ts new file mode 100644 index 0000000..2be8a05 --- /dev/null +++ b/tests/i18n/metadata-copy.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'vitest' +import en from '@/messages/en.json' +import ko from '@/messages/ko.json' + +describe('metadata copy', () => { + it('uses more descriptive homepage title copy', () => { + expect(en.metadata.title.default.length).toBeGreaterThanOrEqual(50) + expect(ko.metadata.title.default.length).toBeGreaterThanOrEqual(35) + expect(ko.metadata.title.default).toContain('Favicon') + expect(ko.metadata.title.default).toContain('OG Image') + }) + + it('uses fuller homepage descriptions with a call to action', () => { + expect(en.metadata.description.length).toBeGreaterThanOrEqual(120) + expect(en.metadata.description.toLowerCase()).toContain('start free') + expect(ko.metadata.description.length).toBeGreaterThanOrEqual(55) + expect(ko.metadata.description).toContain('무료') + }) + + it('adds forgot-password metadata copy in both locales', () => { + expect(en.metadata).toHaveProperty('forgotPassword.title') + expect(en.metadata).toHaveProperty('forgotPassword.description') + expect(ko.metadata).toHaveProperty('forgotPassword.title') + expect(ko.metadata).toHaveProperty('forgotPassword.description') + }) + + it('positions the homepage for launch-ready brand assets in both locales', () => { + expect(en.metadata.description.toLowerCase()).toContain('brand profile') + expect(en.metadata.description.toLowerCase()).toContain('launch') + expect(ko.metadata.description).toContain('브랜드 프로필') + expect(ko.metadata.description).toContain('출시') + }) + + it('avoids unverifiable trust-badge claims in public landing copy', () => { + expect(en.landing.testimonials.trustBadge1.toLowerCase()).not.toContain('soc 2') + expect(en.landing.testimonials.trustBadge2).not.toContain('99.9') + expect(ko.landing.testimonials.trustBadge1).not.toContain('SOC 2') + expect(ko.landing.testimonials.trustBadge2).not.toContain('99.9') + }) +}) diff --git a/tests/lib/seo/json-ld.test.ts b/tests/lib/seo/json-ld.test.ts new file mode 100644 index 0000000..a188a17 --- /dev/null +++ b/tests/lib/seo/json-ld.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it } from 'vitest' +import * as seoJsonLd from '@/lib/seo/json-ld' + +describe('generateOrganizationSchema', () => { + it('omits empty sameAs profiles', () => { + const schema = seoJsonLd.generateOrganizationSchema() + + expect(schema).not.toHaveProperty('sameAs') + }) +}) + +describe('generateSoftwareApplicationSchema', () => { + it('does not include unverifiable aggregate rating data and includes product URLs', () => { + const schema = seoJsonLd.generateSoftwareApplicationSchema('en') + + expect(schema).not.toHaveProperty('aggregateRating') + expect(schema).toMatchObject({ + url: 'https://brand-kit.jadru.com/en', + offers: { + url: 'https://brand-kit.jadru.com/en/signup', + }, + }) + }) +}) + +describe('generateFaqPageSchema', () => { + it('builds faq rich result data from translated question-answer pairs', () => { + expect(typeof seoJsonLd.generateFaqPageSchema).toBe('function') + + const schema = seoJsonLd.generateFaqPageSchema?.('en', [ + { + question: 'What does BrandKit generate?', + answer: 'It generates favicons, OG images, app icons, and more.', + }, + { + question: 'Can I use the assets commercially?', + answer: 'Yes. Commercial use is allowed.', + }, + ]) + + expect(schema).toMatchObject({ + '@context': 'https://schema.org', + '@type': 'FAQPage', + inLanguage: 'en-US', + mainEntity: [ + { + '@type': 'Question', + name: 'What does BrandKit generate?', + acceptedAnswer: { + '@type': 'Answer', + text: 'It generates favicons, OG images, app icons, and more.', + }, + }, + { + '@type': 'Question', + name: 'Can I use the assets commercially?', + acceptedAnswer: { + '@type': 'Answer', + text: 'Yes. Commercial use is allowed.', + }, + }, + ], + }) + }) +}) + +describe('generateBreadcrumbSchema', () => { + it('builds absolute localized urls for breadcrumb items', () => { + const schema = seoJsonLd.generateBreadcrumbSchema( + [ + { name: 'Home', url: '' }, + { name: 'Demo', url: '/demo' }, + ], + 'en' + ) + + expect(schema).toMatchObject({ + '@context': 'https://schema.org', + '@type': 'BreadcrumbList', + itemListElement: [ + { + '@type': 'ListItem', + position: 1, + name: 'Home', + item: 'https://brand-kit.jadru.com/en', + }, + { + '@type': 'ListItem', + position: 2, + name: 'Demo', + item: 'https://brand-kit.jadru.com/en/demo', + }, + ], + }) + }) +})