diff --git a/app/[locale]/(auth)/forgot-password/actions.ts b/app/[locale]/(auth)/forgot-password/actions.ts new file mode 100644 index 0000000..39e4210 --- /dev/null +++ b/app/[locale]/(auth)/forgot-password/actions.ts @@ -0,0 +1,25 @@ +'use server' + +import { createClient } from '@/lib/supabase/server' +import { headers } from 'next/headers' +import { getBaseUrl } from '@/lib/utils/url' + +async function getOrigin(): Promise { + const headerStore = await headers() + return headerStore.get('origin') || getBaseUrl() +} + +export async function sendResetEmail({ email }: { email: string }) { + const supabase = await createClient() + const origin = await getOrigin() + + const { error } = await supabase.auth.resetPasswordForEmail(email, { + redirectTo: `${origin}/auth/callback?next=/projects`, + }) + + if (error) { + return { error: error.message } + } + + return { error: null } +} diff --git a/app/[locale]/(auth)/forgot-password/page.tsx b/app/[locale]/(auth)/forgot-password/page.tsx new file mode 100644 index 0000000..83a168b --- /dev/null +++ b/app/[locale]/(auth)/forgot-password/page.tsx @@ -0,0 +1,108 @@ +'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)/login/page.tsx b/app/[locale]/(auth)/login/page.tsx index 1532480..05a9033 100644 --- a/app/[locale]/(auth)/login/page.tsx +++ b/app/[locale]/(auth)/login/page.tsx @@ -3,6 +3,7 @@ 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' @@ -10,6 +11,10 @@ import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } 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) @@ -49,27 +54,27 @@ export default function LoginPage() {
- - BrandKit + + {tCommon('brandName')}

- 브랜드 에셋을 + {t('branding.headline')}
- 자동으로. + {t('branding.headlineSub')}

- Brand Profile에 스타일을 저장하면, AI가 모든 플랫폼의 에셋을 생성합니다. + {t('branding.description')}

12+
-
Assets
+
{t('branding.stats.assets')}
<60s
-
Speed
+
{t('branding.stats.speed')}
@@ -80,21 +85,19 @@ export default function LoginPage() {
- - BrandKit - + {tCommon('brandName')}
- Log in - 계정에 로그인하여 계속하세요 + {t('title')} + {t('subtitle')}
- + setEmail(e.target.value)} required @@ -102,20 +105,20 @@ export default function LoginPage() {
- + - Forgot password? + {t('forgotPassword')}
setPassword(e.target.value)} required @@ -126,7 +129,7 @@ export default function LoginPage() { onClick={() => setShowPassword(!showPassword)} className="absolute right-3 top-1/2 -translate-y-1/2 text-text-tertiary transition-colors hover:text-text-secondary" tabIndex={-1} - aria-label={showPassword ? '비밀번호 숨기기' : '비밀번호 표시'} + aria-label={showPassword ? t('hidePassword') : t('showPassword')} > {showPassword ? : } @@ -136,7 +139,7 @@ export default function LoginPage() {

{error}

)} @@ -145,7 +148,7 @@ export default function LoginPage() {
- or + {tCommon('or')}
@@ -159,10 +162,10 @@ export default function LoginPage() { - Google + {tAuth('oauth.google')}

- Don't have an account?{' '} - - Sign up + {t('noAccount')} + + {t('signupLink')}

diff --git a/app/[locale]/(auth)/signup/page.tsx b/app/[locale]/(auth)/signup/page.tsx index 89ead07..cc0bab2 100644 --- a/app/[locale]/(auth)/signup/page.tsx +++ b/app/[locale]/(auth)/signup/page.tsx @@ -3,13 +3,19 @@ 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('') @@ -32,22 +38,25 @@ export default function SignupPage() { setError(null) if (password !== confirmPassword) { - setError('Passwords do not match.') + setError(t('validation.passwordsDoNotMatch', { defaultValue: tAuth('validation.passwordsDoNotMatch') })) return } if (password.length < 6) { - setError('Password must be at least 6 characters.') + 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) } @@ -57,6 +66,8 @@ export default function SignupPage() { 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) @@ -69,15 +80,19 @@ export default function SignupPage() {
- Check your email + {t('success.title')} - We sent a confirmation link to {email}. - Click the link to activate your account. + + {t('success.description', { email })} + + + {t('success.backToLogin')} + - + @@ -93,27 +108,27 @@ export default function SignupPage() {
- + BrandKit

- 브랜드의 모든 것, + {t('branding.headline')}
- 한 곳에서. + {t('branding.headlineSub')}

- 무료로 시작하세요. Favicon, OG Image, App Icon까지 모든 에셋을 자동 생성합니다. + {t('branding.description')}

-
Free
-
Start
+
12+
+
{t('branding.stats.start')}
-
3 proj
-
Per Month
+
3/Month
+
{t('branding.stats.perMonth')}
@@ -124,33 +139,31 @@ export default function SignupPage() {
- - BrandKit - + BrandKit
- Create your account - AI로 브랜드 에셋을 자동 생성하세요 + {t('title')} + {t('subtitle')}
- + setEmail(e.target.value)} required />
- +
setPassword(e.target.value)} required @@ -162,29 +175,28 @@ export default function SignupPage() { onClick={() => setShowPassword(!showPassword)} className="absolute right-3 top-1/2 -translate-y-1/2 text-text-tertiary transition-colors hover:text-text-secondary" tabIndex={-1} - aria-label={showPassword ? '비밀번호 숨기기' : '비밀번호 표시'} + aria-label={showPassword ? t('hidePassword') : t('showPassword')} > {showPassword ? : }
- {/* Password strength indicator */} {password.length > 0 && (
{passwordChecks.length ? ( - 6+ characters + {t('passwordChecks.length')} ) : ( - 6+ characters + {t('passwordChecks.length')} )}
)}
- +
setConfirmPassword(e.target.value)} required @@ -195,18 +207,17 @@ export default function SignupPage() { onClick={() => setShowConfirmPassword(!showConfirmPassword)} className="absolute right-3 top-1/2 -translate-y-1/2 text-text-tertiary transition-colors hover:text-text-secondary" tabIndex={-1} - aria-label={showConfirmPassword ? '비밀번호 숨기기' : '비밀번호 표시'} + aria-label={showConfirmPassword ? t('hidePassword') : t('showPassword')} > {showConfirmPassword ? : }
- {/* Password match indicator */} {confirmPassword.length > 0 && (
{passwordChecks.match ? ( - Passwords match + {t('passwordChecks.match')} ) : ( - Passwords don't match + {t('passwordChecks.mismatch')} )}
)} @@ -215,7 +226,7 @@ export default function SignupPage() {

{error}

)} @@ -224,7 +235,7 @@ export default function SignupPage() {
- or + {tCommon('or')}
@@ -235,13 +246,7 @@ export default function SignupPage() { isLoading={oauthLoading === 'google'} disabled={!!oauthLoading || isLoading} > - - Google + {tAuth('oauth.google')}

- Already have an account?{' '} - - Log in + {t('hasAccount')}? + + {t('loginLink')}

diff --git a/app/[locale]/(dashboard)/dashboard/page.tsx b/app/[locale]/(dashboard)/dashboard/page.tsx index 9f77dcd..96dbf8f 100644 --- a/app/[locale]/(dashboard)/dashboard/page.tsx +++ b/app/[locale]/(dashboard)/dashboard/page.tsx @@ -6,24 +6,36 @@ import { Badge } from '@/components/ui/badge' import { PLAN_LIMITS, type Plan, type ProjectStatus } from '@/types/database' import { FolderOpen, Sparkles, Palette, Plus, ArrowRight } from 'lucide-react' import { UsageWarningAuto } from '@/components/shared/usage-warning' +import { getTranslations } from 'next-intl/server' +import { redirect } from 'next/navigation' -const STATUS_CONFIG: Record = { - draft: { variant: 'default', label: '초안' }, - generating: { variant: 'warning', label: '생성 중' }, - completed: { variant: 'success', label: '완료' }, - failed: { variant: 'error', label: '실패' }, +const STATUS_VARIANTS: Record = { + draft: { variant: 'default' }, + generating: { variant: 'warning' }, + completed: { variant: 'success' }, + failed: { variant: 'error' }, } -export default async function DashboardPage() { +export default async function DashboardPage({ + params, +}: { + params: Promise<{ locale: string }> +}) { + const { locale } = await params const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() + const { + data: { user }, + } = await supabase.auth.getUser() + if (!user) redirect('/login') + const t = await getTranslations({ locale, namespace: 'dashboard' }) + const tProjects = await getTranslations({ locale, namespace: 'projects' }) const [{ data: userData }, { data: recentProjects }, { count: profileCount }] = await Promise.all([ - supabase.from('users').select('*').eq('id', user!.id).single(), - supabase.from('projects').select('*').eq('user_id', user!.id) + supabase.from('users').select('*').eq('id', user.id).single(), + supabase.from('projects').select('*').eq('user_id', user.id) .order('created_at', { ascending: false }).limit(5), supabase.from('brand_profiles').select('id', { count: 'exact' }) - .eq('user_id', user!.id), + .eq('user_id', user.id), ]) const plan: Plan = (userData?.plan as Plan) ?? 'free' @@ -31,19 +43,19 @@ export default async function DashboardPage() { const stats = [ { - label: 'Projects this month', + label: t('stats.projectsThisMonth'), value: userData?.projects_used_this_month ?? 0, limit: limits.projects_per_month, icon: FolderOpen, }, { - label: 'AI Headlines', + label: t('stats.aiHeadlines'), value: userData?.ai_headlines_used_this_month ?? 0, limit: limits.ai_headlines_per_month, icon: Sparkles, }, { - label: 'Brand Profiles', + label: t('stats.brandProfiles'), value: profileCount ?? 0, limit: limits.brand_profiles, icon: Palette, @@ -64,9 +76,11 @@ export default async function DashboardPage() {

- Dashboard + {t('title')}

-

Welcome back, {userData?.email}

+

+ {t('welcome', { email: userData?.email })} +

{plan.toUpperCase()} @@ -121,21 +135,21 @@ export default async function DashboardPage() { - +
{/* Recent Projects */} - {recentProjects && recentProjects.length > 0 && ( + {recentProjects && recentProjects.length > 0 ? ( - Recent Projects + {t('recentProjects.title')} - View all + {t('actions.viewAll')} @@ -156,8 +170,8 @@ export default async function DashboardPage() {
- - {STATUS_CONFIG[project.status as ProjectStatus]?.label || project.status} + + {tProjects(`status.${project.status as ProjectStatus}` as const)}
@@ -166,6 +180,24 @@ export default async function DashboardPage() {
+ ) : ( + + + {t('empty.title')} +

{t('empty.description')}

+
+ +

{t('recentProjects.onboardingDescription')}

+
    +
  1. 1. {t('recentProjects.step1')}
  2. +
  3. 2. {t('recentProjects.step2')}
  4. +
  5. 3. {t('recentProjects.step3')}
  6. +
+ + + +
+
)}
) diff --git a/app/[locale]/(dashboard)/projects/[id]/project-detail-client.tsx b/app/[locale]/(dashboard)/projects/[id]/project-detail-client.tsx index 0c3a269..11eaaaa 100644 --- a/app/[locale]/(dashboard)/projects/[id]/project-detail-client.tsx +++ b/app/[locale]/(dashboard)/projects/[id]/project-detail-client.tsx @@ -2,6 +2,7 @@ import { useState } from 'react' import { Download, RefreshCw, Loader2, Copy } from 'lucide-react' +import { useTranslations } from 'next-intl' import { toast } from 'sonner' import { useRouter } from '@/i18n/navigation' import { Button } from '@/components/ui/button' @@ -13,6 +14,7 @@ import { WebPreview } from '@/components/preview/web-preview' import { MobilePreview } from '@/components/preview/mobile-preview' import { CodePreview } from '@/components/preview/code-preview' import { useAssetGeneration } from '@/hooks/use-asset-generation' +import { AnalyticsEvents, trackEvent } from '@/lib/analytics/events' import { duplicateProject } from '../actions' import type { Project, ProjectStatus } from '@/types/database' @@ -21,38 +23,34 @@ interface ProjectDetailClientProps { primaryColor: string } -const STATUS_BADGE: Record = { - draft: { variant: 'default', label: '초안' }, - generating: { variant: 'warning', label: '생성 중' }, - completed: { variant: 'success', label: '완료' }, - failed: { variant: 'error', label: '실패' }, +const STATUS_BADGE: Record = { + draft: { variant: 'default' }, + generating: { variant: 'warning' }, + completed: { variant: 'success' }, + failed: { variant: 'error' }, } export function ProjectDetailClient({ project, primaryColor }: ProjectDetailClientProps) { const router = useRouter() + const t = useTranslations('projects') + const tPlatform = useTranslations('projects.platform') const [activeTab, setActiveTab] = useState('web') - const [isRegenerating, setIsRegenerating] = useState(false) const [isDuplicating, setIsDuplicating] = useState(false) - const { status: genStatus, progress } = useAssetGeneration({ - projectId: isRegenerating ? project.id : null, - enabled: isRegenerating, + const { status: genStatus, progress, startGeneration } = useAssetGeneration({ + projectId: project.id, + enabled: false, }) const showMobile = project.platform === 'mobile' || project.platform === 'all' const showWeb = project.platform === 'web' || project.platform === 'all' - const isGenerating = isRegenerating && (genStatus === 'generating' || genStatus === 'idle') - const currentStatus = isRegenerating ? genStatus : project.status + const isGenerating = genStatus === 'generating' + const currentStatus = genStatus === 'idle' ? project.status : genStatus const badge = STATUS_BADGE[currentStatus as ProjectStatus] || STATUS_BADGE.draft async function handleRegenerate() { - setIsRegenerating(true) - - await fetch(`/api/assets/generate`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ projectId: project.id }), - }) + await startGeneration() + router.refresh() } async function handleDuplicate() { @@ -60,19 +58,20 @@ export function ProjectDetailClient({ project, primaryColor }: ProjectDetailClie try { const result = await duplicateProject(project.id) if (result.success && result.newProjectId) { - toast.success('프로젝트가 복제되었습니다') + toast.success(t('detail.copied', { defaultValue: '복제되었습니다' })) router.push(`/projects/${result.newProjectId}`) } else { - toast.error(result.error || '복제에 실패했습니다') + toast.error(result.error || t('detail.duplicateFailed')) } } catch { - toast.error('복제에 실패했습니다') + toast.error(t('detail.duplicateFailed')) } finally { setIsDuplicating(false) } } async function handleDownload() { + trackEvent(AnalyticsEvents.ASSET_DOWNLOAD, { project_id: project.id, format: 'zip' }) const link = document.createElement('a') link.href = `/api/assets/download/${project.id}` link.download = `${project.name}-assets.zip` @@ -95,8 +94,8 @@ export function ProjectDetailClient({ project, primaryColor }: ProjectDetailClie {/* Breadcrumb Navigation */} @@ -104,12 +103,12 @@ export function ProjectDetailClient({ project, primaryColor }: ProjectDetailClie

{project.name}

- {badge.label} + {t(`status.${project.status}` as const)}

- {project.platform === 'web' ? 'Web' : project.platform === 'mobile' ? 'Mobile' : 'Web + Mobile'} + {project.platform === 'web' ? tPlatform('web') : project.platform === 'mobile' ? tPlatform('mobile') : tPlatform('all')} {' · '} - {new Date(project.created_at).toLocaleDateString('ko-KR')} + {new Date(project.created_at).toLocaleDateString()}

@@ -124,7 +123,7 @@ export function ProjectDetailClient({ project, primaryColor }: ProjectDetailClie ) : ( )} - 복제 + {t('detail.copy')} {(project.status === 'completed' || genStatus === 'completed') && ( )}
@@ -153,7 +152,7 @@ export function ProjectDetailClient({ project, primaryColor }: ProjectDetailClie
-

에셋 재생성 중...

+

{t('status.generating')}

- - {showWeb && Web} - {showMobile && Mobile} - Code + + {showWeb && {tPlatform('web')}} + {showMobile && {tPlatform('mobile')}} + {t('detail.code')} {showWeb && ( @@ -200,13 +199,13 @@ export function ProjectDetailClient({ project, primaryColor }: ProjectDetailClie )} - {!isGenerating && project.status === 'failed' && !isRegenerating && ( + {!isGenerating && currentStatus === 'failed' && project.status !== 'completed' && ( -

에셋 생성에 실패했습니다.

+

{t('detail.retry')}

diff --git a/app/[locale]/(dashboard)/projects/new/page.tsx b/app/[locale]/(dashboard)/projects/new/page.tsx index 96b984c..2827163 100644 --- a/app/[locale]/(dashboard)/projects/new/page.tsx +++ b/app/[locale]/(dashboard)/projects/new/page.tsx @@ -2,10 +2,19 @@ import { createClient } from '@/lib/supabase/server' import { redirect } from 'next/navigation' import { WizardShell } from '@/components/wizard/wizard-shell' import type { User } from '@/types/database' +import { getTranslations } from 'next-intl/server' -export default async function NewProjectPage() { +export default async function NewProjectPage({ + params, +}: { + params: Promise<{ locale: string }> +}) { + const { locale } = await params const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() + const { + data: { user }, + } = await supabase.auth.getUser() + const t = await getTranslations({ locale, namespace: 'projects' }) if (!user) redirect('/login') @@ -20,8 +29,8 @@ export default async function NewProjectPage() { return (
-

New Project

-

Create brand assets in a few steps.

+

{t('newProject')}

+

{t('newDescription')}

}) { +const STATUSES: ProjectStatus[] = ['draft', 'generating', 'completed', 'failed'] + +export default async function ProjectsPage({ + params, + searchParams, +}: { + params: Promise<{ locale: string }> + searchParams: + | Promise<{ + q?: string | string[] + status?: string | string[] + }> + | { + q?: string | string[] + status?: string | string[] + } +}) { const { locale } = await params + const resolvedSearchParams = await searchParams + const { q: rawQuery = '', status: rawStatus = 'all' } = resolvedSearchParams + const query = typeof rawQuery === 'string' ? rawQuery.trim() : '' + const selectedStatus = typeof rawStatus === 'string' + ? rawStatus + : Array.isArray(rawStatus) + ? rawStatus[0] ?? 'all' + : 'all' + const supabase = await createClient() const { data: { user } } = await supabase.auth.getUser() const t = await getTranslations({ locale, namespace: 'projects' }) + if (!user) redirect('/login') - const { data: projects } = await supabase + let projectQuery = supabase .from('projects') .select('*, brand_profiles(primary_color)') - .eq('user_id', user!.id) + .eq('user_id', user.id) .order('created_at', { ascending: false }) + if (query) { + projectQuery = projectQuery.ilike('name', `%${query}%`) + } + + if (selectedStatus !== 'all' && STATUSES.includes(selectedStatus as ProjectStatus)) { + projectQuery = projectQuery.eq('status', selectedStatus) + } + + const { data: projects } = await projectQuery + + const isStatusFiltered = selectedStatus !== 'all' && STATUSES.includes(selectedStatus as ProjectStatus) + const hasActiveFilter = Boolean(query) || isStatusFiltered + const dateLocale = locale === 'ko' ? 'ko-KR' : 'en-US' + const statusKeyMap: Record = { + all: 'statusAll', + draft: 'draft', + generating: 'generating', + completed: 'completed', + failed: 'failed', + } return (
@@ -47,6 +95,40 @@ export default async function ProjectsPage({ params }: { params: Promise<{ local
+
+ + +
+ + + + +
+
+ {!projects || projects.length === 0 ? ( @@ -62,7 +144,7 @@ export default async function ProjectsPage({ params }: { params: Promise<{ local {t('empty.title')}

- {t('empty.description')} + {hasActiveFilter ? t('filters.noResults') : t('empty.description')}

)}
@@ -80,7 +84,7 @@ export function BillingClient({ user }: BillingClientProps) {
-

플랜 비교

+

{tBilling('planComparison')}

(null) + const [success, setSuccess] = useState(null) + + async function handleSubmit(event: FormEvent) { + event.preventDefault() + if (isSaving) return + + const nextEmail = email.trim() + setError(null) + setSuccess(null) + + if (!EMAIL_REGEX.test(nextEmail)) { + setError(t('errors.invalidEmail')) + return + } + + if (nextEmail.toLowerCase() === currentEmail.toLowerCase()) { + setError(t('errors.sameAsCurrent')) + return + } + + setIsSaving(true) + try { + const supabase = createClient() + const { error: updateError } = await supabase.auth.updateUser({ email: nextEmail }) + + if (updateError) { + setError(updateError.message || t('errors.unknown')) + return + } + + setEmail('') + setSuccess(t('success', { email: nextEmail })) + } catch { + setError(t('errors.unknown')) + } finally { + setIsSaving(false) + } + } + + return ( +
+
+ + setEmail(event.target.value)} + placeholder={t('newEmailPlaceholder')} + autoComplete="email" + required + /> +
+ + {error && ( +

+ {error} +

+ )} + {success && ( +

+ {success} +

+ )} + + +
+ ) +} diff --git a/app/[locale]/(dashboard)/settings/page.tsx b/app/[locale]/(dashboard)/settings/page.tsx index 2034694..38dbc2a 100644 --- a/app/[locale]/(dashboard)/settings/page.tsx +++ b/app/[locale]/(dashboard)/settings/page.tsx @@ -1,29 +1,51 @@ import { Link } from '@/i18n/navigation' import { createClient } from '@/lib/supabase/server' +import { redirect } from 'next/navigation' +import { getTranslations } from 'next-intl/server' import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card' import { Badge } from '@/components/ui/badge' -import { Button } from '@/components/ui/button' +import { Button, buttonVariants } from '@/components/ui/button' +import { cn } from '@/lib/utils/cn' import { PLAN_LIMITS, type Plan } from '@/types/database' -import { User, CreditCard, Shield, ArrowRight } from 'lucide-react' +import { PasswordChangeForm } from './password-change-form' +import { EmailChangeForm } from './email-change-form' +import { User, CreditCard, Shield, ArrowRight, AlertTriangle } from 'lucide-react' -export default async function SettingsPage() { +export default async function SettingsPage({ + params, +}: { + params: Promise<{ locale: string }> +}) { + const { locale } = await params const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() - const { data: userData } = await supabase.from('users').select('*').eq('id', user!.id).single() + const { + data: { user }, + } = await supabase.auth.getUser() + + if (!user) { + redirect('/login') + } + + const { data: userData } = await supabase.from('users').select('*').eq('id', user.id).single() + + const t = await getTranslations({ locale, namespace: 'settings' }) + const tCommon = await getTranslations({ locale, namespace: 'common' }) const plan: Plan = (userData?.plan as Plan) ?? 'free' const limits = PLAN_LIMITS[plan] + const currentEmail = userData?.email ?? user.email ?? '' + + const deleteAccountMailto = `mailto:support@brandkit.app?subject=${encodeURIComponent('Account deletion request')}&body=${encodeURIComponent(`Please delete my account.\n\nUser ID: ${user.id}\nEmail: ${userData?.email ?? ''}`)}` return (

- Settings + {t('title')}

-

Manage your account and subscription

+

{t('subtitle')}

- {/* Account */}
@@ -31,8 +53,8 @@ export default async function SettingsPage() {
- Account - Your account information + {t('account.title')} + {t('account.subtitle')}
@@ -40,21 +62,20 @@ export default async function SettingsPage() {
-

Email

-

{userData?.email}

+

{t('account.email')}

+

{currentEmail}

-

User ID

-

{user?.id}

+

{t('account.userId')}

+

{user.id}

- {/* Subscription */}
@@ -62,25 +83,23 @@ export default async function SettingsPage() {
- Subscription - Your current plan and usage limits + {t('subscription.title')} + {t('subscription.subtitle')}
- - {plan.toUpperCase()} - + {plan.toUpperCase()} - {plan === 'pro' ? 'Unlimited access to all features' : 'Basic features with usage limits'} + {plan === 'pro' ? t('subscription.unlimited') : t('subscription.limited')}
{plan === 'free' && ( - + @@ -89,28 +108,31 @@ export default async function SettingsPage() {
-

Projects / Month

+

+ {t('subscription.projectsPerMonth')} +

- {limits.projects_per_month === Infinity ? 'Unlimited' : limits.projects_per_month} + {limits.projects_per_month === Infinity ? tCommon('unlimited') : limits.projects_per_month}

-

AI Headlines

+

+ {t('subscription.aiHeadlines')} +

- {limits.ai_headlines_per_month === Infinity ? 'Unlimited' : `${limits.ai_headlines_per_month}/mo`} + {limits.ai_headlines_per_month === Infinity ? tCommon('unlimited') : `${limits.ai_headlines_per_month}/mo`}

-

Brand Profiles

-

- {limits.brand_profiles} +

+ {t('subscription.brandProfiles')}

+

{limits.brand_profiles}

- {/* Security */}
@@ -118,22 +140,61 @@ export default async function SettingsPage() {
- Security - Authentication and security settings + {t('security.title')} + {t('security.subtitle')}
-
+
-

Sign out

-

Sign out of your account on this device

-
-
- -
+

{t('security.signout')}

+

{t('security.signoutDesc')}

+
+ +
+
+ +
+

{t('security.changeEmail.title')}

+

{t('security.changeEmail.description')}

+
+ +
+
+ +
+

{t('security.changePassword.title')}

+

{t('security.changePassword.description')}

+
+ +
+
+ +
+
+
+ +
+

{t('security.deleteAccount.title')}

+

{t('security.deleteAccount.description')}

+
    +
  1. {t('security.deleteAccount.step1')}
  2. +
  3. {t('security.deleteAccount.step2')}
  4. +
  5. {t('security.deleteAccount.step3')}
  6. +
+ + {t('security.deleteAccount.contactButton')} + +
+
+
+
diff --git a/app/[locale]/(dashboard)/settings/password-change-form.tsx b/app/[locale]/(dashboard)/settings/password-change-form.tsx new file mode 100644 index 0000000..1b88256 --- /dev/null +++ b/app/[locale]/(dashboard)/settings/password-change-form.tsx @@ -0,0 +1,105 @@ +'use client' + +import { useState, type FormEvent } from 'react' +import { useTranslations } from 'next-intl' +import { createClient } from '@/lib/supabase/client' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' + +export function PasswordChangeForm() { + const t = useTranslations('settings.security.changePassword') + const [password, setPassword] = useState('') + const [confirmPassword, setConfirmPassword] = useState('') + const [isSaving, setIsSaving] = useState(false) + const [error, setError] = useState(null) + const [success, setSuccess] = useState(null) + + async function handleSubmit(event: FormEvent) { + event.preventDefault() + if (isSaving) return + + setError(null) + setSuccess(null) + + if (password.length < 6) { + setError(t('errors.tooShort')) + return + } + + if (password !== confirmPassword) { + setError(t('errors.mismatch')) + return + } + + setIsSaving(true) + try { + const supabase = createClient() + const { error: updateError } = await supabase.auth.updateUser({ + password, + }) + + if (updateError) { + setError(updateError.message || t('errors.unknown')) + return + } + + setPassword('') + setConfirmPassword('') + setSuccess(t('success')) + } catch { + setError(t('errors.unknown')) + } finally { + setIsSaving(false) + } + } + + return ( +
+
+ + setPassword(event.target.value)} + placeholder={t('newPasswordPlaceholder')} + minLength={6} + required + /> +
+ +
+ + setConfirmPassword(event.target.value)} + placeholder={t('confirmPasswordPlaceholder')} + minLength={6} + required + /> +
+ + {error && ( +

+ {error} +

+ )} + {success && ( +

+ {success} +

+ )} + + +
+ ) +} diff --git a/app/api/assets/download/[projectId]/route.ts b/app/api/assets/download/[projectId]/route.ts index a537090..4e0983d 100644 --- a/app/api/assets/download/[projectId]/route.ts +++ b/app/api/assets/download/[projectId]/route.ts @@ -6,6 +6,7 @@ import { ValidationError, } from '@/lib/utils/errors' import type { Project } from '@/types/database' +import { createProjectAssetSignedUrl, getProjectAssetStoragePath } from '@/lib/supabase/storage' export async function GET( _request: Request, @@ -37,15 +38,15 @@ export async function GET( throw new NotFoundError('에셋 파일') } - const supabaseHost = process.env.NEXT_PUBLIC_SUPABASE_URL!.replace( - 'https://', - '' - ) - if (!project.assets_zip_url.includes(supabaseHost)) { + let downloadUrl = '' + try { + const storagePath = getProjectAssetStoragePath(project.assets_zip_url) + downloadUrl = await createProjectAssetSignedUrl(storagePath) + } catch { throw new ValidationError('유효하지 않은 저장소 URL입니다.') } - const response = await fetch(project.assets_zip_url) + const response = await fetch(downloadUrl) const buffer = await response.arrayBuffer() return new Response(buffer, { diff --git a/app/api/assets/generate/route.ts b/app/api/assets/generate/route.ts index 61097b5..04d9113 100644 --- a/app/api/assets/generate/route.ts +++ b/app/api/assets/generate/route.ts @@ -70,7 +70,7 @@ export async function POST(request: Request) { await supabase .from('projects') - .update({ status: 'generating' }) + .update({ status: 'generating', pipeline_stage: 'icon_resolve' }) .eq('id', projectId) try { @@ -80,11 +80,18 @@ export async function POST(request: Request) { brandProfile, stylePreset, userId: user.id, + onStage: (stage) => { + supabase + .from('projects') + .update({ pipeline_stage: stage }) + .eq('id', projectId) + .then(() => {}) + }, }) await supabase .from('projects') - .update({ status: 'completed', assets_zip_url: storageUrl }) + .update({ status: 'completed', assets_zip_url: storageUrl, pipeline_stage: null }) .eq('id', projectId) logger.info('asset.generate.completed', { @@ -97,7 +104,7 @@ export async function POST(request: Request) { } catch (pipelineError) { await supabase .from('projects') - .update({ status: 'failed' }) + .update({ status: 'failed', pipeline_stage: null }) .eq('id', projectId) logger.error('asset.generate.pipeline_failed', { diff --git a/app/api/assets/status/[projectId]/route.ts b/app/api/assets/status/[projectId]/route.ts index 930502b..70c695a 100644 --- a/app/api/assets/status/[projectId]/route.ts +++ b/app/api/assets/status/[projectId]/route.ts @@ -1,4 +1,5 @@ import { createClient } from '@/lib/supabase/server' +import { createProjectAssetSignedUrl, getProjectAssetStoragePath } from '@/lib/supabase/storage' import { handleApiError, UnauthorizedError, @@ -23,22 +24,34 @@ export async function GET( const { data: projectData } = await supabase .from('projects') - .select('status, assets_zip_url') + .select('status, assets_zip_url, pipeline_stage') .eq('id', projectId) .eq('user_id', user.id) .single() const project = projectData as Pick< Project, - 'status' | 'assets_zip_url' + 'status' | 'assets_zip_url' | 'pipeline_stage' > | null if (!project) { throw new NotFoundError('프로젝트') } + let downloadUrl: string | null = null + if (project.assets_zip_url && project.status === 'completed') { + try { + const storagePath = getProjectAssetStoragePath(project.assets_zip_url) + downloadUrl = await createProjectAssetSignedUrl(storagePath) + } catch { + downloadUrl = null + } + } + return Response.json({ status: project.status, - url: project.assets_zip_url, + url: downloadUrl, + warnings: [], + pipelineStage: project.pipeline_stage, }) } catch (error) { return handleApiError(error) diff --git a/components/landing/footer.tsx b/components/landing/footer.tsx index 9b8445b..8210760 100644 --- a/components/landing/footer.tsx +++ b/components/landing/footer.tsx @@ -88,10 +88,6 @@ export function Footer() {

© {new Date().getFullYear()} {tc('brandName')}. {t('copyright')}

-
-
diff --git a/components/landing/hero.tsx b/components/landing/hero.tsx index 366ab52..043006f 100644 --- a/components/landing/hero.tsx +++ b/components/landing/hero.tsx @@ -38,6 +38,12 @@ export function Hero() { > {tc('signup')} + + {t('ctaDemo')} +
@@ -75,6 +81,13 @@ export function Hero() { {t('ctaPrimary')} + + {t('ctaDemo')} +
- {/* Trust indicators */}
@@ -99,26 +111,6 @@ export function Hero() { {t('trustFreePlan')}
- - {/* Social Proof Stats */} -
-
-
2,500+
-
{t('stats.users')}
-
-
-
15K+
-
{t('stats.assetsGenerated')}
-
-
-
12+
-
{t('stats.assetTypes')}
-
-
-
<60s
-
{t('stats.generation')}
-
-
{/* Right: Floating asset tiles - decorative */} diff --git a/components/landing/testimonials.tsx b/components/landing/testimonials.tsx index c57988b..78daf3c 100644 --- a/components/landing/testimonials.tsx +++ b/components/landing/testimonials.tsx @@ -9,7 +9,6 @@ const testimonials = [ content: 'testimonials.items.1.content', author: 'testimonials.items.1.author', role: 'testimonials.items.1.role', - avatar: 'J', color: '#6366F1', highlight: false, }, @@ -18,7 +17,6 @@ const testimonials = [ content: 'testimonials.items.2.content', author: 'testimonials.items.2.author', role: 'testimonials.items.2.role', - avatar: 'S', color: '#EC4899', highlight: true, }, @@ -27,7 +25,6 @@ const testimonials = [ content: 'testimonials.items.3.content', author: 'testimonials.items.3.author', role: 'testimonials.items.3.role', - avatar: 'M', color: '#10B981', highlight: false, }, @@ -36,12 +33,17 @@ const testimonials = [ content: 'testimonials.items.4.content', author: 'testimonials.items.4.author', role: 'testimonials.items.4.role', - avatar: 'A', color: '#F97316', highlight: false, }, ] +function getAvatarText(value: string) { + const cleaned = value.replace(/[^A-Za-z0-9가-힣]/g, '') + if (!cleaned) return 'BK' + return cleaned.slice(0, 2).toUpperCase() +} + export function Testimonials() { const t = useTranslations('landing') @@ -68,10 +70,16 @@ export function Testimonials() { {/* Testimonials grid */}
- {testimonials.map((testimonial, idx) => ( -
{ + const author = t(testimonial.author) + const role = t(testimonial.role) + const content = t(testimonial.content) + const avatarText = getAvatarText(author) + + return ( +
- {/* Highlight glow */} - {testimonial.highlight && ( -
- )} + style={{ animationDelay: `${idx * 100}ms` }} + > + {/* Highlight glow */} + {testimonial.highlight && ( +
+ )} - {/* Stars */} -
- {[...Array(5)].map((_, i) => ( - - ))} -
+ {/* Stars */} +
+ {[...Array(5)].map((_, i) => ( + + ))} +
- {/* Content */} -

- - {t(testimonial.content)} - -

+ {/* Content */} +

+ + {content} + +

- {/* Author */} -
-
- {testimonial.avatar} + {/* Author */} +
-
-
-

- {t(testimonial.author)} -

-

- {t(testimonial.role)} -

+ className="relative flex h-11 w-11 items-center justify-center rounded-full text-xs font-semibold text-white shadow-md transition-transform group-hover:scale-105" + style={{ + backgroundImage: `linear-gradient(135deg, ${testimonial.color}, ${testimonial.color}aa)`, + }} + > + + {avatarText} + +
+
+
+

+ {author} +

+

+ {role} +

+
-
- ))} + ) + })}
{/* Trust badges */} diff --git a/components/layout/header.tsx b/components/layout/header.tsx index 9d5252c..2477ee9 100644 --- a/components/layout/header.tsx +++ b/components/layout/header.tsx @@ -4,6 +4,7 @@ import Link from 'next/link' import { Menu } from 'lucide-react' import { Button } from '@/components/ui/button' import { cn } from '@/lib/utils/cn' +import { useTranslations } from 'next-intl' interface HeaderProps { showAuth?: boolean @@ -12,6 +13,8 @@ interface HeaderProps { } export function Header({ showAuth = true, showUserMenu = false, className }: HeaderProps) { + const t = useTranslations('common') + return (
@@ -24,11 +27,18 @@ export function Header({ showAuth = true, showUserMenu = false, className }: Hea