diff --git a/DESIGN.md b/DESIGN.md new file mode 100644 index 0000000..9fd1f73 --- /dev/null +++ b/DESIGN.md @@ -0,0 +1,119 @@ +# BrandKit Design System + +## Overview + +BrandKit uses a warm editorial marketing system inspired by Intercom's cream canvas. The page ground is not pure white: it is a soft cream canvas (`#f5f1ec`) that signals calm, product focus, and launch readiness. White floating cards sit on that canvas with thin warm hairline borders, while charcoal (`#111111`) carries headings, body text, and primary actions. + +The product should feel useful before it feels decorative. Public pages lead with high-fidelity product or asset mockups framed in white cards. Marketing chrome stays quiet so favicon, OG image, app icon, and code-snippet outputs become the protagonist. + +## Color Tokens + +### Brand + +- `colors.ink`: `#111111` — system primary for headlines, body text, and primary CTAs. +- `colors.on-primary`: `#fffdf9` — text on charcoal CTAs. +- `colors.fin-orange`: `#ff5600` — AI/product accent, reserved for AI CTA badges and a few inline emphasis moments. +- `colors.brand-blue`: `#0007cb` — available only inside product mockups or asset previews. + +### Surfaces + +- `colors.canvas`: `#f5f1ec` — default page background. +- `colors.surface-1`: `#ffffff` — floating cards, pricing cards, mockup cards, dialogs. +- `colors.surface-2`: `#ede8df` — secondary panels, nav hover, alternate rows. +- `colors.hairline`: `#d3cec6` — card borders and structural dividers. +- `colors.hairline-soft`: `#e2ddd4` — FAQ/footer subtle dividers. +- `colors.inverse-canvas`: `#000000` — testimonial/quote strips only. + +### Text + +- `colors.ink-muted`: `#626260` — secondary text. +- `colors.ink-subtle`: `#7b7b78` — tertiary text. +- `colors.ink-tertiary`: `#9c9fa5` — disabled/help text. +- `colors.inverse-ink`: `#fffdf9` — text on inverse canvas. +- `colors.inverse-ink-muted`: `#c7c3bc` — secondary text on inverse canvas. + +### Product Mockup Palette + +Report colors are allowed inside mockups only: blue `#0007cb`, green `#11a36a`, pink `#f45b99`, lime `#c7f464`, cyan `#39c5cf`, orange `#ff7a1a`. + +## Typography + +Use one geometric sans family throughout. If Saans is unavailable, use Inter or Geist Sans. Body and display stay in the same family; hierarchy comes from size, weight, line height, and letter spacing. + +- Display XL: 72px, 500, line-height 1.05, letter-spacing -2px. +- Display LG: 56px, 500, line-height 1.10, letter-spacing -1.4px. +- Display MD: 40px, 500, line-height 1.15, letter-spacing -0.8px. +- Headline: 28px, 500, line-height 1.20, letter-spacing -0.5px. +- Card title: 22px, 500, line-height 1.25, letter-spacing -0.3px. +- Body LG: 18px, 400, line-height 1.50. +- Body: 16px, 400, line-height 1.50. +- Caption: 12px, 400, line-height 1.40. +- Button: 15px, 500, line-height 1.20. +- Mono: 13px, 400, line-height 1.50. Use only for code snippets or product UI details. + +Do not use all-caps tracked eyebrows. Section labels use sentence case, 14px, weight 500. + +## Layout + +- Base spacing unit: 8px. +- Maximum content width: 1280px. +- Section rhythm: 96px vertical spacing on desktop. +- Card grids: 3-up desktop, 2-up tablet, 1-up mobile. +- Product mockup cards span wide and maintain aspect ratio without cropping. +- The cream canvas creates separation; avoid colorful section backgrounds. + +## Components + +### Buttons + +- Primary: charcoal background, cream/white text, 8px radius, 10px vertical and 18px horizontal padding. +- Secondary: white background, charcoal text, warm hairline border, 8px radius. +- Tertiary: cream background, charcoal text, no decorative border. +- Orange CTA: `#ff5600`, reserved for AI/product moments only. + +Do not pill-round CTAs. Do not combine charcoal and orange primary CTAs in the same viewport. + +### Cards + +- Standard card: white background, `1px` warm hairline border, 12px radius, no drop shadow. +- Product mockup card: white background, hairline border, 16px radius, 24px padding. +- CTA banner: white background, hairline border, 12px radius, 48px padding. +- Featured pricing card: charcoal background, cream text, 12px radius. + +Depth comes from white-on-cream contrast, not shadows or glows. + +### Forms + +Inputs use white background, charcoal text, warm hairline border, 8px radius, and a charcoal focus ring. Errors use semantic red, not orange. + +### Navigation & Footer + +Navigation and footer sit directly on the cream canvas. Nav height is compact, and links use muted charcoal until hover. The footer is a dense link grid, not a decorative block. + +## Do + +- Keep the cream canvas as the anchor surface. +- Lift important content onto white cards. +- Make product/asset previews the visual payload of each section. +- Use charcoal as the system primary. +- Use orange sparingly for AI/product emphasis. +- Keep card corners modest: 12px or 16px. +- Keep body text at normal tracking. + +## Don't + +- Do not use pure white as the page canvas. +- Do not use purple/blue gradients as the brand surface. +- Do not add atmospheric orbs, glow borders, or heavy drop shadows. +- Do not use Fin Orange as a generic system primary. +- Do not pill-round normal CTAs. +- Do not use mono type in marketing chrome. +- Do not promote report palette colors to page backgrounds. + +## Responsive + +- CTAs hold at least 40px tap height. +- Inputs hold at least 44px tap height. +- Product mockups maintain aspect ratio and do not crop. +- Display type scales from ~72px desktop toward ~40px tablet and ~32px mobile. +- Navigation collapses below 768px while preserving the primary path. diff --git a/app/[locale]/(auth)/auth/callback/route.ts b/app/[locale]/(auth)/auth/callback/route.ts index 3046987..474cd26 100644 --- a/app/[locale]/(auth)/auth/callback/route.ts +++ b/app/[locale]/(auth)/auth/callback/route.ts @@ -1,5 +1,6 @@ import { type NextRequest, NextResponse } from 'next/server' import { createClient } from '@/lib/supabase/server' +import { getSafeRedirectUrl } from '@/lib/utils/url' export async function GET(request: NextRequest) { const { searchParams, origin } = new URL(request.url) @@ -11,16 +12,7 @@ export async function GET(request: NextRequest) { const { error } = await supabase.auth.exchangeCodeForSession(code) if (!error) { - const forwardedHost = request.headers.get('x-forwarded-host') - const isLocalEnv = process.env.NODE_ENV === 'development' - - if (isLocalEnv) { - return NextResponse.redirect(`${origin}${next}`) - } else if (forwardedHost) { - return NextResponse.redirect(`https://${forwardedHost}${next}`) - } else { - return NextResponse.redirect(`${origin}${next}`) - } + return NextResponse.redirect(getSafeRedirectUrl(origin, next)) } } diff --git a/app/[locale]/(auth)/auth/confirm/route.ts b/app/[locale]/(auth)/auth/confirm/route.ts index e87983d..7c804d6 100644 --- a/app/[locale]/(auth)/auth/confirm/route.ts +++ b/app/[locale]/(auth)/auth/confirm/route.ts @@ -2,6 +2,7 @@ import { type NextRequest } from 'next/server' import { redirect } from 'next/navigation' import { createClient } from '@/lib/supabase/server' import { type EmailOtpType } from '@supabase/supabase-js' +import { getSafeRedirectPath } from '@/lib/utils/url' export async function GET(request: NextRequest) { const { searchParams } = new URL(request.url) @@ -14,7 +15,7 @@ export async function GET(request: NextRequest) { const { error } = await supabase.auth.verifyOtp({ type, token_hash }) if (!error) { - redirect(next) + redirect(getSafeRedirectPath(next)) } } diff --git a/app/[locale]/(auth)/forgot-password/forgot-password-client.tsx b/app/[locale]/(auth)/forgot-password/forgot-password-client.tsx index 598b0d9..570574e 100644 --- a/app/[locale]/(auth)/forgot-password/forgot-password-client.tsx +++ b/app/[locale]/(auth)/forgot-password/forgot-password-client.tsx @@ -37,24 +37,21 @@ export default function ForgotPasswordPage() { } return ( -
+
{/* Left: Branding panel */} -
-
-
-
+
{tCommon('brandName')}
-

+

{t('title')}

-

+

{t('description')}

@@ -63,7 +60,7 @@ export default function ForgotPasswordPage() { {/* Right: Form */}
- +

{t('title')}

{t('description')} diff --git a/app/[locale]/(auth)/login/actions.ts b/app/[locale]/(auth)/login/actions.ts index 2b508a4..a88a2ff 100644 --- a/app/[locale]/(auth)/login/actions.ts +++ b/app/[locale]/(auth)/login/actions.ts @@ -5,44 +5,78 @@ import { createClient } from '@/lib/supabase/server' import { headers } from 'next/headers' import { getBaseUrl } from '@/lib/utils/url' +export type AuthActionResult = + | { error?: string; errorCode?: 'AUTH_SERVICE_UNAVAILABLE' } + | undefined + +function isAuthServiceUnavailable(error: unknown): boolean { + const message = error instanceof Error + ? error.message + : typeof error === 'object' && error && 'message' in error + ? String((error as { message?: unknown }).message) + : String(error) + + return /fetch failed|failed to fetch|network|enotfound|eai_again/i.test(message) +} + export async function signIn({ email, password, }: { email: string password: string -}) { - const supabase = await createClient() +}): Promise { + try { + const supabase = await createClient() - const { error } = await supabase.auth.signInWithPassword({ - email, - password, - }) + const { error } = await supabase.auth.signInWithPassword({ + email, + password, + }) - if (error) { - return { error: error.message } + if (error) { + if (isAuthServiceUnavailable(error)) { + return { errorCode: 'AUTH_SERVICE_UNAVAILABLE' } + } + return { error: error.message } + } + } catch (error) { + console.error('Email sign in failed:', error) + return { errorCode: 'AUTH_SERVICE_UNAVAILABLE' } } redirect('/dashboard') } -export async function signInWithOAuth(provider: 'google' | 'github') { - const supabase = await createClient() - const headerStore = await headers() - const origin = headerStore.get('origin') || getBaseUrl() +export async function signInWithOAuth(provider: 'google' | 'github'): Promise { + let redirectUrl: string | null = null + + try { + const supabase = await createClient() + const headerStore = await headers() + const origin = headerStore.get('origin') || getBaseUrl() + + const { data, error } = await supabase.auth.signInWithOAuth({ + provider, + options: { + redirectTo: `${origin}/auth/callback`, + }, + }) - const { data, error } = await supabase.auth.signInWithOAuth({ - provider, - options: { - redirectTo: `${origin}/auth/callback`, - }, - }) + if (error) { + if (isAuthServiceUnavailable(error)) { + return { errorCode: 'AUTH_SERVICE_UNAVAILABLE' } + } + return { error: error.message } + } - if (error) { - return { error: error.message } + redirectUrl = data.url ?? null + } catch (error) { + console.error(`${provider} sign in failed:`, error) + return { errorCode: 'AUTH_SERVICE_UNAVAILABLE' } } - if (data.url) { - redirect(data.url) + if (redirectUrl) { + redirect(redirectUrl) } } diff --git a/app/[locale]/(auth)/login/login-client.tsx b/app/[locale]/(auth)/login/login-client.tsx index 60db618..eebf23d 100644 --- a/app/[locale]/(auth)/login/login-client.tsx +++ b/app/[locale]/(auth)/login/login-client.tsx @@ -18,7 +18,7 @@ import { import { AnalyticsEvents, trackEvent } from '@/lib/analytics/events' import { signIn, signInWithOAuth } from './actions' -export default function LoginPage() { +export default function LoginPage({ routeError }: { routeError?: string | null }) { const t = useTranslations('auth.login') const tAuth = useTranslations('auth') const tCommon = useTranslations('common') @@ -29,6 +29,11 @@ export default function LoginPage() { const [error, setError] = useState(null) const [isLoading, setIsLoading] = useState(false) const [oauthLoading, setOauthLoading] = useState<'google' | 'github' | null>(null) + const routeErrorMessage = routeError === 'config' + ? t('errors.config') + : routeError === 'session' + ? t('errors.session') + : null async function handleSubmit(e: React.FormEvent) { e.preventDefault() @@ -41,6 +46,9 @@ export default function LoginPage() { if (result?.error) { setError(result.error) setIsLoading(false) + } else if (result?.errorCode === 'AUTH_SERVICE_UNAVAILABLE') { + setError(t('errors.serviceUnavailable')) + setIsLoading(false) } else { trackEvent(AnalyticsEvents.LOGIN_COMPLETE, { method: 'email' }) } @@ -55,45 +63,45 @@ export default function LoginPage() { if (result?.error) { setError(result.error) setOauthLoading(null) + } else if (result?.errorCode === 'AUTH_SERVICE_UNAVAILABLE') { + setError(t('errors.serviceUnavailable')) + setOauthLoading(null) } } return ( -
+
{/* Left: Branding panel */} -
-
-
-
+
{tCommon('brandName')}
-

+

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

-

+

{t('branding.description')}

-
12+
-
+
12+
+
{t('branding.stats.assets')}
-
+
<60s
-
+
{t('branding.stats.speed')}
@@ -103,10 +111,10 @@ export default function LoginPage() { {/* Right: Form */}
- +
- + {tCommon('brandName')}
@@ -162,9 +170,9 @@ export default function LoginPage() {
- {error && ( + {(routeErrorMessage || error) && (

- {error} + {error ?? routeErrorMessage}

)}
- + {tCommon('or')}
diff --git a/app/[locale]/(auth)/login/page.tsx b/app/[locale]/(auth)/login/page.tsx index 35b863c..2d68a01 100644 --- a/app/[locale]/(auth)/login/page.tsx +++ b/app/[locale]/(auth)/login/page.tsx @@ -19,6 +19,17 @@ export async function generateMetadata({ }) } -export default function LoginPage() { - return +export default async function LoginPage({ + searchParams, +}: { + searchParams?: Promise<{ error?: string | string[] }> +}) { + const resolvedSearchParams = searchParams ? await searchParams : {} + const errorParam = typeof resolvedSearchParams.error === 'string' + ? resolvedSearchParams.error + : Array.isArray(resolvedSearchParams.error) + ? resolvedSearchParams.error[0] + : null + + return } diff --git a/app/[locale]/(auth)/signup/actions.ts b/app/[locale]/(auth)/signup/actions.ts index 8fdd29e..fbf85ae 100644 --- a/app/[locale]/(auth)/signup/actions.ts +++ b/app/[locale]/(auth)/signup/actions.ts @@ -3,7 +3,21 @@ import { createClient } from '@/lib/supabase/server' import { headers } from 'next/headers' import { redirect } from 'next/navigation' -import { getBaseUrl } from '@/lib/utils/url' +import { getBaseUrl, getSafeRedirectPath } from '@/lib/utils/url' + +export type SignupActionResult = + | { error: string | null; errorCode?: 'AUTH_SERVICE_UNAVAILABLE' } + | undefined + +function isAuthServiceUnavailable(error: unknown): boolean { + const message = error instanceof Error + ? error.message + : typeof error === 'object' && error && 'message' in error + ? String((error as { message?: unknown }).message) + : String(error) + + return /fetch failed|failed to fetch|network|enotfound|eai_again/i.test(message) +} async function getOrigin(): Promise { const headerStore = await headers() @@ -13,44 +27,71 @@ async function getOrigin(): Promise { export async function signUp({ email, password, + next, }: { email: string password: string -}) { - const supabase = await createClient() - const origin = await getOrigin() - - const { error } = await supabase.auth.signUp({ - email, - password, - options: { - emailRedirectTo: `${origin}/auth/confirm`, - }, - }) - - if (error) { - return { error: error.message } + next?: string +}): Promise { + try { + const supabase = await createClient() + const origin = await getOrigin() + const nextPath = getSafeRedirectPath(next) + + const { error } = await supabase.auth.signUp({ + email, + password, + options: { + emailRedirectTo: `${origin}/auth/confirm?next=${encodeURIComponent(nextPath)}`, + }, + }) + + if (error) { + if (isAuthServiceUnavailable(error)) { + return { error: null, errorCode: 'AUTH_SERVICE_UNAVAILABLE' } + } + return { error: error.message } + } + } catch (error) { + console.error('Email sign up failed:', error) + return { error: null, errorCode: 'AUTH_SERVICE_UNAVAILABLE' } } return { error: null } } -export async function signUpWithOAuth(provider: 'google' | 'github') { - const supabase = await createClient() - const origin = await getOrigin() +export async function signUpWithOAuth( + provider: 'google' | 'github', + next?: string +): Promise { + let redirectUrl: string | null = null + + try { + const supabase = await createClient() + const origin = await getOrigin() + const nextPath = getSafeRedirectPath(next) + + const { data, error } = await supabase.auth.signInWithOAuth({ + provider, + options: { + redirectTo: `${origin}/auth/callback?next=${encodeURIComponent(nextPath)}`, + }, + }) - const { data, error } = await supabase.auth.signInWithOAuth({ - provider, - options: { - redirectTo: `${origin}/auth/callback`, - }, - }) + if (error) { + if (isAuthServiceUnavailable(error)) { + return { error: null, errorCode: 'AUTH_SERVICE_UNAVAILABLE' } + } + return { error: error.message } + } - if (error) { - return { error: error.message } + redirectUrl = data.url ?? null + } catch (error) { + console.error(`${provider} sign up failed:`, error) + return { error: null, errorCode: 'AUTH_SERVICE_UNAVAILABLE' } } - if (data.url) { - redirect(data.url) + if (redirectUrl) { + redirect(redirectUrl) } } diff --git a/app/[locale]/(auth)/signup/page.tsx b/app/[locale]/(auth)/signup/page.tsx index 6c72604..d425ef9 100644 --- a/app/[locale]/(auth)/signup/page.tsx +++ b/app/[locale]/(auth)/signup/page.tsx @@ -19,6 +19,31 @@ export async function generateMetadata({ }) } -export default function SignupPage() { - return +export default async function SignupPage({ + searchParams, +}: { + searchParams?: Promise<{ + template?: string | string[] + intent?: string | string[] + interval?: string | string[] + }> +}) { + const resolvedSearchParams = searchParams ? await searchParams : {} + const templateId = typeof resolvedSearchParams.template === 'string' + ? resolvedSearchParams.template + : Array.isArray(resolvedSearchParams.template) + ? resolvedSearchParams.template[0] + : null + const intent = typeof resolvedSearchParams.intent === 'string' + ? resolvedSearchParams.intent + : Array.isArray(resolvedSearchParams.intent) + ? resolvedSearchParams.intent[0] + : null + const interval = typeof resolvedSearchParams.interval === 'string' + ? resolvedSearchParams.interval + : Array.isArray(resolvedSearchParams.interval) + ? resolvedSearchParams.interval[0] + : null + + return } diff --git a/app/[locale]/(auth)/signup/signup-client.tsx b/app/[locale]/(auth)/signup/signup-client.tsx index 50b99a6..7703913 100644 --- a/app/[locale]/(auth)/signup/signup-client.tsx +++ b/app/[locale]/(auth)/signup/signup-client.tsx @@ -18,7 +18,17 @@ import { import { signUp, signUpWithOAuth } from './actions' import { AnalyticsEvents, trackEvent } from '@/lib/analytics/events' -export default function SignupPage() { +interface SignupPageClientProps { + templateId?: string | null + intent?: string | null + interval?: string | null +} + +export default function SignupPage({ + templateId, + intent, + interval: rawInterval, +}: SignupPageClientProps) { const t = useTranslations('auth.signup') const tAuth = useTranslations('auth') const tCommon = useTranslations('common') @@ -32,6 +42,12 @@ export default function SignupPage() { const [isLoading, setIsLoading] = useState(false) const [oauthLoading, setOauthLoading] = useState<'google' | 'github' | null>(null) const [isSuccess, setIsSuccess] = useState(false) + const interval = rawInterval === 'year' ? 'year' : 'month' + const postSignupPath = intent === 'pro' + ? `/settings/billing?intent=pro&interval=${interval}${templateId ? `&template=${encodeURIComponent(templateId)}` : ''}` + : templateId + ? `/projects/new?template=${encodeURIComponent(templateId)}` + : '/dashboard' // Password validation const passwordChecks = { @@ -63,11 +79,14 @@ export default function SignupPage() { setIsLoading(true) trackEvent(AnalyticsEvents.SIGNUP_START, { method: 'email' }) - const result = await signUp({ email, password }) + const result = await signUp({ email, password, next: postSignupPath }) if (result?.error) { setError(result.error) setIsLoading(false) + } else if (result?.errorCode === 'AUTH_SERVICE_UNAVAILABLE') { + setError(t('errors.serviceUnavailable')) + setIsLoading(false) } else { trackEvent(AnalyticsEvents.SIGNUP_COMPLETE, { method: 'email' }) setIsSuccess(true) @@ -81,10 +100,13 @@ export default function SignupPage() { setOauthLoading(provider) trackEvent(AnalyticsEvents.SIGNUP_METHOD_SELECT, { method: provider }) trackEvent(AnalyticsEvents.SIGNUP_START, { method: provider }) - const result = await signUpWithOAuth(provider) + const result = await signUpWithOAuth(provider, postSignupPath) if (result?.error) { setError(result.error) setOauthLoading(null) + } else if (result?.errorCode === 'AUTH_SERVICE_UNAVAILABLE') { + setError(t('errors.serviceUnavailable')) + setOauthLoading(null) } } @@ -110,41 +132,38 @@ export default function SignupPage() { } return ( -
+
{/* Left: Branding panel */} -
-
-
-
+
BrandKit
-

+

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

-

+

{t('branding.description')}

-
12+
-
+
12+
+
{t('branding.stats.start')}
-
+
3/Month
-
+
{t('branding.stats.perMonth')}
@@ -154,10 +173,10 @@ export default function SignupPage() { {/* Right: Form */}
- +
- + BrandKit
@@ -275,7 +294,7 @@ export default function SignupPage() {
- + {tCommon('or')}
@@ -302,7 +321,7 @@ export default function SignupPage() {

- {t('hasAccount')}? + {t('hasAccount')} {t('loginLink')} diff --git a/app/[locale]/(dashboard)/brand-profiles/client.tsx b/app/[locale]/(dashboard)/brand-profiles/client.tsx index b3ea900..253387b 100644 --- a/app/[locale]/(dashboard)/brand-profiles/client.tsx +++ b/app/[locale]/(dashboard)/brand-profiles/client.tsx @@ -146,9 +146,9 @@ export function BrandProfilesClient({ initialProfiles, plan }: BrandProfilesClie {/* Header */} -

+
-
+
@@ -167,7 +167,7 @@ export function BrandProfilesClient({ initialProfiles, plan }: BrandProfilesClie
diff --git a/app/[locale]/(dashboard)/projects/[id]/page.tsx b/app/[locale]/(dashboard)/projects/[id]/page.tsx index c89afee..3288309 100644 --- a/app/[locale]/(dashboard)/projects/[id]/page.tsx +++ b/app/[locale]/(dashboard)/projects/[id]/page.tsx @@ -25,7 +25,7 @@ export default async function ProjectDetailPage({ if (!project) notFound() // Get brand profile for color info - let primaryColor = '#6366f1' + let primaryColor = '#ff5600' if (project.primary_color_override) { primaryColor = project.primary_color_override } else if (project.brand_profile_id) { diff --git a/app/[locale]/(dashboard)/projects/[id]/project-detail-client.tsx b/app/[locale]/(dashboard)/projects/[id]/project-detail-client.tsx index 11eaaaa..20b4f46 100644 --- a/app/[locale]/(dashboard)/projects/[id]/project-detail-client.tsx +++ b/app/[locale]/(dashboard)/projects/[id]/project-detail-client.tsx @@ -14,7 +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 { AnalyticsEvents, trackEvent, trackFirstAssetDownload } from '@/lib/analytics/events' import { duplicateProject } from '../actions' import type { Project, ProjectStatus } from '@/types/database' @@ -57,9 +57,16 @@ export function ProjectDetailClient({ project, primaryColor }: ProjectDetailClie setIsDuplicating(true) try { const result = await duplicateProject(project.id) - if (result.success && result.newProjectId) { + if (result.success) { toast.success(t('detail.copied', { defaultValue: '복제되었습니다' })) router.push(`/projects/${result.newProjectId}`) + } else if (result.code === 'PROJECT_LIMIT_REACHED') { + trackEvent(AnalyticsEvents.PROJECT_LIMIT_REACHED, { + current: result.current ?? 0, + limit: result.limit ?? 0, + source: 'project_duplicate', + }) + toast.error(t('detail.duplicateLimitReached')) } else { toast.error(result.error || t('detail.duplicateFailed')) } @@ -72,6 +79,7 @@ export function ProjectDetailClient({ project, primaryColor }: ProjectDetailClie async function handleDownload() { trackEvent(AnalyticsEvents.ASSET_DOWNLOAD, { project_id: project.id, format: 'zip' }) + trackFirstAssetDownload(project.id, 'project_detail') const link = document.createElement('a') link.href = `/api/assets/download/${project.id}` link.download = `${project.name}-assets.zip` @@ -103,7 +111,7 @@ export function ProjectDetailClient({ project, primaryColor }: ProjectDetailClie

{project.name}

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

{project.platform === 'web' ? tPlatform('web') : project.platform === 'mobile' ? tPlatform('mobile') : tPlatform('all')} diff --git a/app/[locale]/(dashboard)/projects/actions.ts b/app/[locale]/(dashboard)/projects/actions.ts index 7ea7a3e..284a528 100644 --- a/app/[locale]/(dashboard)/projects/actions.ts +++ b/app/[locale]/(dashboard)/projects/actions.ts @@ -1,14 +1,36 @@ 'use server' import { createClient } from '@/lib/supabase/server' +import { checkUsage, incrementUsage } from '@/lib/utils/rate-limit' import type { Project } from '@/types/database' -export async function duplicateProject(projectId: string): Promise<{ success: boolean; newProjectId?: string; error?: string }> { +export type DuplicateProjectResult = + | { success: true; newProjectId: string } + | { + success: false + error: string + code?: 'PROJECT_LIMIT_REACHED' | 'UNAUTHORIZED' | 'NOT_FOUND' + current?: number + limit?: number + } + +export async function duplicateProject(projectId: string): Promise { const supabase = await createClient() const { data: { user } } = await supabase.auth.getUser() if (!user) { - return { success: false, error: 'Unauthorized' } + return { success: false, code: 'UNAUTHORIZED', error: 'Unauthorized' } + } + + const usage = await checkUsage(supabase, user.id, 'projects_used_this_month') + if (!usage.allowed) { + return { + success: false, + code: 'PROJECT_LIMIT_REACHED', + error: 'Monthly project limit reached', + current: usage.current, + limit: usage.limit, + } } // 원본 프로젝트 조회 @@ -20,7 +42,7 @@ export async function duplicateProject(projectId: string): Promise<{ success: bo .single() if (fetchError || !original) { - return { success: false, error: 'Project not found' } + return { success: false, code: 'NOT_FOUND', error: 'Project not found' } } const typedOriginal = original as Project @@ -49,6 +71,8 @@ export async function duplicateProject(projectId: string): Promise<{ success: bo return { success: false, error: 'Failed to duplicate project' } } + await incrementUsage(supabase, user.id, 'projects_used_this_month') + return { success: true, newProjectId: (newProject as { id: string }).id } } diff --git a/app/[locale]/(dashboard)/projects/new/actions.ts b/app/[locale]/(dashboard)/projects/new/actions.ts new file mode 100644 index 0000000..7473313 --- /dev/null +++ b/app/[locale]/(dashboard)/projects/new/actions.ts @@ -0,0 +1,93 @@ +'use server' + +import { createClient } from '@/lib/supabase/server' +import { checkUsage, incrementUsage } from '@/lib/utils/rate-limit' +import type { IconType, MobileTarget, Platform } from '@/types/database' + +export interface CreateWizardProjectInput { + brandProfileId: string | null + stylePresetId: string | null + name: string + description: string + platform: Platform + mobileTarget: MobileTarget | null + primaryColorOverride: string | null + iconType: IconType | null + iconValue: string | null + aiHeadline: string | null + aiTagline: string | null + aiOgDescription: string | null + aiShortSlogan: string | null +} + +export type CreateWizardProjectResult = + | { success: true; projectId: string } + | { + success: false + error: string + code?: 'PROJECT_LIMIT_REACHED' | 'VALIDATION_FAILED' | 'UNAUTHORIZED' + current?: number + limit?: number + } + +export async function createWizardProject( + input: CreateWizardProjectInput +): Promise { + const supabase = await createClient() + const { + data: { user }, + } = await supabase.auth.getUser() + + if (!user) { + return { success: false, code: 'UNAUTHORIZED', error: 'Unauthorized' } + } + + if (!input.name.trim() || !input.stylePresetId || !input.iconType || !input.iconValue) { + return { + success: false, + code: 'VALIDATION_FAILED', + error: 'Missing required project fields', + } + } + + const usage = await checkUsage(supabase, user.id, 'projects_used_this_month') + if (!usage.allowed) { + return { + success: false, + code: 'PROJECT_LIMIT_REACHED', + error: 'Monthly project limit reached', + current: usage.current, + limit: usage.limit, + } + } + + const { data: project, error } = await supabase + .from('projects') + .insert({ + user_id: user.id, + brand_profile_id: input.brandProfileId, + style_preset_id: input.stylePresetId, + name: input.name.trim(), + description: input.description.trim() || null, + platform: input.platform, + mobile_target: input.mobileTarget, + primary_color_override: input.primaryColorOverride, + icon_type: input.iconType, + icon_value: input.iconValue, + ai_headline: input.aiHeadline, + ai_tagline: input.aiTagline, + ai_og_description: input.aiOgDescription, + ai_short_slogan: input.aiShortSlogan, + status: 'draft', + }) + .select('id') + .single() + + if (error || !project) { + return { success: false, error: error?.message || 'Failed to save project' } + } + + await incrementUsage(supabase, user.id, 'projects_used_this_month') + + return { success: true, projectId: project.id } +} diff --git a/app/[locale]/(dashboard)/projects/new/page.tsx b/app/[locale]/(dashboard)/projects/new/page.tsx index 2827163..116ce7b 100644 --- a/app/[locale]/(dashboard)/projects/new/page.tsx +++ b/app/[locale]/(dashboard)/projects/new/page.tsx @@ -1,15 +1,27 @@ import { createClient } from '@/lib/supabase/server' import { redirect } from 'next/navigation' import { WizardShell } from '@/components/wizard/wizard-shell' +import { BRAND_PROFILE_TEMPLATES } from '@/lib/data/brand-profile-templates' import type { User } from '@/types/database' import { getTranslations } from 'next-intl/server' export default async function NewProjectPage({ params, + searchParams, }: { params: Promise<{ locale: string }> + searchParams?: Promise<{ template?: string | string[] }> }) { const { locale } = await params + const resolvedSearchParams = searchParams ? await searchParams : {} + const templateId = typeof resolvedSearchParams.template === 'string' + ? resolvedSearchParams.template + : Array.isArray(resolvedSearchParams.template) + ? resolvedSearchParams.template[0] + : undefined + const initialTemplate = templateId + ? BRAND_PROFILE_TEMPLATES.find((template) => template.id === templateId) ?? null + : null const supabase = await createClient() const { data: { user }, @@ -37,6 +49,7 @@ export default async function NewProjectPage({ brandProfiles={brandProfiles ?? []} stylePresets={stylePresets ?? []} user={userData as User} + initialTemplate={initialTemplate} />

) diff --git a/app/[locale]/(dashboard)/projects/page.tsx b/app/[locale]/(dashboard)/projects/page.tsx index 1ab6743..7ac9264 100644 --- a/app/[locale]/(dashboard)/projects/page.tsx +++ b/app/[locale]/(dashboard)/projects/page.tsx @@ -7,6 +7,8 @@ import { Badge } from '@/components/ui/badge' import { Card, CardContent } from '@/components/ui/card' import { Input } from '@/components/ui/input' import { Plus, ArrowRight, Sparkles } from 'lucide-react' +import { TemplateStartLink } from '@/components/shared/template-start-link' +import { BRAND_PROFILE_TEMPLATES } from '@/lib/data/brand-profile-templates' import type { ProjectStatus } from '@/types/database' const STATUS_VARIANT: Record = { @@ -66,6 +68,7 @@ export default async function ProjectsPage({ const isStatusFiltered = selectedStatus !== 'all' && STATUSES.includes(selectedStatus as ProjectStatus) const hasActiveFilter = Boolean(query) || isStatusFiltered + const starterTemplate = BRAND_PROFILE_TEMPLATES[0] const dateLocale = locale === 'ko' ? 'ko-KR' : 'en-US' const statusKeyMap: Record = { @@ -132,12 +135,8 @@ export default async function ProjectsPage({ {!projects || projects.length === 0 ? ( - {/* Background decoration */} -
-
-
-
+

@@ -146,12 +145,19 @@ export default async function ProjectsPage({

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

- - - +
+ + + + {!hasActiveFilter && ( + + {t('empty.templateCta')} + + )} +

@@ -161,7 +167,7 @@ export default async function ProjectsPage({ const brandColor = (project.brand_profiles as { primary_color?: string } | null)?.primary_color return ( - + {/* Color bar at top */}
(initialInterval) + const successTrackedRef = useRef(false) const tBilling = useTranslations('settings.billing') const plan = user.plan as Plan const currentPlanLabel = plan === 'pro' ? tBilling('planPro') : tBilling('planFree') + useEffect(() => { + if (!checkoutSuccess || successTrackedRef.current) return + + successTrackedRef.current = true + trackEvent(AnalyticsEvents.CHECKOUT_SUCCESS_VIEW, { + plan: 'pro', + interval: billingInterval, + source: 'settings_billing', + }) + }, [billingInterval, checkoutSuccess]) + async function handleUpgrade() { if (isLoading) return setIsLoading(true) trackEvent(AnalyticsEvents.UPGRADE_CLICK, { source: 'settings_billing' }) - trackEvent(AnalyticsEvents.CHECKOUT_START, { plan: 'pro', source: 'settings_billing' }) + trackEvent(AnalyticsEvents.CHECKOUT_START, { + plan: 'pro', + source: 'settings_billing', + interval: billingInterval, + }) try { - const res = await fetch('/api/lemonsqueezy/checkout', { method: 'POST' }) + const res = await fetch('/api/lemonsqueezy/checkout', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ interval: billingInterval }), + }) const data = await res.json() if (data.url) { window.location.href = data.url @@ -84,7 +112,35 @@ export function BillingClient({ user }: BillingClientProps) {
-

{tBilling('planComparison')}

+
+

{tBilling('planComparison')}

+ {plan === 'free' && ( +
+ + +
+ )} +
diff --git a/app/[locale]/(dashboard)/settings/billing/page.tsx b/app/[locale]/(dashboard)/settings/billing/page.tsx index 548b20f..b3f95cf 100644 --- a/app/[locale]/(dashboard)/settings/billing/page.tsx +++ b/app/[locale]/(dashboard)/settings/billing/page.tsx @@ -3,7 +3,24 @@ import { createClient } from '@/lib/supabase/server' import { BillingClient } from './billing-client' import type { User } from '@/types/database' -export default async function BillingPage() { +export default async function BillingPage({ + searchParams, +}: { + searchParams?: Promise<{ success?: string | string[]; interval?: string | string[] }> +}) { + const resolvedSearchParams = searchParams ? await searchParams : {} + const intervalParam = typeof resolvedSearchParams.interval === 'string' + ? resolvedSearchParams.interval + : Array.isArray(resolvedSearchParams.interval) + ? resolvedSearchParams.interval[0] + : undefined + const successParam = typeof resolvedSearchParams.success === 'string' + ? resolvedSearchParams.success + : Array.isArray(resolvedSearchParams.success) + ? resolvedSearchParams.success[0] + : undefined + const initialInterval = intervalParam === 'year' ? 'year' : 'month' + const checkoutSuccess = successParam === 'true' const supabase = await createClient() const { data: { user }, @@ -19,5 +36,11 @@ export default async function BillingPage() { if (!userData) redirect('/login') - return + return ( + + ) } diff --git a/app/[locale]/(dashboard)/settings/page.tsx b/app/[locale]/(dashboard)/settings/page.tsx index 38dbc2a..27a7afc 100644 --- a/app/[locale]/(dashboard)/settings/page.tsx +++ b/app/[locale]/(dashboard)/settings/page.tsx @@ -108,7 +108,7 @@ export default async function SettingsPage({
-

+

{t('subscription.projectsPerMonth')}

@@ -116,7 +116,7 @@ export default async function SettingsPage({

-

+

{t('subscription.aiHeadlines')}

@@ -124,7 +124,7 @@ export default async function SettingsPage({

-

+

{t('subscription.brandProfiles')}

{limits.brand_profiles}

diff --git a/app/[locale]/demo/client.tsx b/app/[locale]/demo/client.tsx index bfc915b..4ae71ab 100644 --- a/app/[locale]/demo/client.tsx +++ b/app/[locale]/demo/client.tsx @@ -1,24 +1,22 @@ 'use client' -import { useState } from 'react' -import { useTranslations } from 'next-intl' +import { useEffect } from 'react' +import { useLocale, useTranslations } from 'next-intl' import { Link } from '@/i18n/navigation' import { - ArrowRight, Download, - Lock, Image, FileCode, Smartphone, Globe, Sparkles, Check, - X, } from 'lucide-react' -import { Button } from '@/components/ui/button' +import { buttonVariants } from '@/components/ui/button' import { Card, CardContent } from '@/components/ui/card' import { Badge } from '@/components/ui/badge' import { BRAND_PROFILE_TEMPLATES } from '@/lib/data/brand-profile-templates' +import { AnalyticsEvents, trackEvent } from '@/lib/analytics/events' const DEMO_BRAND = BRAND_PROFILE_TEMPLATES[0] // Modern SaaS template @@ -28,23 +26,40 @@ const DEMO_ASSETS = [ { name: 'Apple Touch Icon', size: '180×180', icon: Smartphone, format: 'PNG' }, { name: 'Android Icon', size: '512×512', icon: Smartphone, format: 'PNG' }, { name: 'Twitter Card', size: '1200×600', icon: Image, format: 'PNG' }, - { name: 'Meta Tags', size: '-', icon: FileCode, format: 'HTML' }, + { name: 'Meta Tags', size: 'HTML', icon: FileCode, format: 'Code' }, ] export function DemoClient() { const t = useTranslations('demo') - const [showSignupModal, setShowSignupModal] = useState(false) + const locale = useLocale() + + useEffect(() => { + trackEvent(AnalyticsEvents.DEMO_VIEW, { + template_id: DEMO_BRAND.id, + locale, + }) + }, [locale]) const handleDownload = () => { - setShowSignupModal(true) + trackEvent(AnalyticsEvents.ASSET_DOWNLOAD, { + project_id: `demo-${DEMO_BRAND.id}`, + format: 'zip', + }) + } + + const handleTemplateStart = () => { + trackEvent(AnalyticsEvents.DEMO_TEMPLATE_START, { + template_id: DEMO_BRAND.id, + source: 'demo', + }) } return (
{/* Header */} -
+
- + BrandKit
@@ -52,8 +67,12 @@ export function DemoClient() { {t('demoBadge')} - - + + {t('signupCta')}
@@ -63,14 +82,13 @@ export function DemoClient() {
{/* Left: Preview */}
-
+
- - LIVE PREVIEW + Live preview
-

+

{t('title')}

@@ -84,7 +102,7 @@ export function DemoClient() {

{t('sampleBrand.name').charAt(0)} @@ -103,13 +121,13 @@ export function DemoClient() {
{/* Color palette */}
-

+

{t('colorPalette')}

@@ -119,7 +137,7 @@ export function DemoClient() { {DEMO_BRAND.values.secondary_colors.map((color, i) => (
@@ -129,7 +147,7 @@ export function DemoClient() { {/* Keywords */}
-

+

{t('keywords')}

@@ -152,11 +170,11 @@ export function DemoClient() { {/* OG Preview */} -

+

{t('ogPreview')}

-
+
{t('sampleBrand.name')}
-
+
{t('sampleBrand.tagline')}
@@ -193,7 +211,7 @@ export function DemoClient() { style={{ animationDelay: `${idx * 80}ms` }} > -
+
@@ -209,26 +227,38 @@ export function DemoClient() {
{/* Download CTA */} - + - {/* Background glow */} -
-
-
-
+
-

+

{t('downloadTitle')}

{t('downloadDescription')}

- + + + + {t('templateCta')} +

{t('freeNote')}

@@ -238,76 +268,6 @@ export function DemoClient() {
- - {/* Signup Modal */} - {showSignupModal && ( -
{ - if (e.target === e.currentTarget) setShowSignupModal(false) - }} - > - - - {/* Close button */} - - - {/* Header gradient */} -
-
-
- - {/* Content */} -
- {/* Icon overlapping header */} -
-
- -
-
- -

- {t('modal.title')} -

-

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

- -
- - - - - - -
- - -
- - -
- )}
) } diff --git a/app/[locale]/layout.tsx b/app/[locale]/layout.tsx index 10bb6bb..28c1bdd 100644 --- a/app/[locale]/layout.tsx +++ b/app/[locale]/layout.tsx @@ -1,5 +1,4 @@ import type { Metadata, Viewport } from 'next' -import { JetBrains_Mono } from 'next/font/google' import { NextIntlClientProvider } from 'next-intl' import { getMessages, getTranslations, setRequestLocale } from 'next-intl/server' import { hasLocale } from 'next-intl' @@ -12,16 +11,10 @@ import { generateOrganizationSchema, generateWebSiteSchema } from '@/lib/seo/jso import { getSiteUrl } from '@/lib/seo/site-url' import '../globals.css' -const jetbrainsMono = JetBrains_Mono({ - subsets: ['latin'], - variable: '--font-jet', - weight: ['400', '500'], -}) - export const viewport: Viewport = { themeColor: [ - { media: '(prefers-color-scheme: light)', color: '#ffffff' }, - { media: '(prefers-color-scheme: dark)', color: '#0a0a0b' }, + { media: '(prefers-color-scheme: light)', color: '#f5f1ec' }, + { media: '(prefers-color-scheme: dark)', color: '#f5f1ec' }, ], width: 'device-width', initialScale: 1, @@ -53,6 +46,14 @@ export async function generateMetadata({ creator: 'BrandKit', publisher: 'BrandKit', applicationName: 'BrandKit', + manifest: '/manifest.json', + icons: { + icon: [ + { url: '/favicon-32x32.png', sizes: '32x32', type: 'image/png' }, + { url: '/icons/brandkit-icon-192.png', sizes: '192x192', type: 'image/png' }, + ], + apple: [{ url: '/apple-touch-icon.png', sizes: '180x180', type: 'image/png' }], + }, verification: { google: process.env.NEXT_PUBLIC_GOOGLE_SITE_VERIFICATION, other: { @@ -83,17 +84,17 @@ export default async function LocaleLayout({ {/* PWA Manifest */} - + - + {/* Skip Link for Accessibility */} {locale === 'ko' ? '본문으로 건너뛰기' : 'Skip to main content'} - + {children} diff --git a/app/[locale]/not-found.tsx b/app/[locale]/not-found.tsx index ecde2e0..3979a67 100644 --- a/app/[locale]/not-found.tsx +++ b/app/[locale]/not-found.tsx @@ -21,14 +21,14 @@ export default async function NotFound() {
{t('home')} 로그인 diff --git a/app/api/assets/download/[projectId]/route.ts b/app/api/assets/download/[projectId]/route.ts index 4e0983d..c695d53 100644 --- a/app/api/assets/download/[projectId]/route.ts +++ b/app/api/assets/download/[projectId]/route.ts @@ -8,6 +8,18 @@ import { import type { Project } from '@/types/database' import { createProjectAssetSignedUrl, getProjectAssetStoragePath } from '@/lib/supabase/storage' +function createDownloadFilename(projectName: string) { + const safeName = projectName + .replace(/[\r\n]+/g, ' ') + .replace(/[\\/:*?"<>|]+/g, '-') + .replace(/\s+/g, ' ') + .trim() + .replace(/^\.+|\.+$/g, '') + .slice(0, 80) + + return `${safeName || 'brandkit'}-assets.zip` +} + export async function GET( _request: Request, { params }: { params: Promise<{ projectId: string }> } @@ -47,12 +59,17 @@ export async function GET( } const response = await fetch(downloadUrl) + if (!response.ok) { + throw new NotFoundError('에셋 파일') + } + const buffer = await response.arrayBuffer() return new Response(buffer, { headers: { + 'Cache-Control': 'private, no-store', 'Content-Type': 'application/zip', - 'Content-Disposition': `attachment; filename="${project.name}-assets.zip"`, + 'Content-Disposition': `attachment; filename="${createDownloadFilename(project.name)}"`, }, }) } catch (error) { diff --git a/app/api/assets/generate/route.ts b/app/api/assets/generate/route.ts index 0601352..c848394 100644 --- a/app/api/assets/generate/route.ts +++ b/app/api/assets/generate/route.ts @@ -1,5 +1,8 @@ import { createClient } from '@/lib/supabase/server' +import { getSupabaseAdmin } from '@/lib/supabase/admin' import { runAssetPipeline } from '@/lib/assets/pipeline' +import { sendEmail } from '@/lib/email/client' +import { ProjectCompleteEmail } from '@/lib/email/templates' import { logger } from '@/lib/utils/logger' import { handleApiError, @@ -101,7 +104,7 @@ export async function POST(request: Request) { try { const startedAt = Date.now() - const { storageUrl, warnings } = await runAssetPipeline({ + const { storageUrl, results, warnings } = await runAssetPipeline({ project, brandProfile, stylePreset, @@ -126,6 +129,15 @@ export async function POST(request: Request) { durationMs: Date.now() - startedAt, }) + const assetsGenerated = countGeneratedAssets(results) + await notifyProjectCompleted({ + userId: user.id, + userEmail: user.email ?? null, + projectId, + projectName: project.name, + assetsGenerated, + }) + return Response.json({ success: true, url: storageUrl, warnings }) } catch (pipelineError) { await supabase @@ -150,3 +162,60 @@ export async function POST(request: Request) { return handleApiError(error) } } + +function countGeneratedAssets(results: Awaited>['results']) { + return Object.values(results).reduce((total, group) => { + if (!group) return total + return total + Object.keys(group).length + }, 0) +} + +async function notifyProjectCompleted({ + userId, + userEmail, + projectId, + projectName, + assetsGenerated, +}: { + userId: string + userEmail: string | null + projectId: string + projectName: string + assetsGenerated: number +}) { + try { + const supabaseAdmin = getSupabaseAdmin() + const { data: userData } = await supabaseAdmin + .from('users') + .select('email') + .eq('id', userId) + .single() + + await supabaseAdmin.from('notifications').insert({ + user_id: userId, + type: 'project_complete', + title: 'Brand assets ready', + message: `${projectName} is ready to download.`, + data: { projectId, assetsGenerated }, + }) + + const email = userEmail || userData?.email + if (email) { + await sendEmail({ + to: email, + subject: `Your brand assets are ready: ${projectName}`, + react: ProjectCompleteEmail({ + projectName, + assetsGenerated, + locale: 'en', + }), + }) + } + } catch (error) { + logger.warn('asset.generate.notification_failed', { + projectId, + userId, + error: error instanceof Error ? error.message : String(error), + }) + } +} diff --git a/app/api/demo/download/route.ts b/app/api/demo/download/route.ts new file mode 100644 index 0000000..3e6bdfe --- /dev/null +++ b/app/api/demo/download/route.ts @@ -0,0 +1,97 @@ +import { readFile } from 'node:fs/promises' +import path from 'node:path' +import JSZip from 'jszip' + +export const runtime = 'nodejs' + +const PUBLIC_DIR = path.join(process.cwd(), 'public') + +async function addPublicFile(zip: JSZip, outputPath: string, publicPath: string[]) { + const file = await readFile(path.join(PUBLIC_DIR, ...publicPath)) + zip.file(outputPath, file) +} + +export async function GET() { + const zip = new JSZip() + + zip.file( + 'README.md', + `# BrandKit Demo Assets + +This sample pack shows the type of launch-ready files BrandKit prepares from one brand profile. + +Included: +- web/favicon-32x32.png +- mobile/apple-touch-icon.png +- mobile/icon-192.png +- mobile/icon-512.png +- social/og-preview.png +- code/metadata.next.tsx +- code/meta-tags.html + +Create a free BrandKit account to generate a package with your own brand profile, colors, copy, and platform targets. +` + ) + + zip.file( + 'code/metadata.next.tsx', + `import type { Metadata } from 'next' + +export const metadata: Metadata = { + title: 'Acme SaaS', + description: 'Build better software, faster', + icons: { + icon: '/favicon-32x32.png', + apple: '/apple-touch-icon.png', + }, + openGraph: { + title: 'Acme SaaS', + description: 'Build better software, faster', + images: ['/og-preview.png'], + }, + twitter: { + card: 'summary_large_image', + title: 'Acme SaaS', + description: 'Build better software, faster', + images: ['/og-preview.png'], + }, +} +` + ) + + zip.file( + 'code/meta-tags.html', + `Acme SaaS + + + + + + + + +` + ) + + await Promise.all([ + addPublicFile(zip, 'web/favicon-32x32.png', ['favicon-32x32.png']), + addPublicFile(zip, 'mobile/apple-touch-icon.png', ['apple-touch-icon.png']), + addPublicFile(zip, 'mobile/icon-192.png', ['icons', 'brandkit-icon-192.png']), + addPublicFile(zip, 'mobile/icon-512.png', ['icons', 'brandkit-icon-512.png']), + addPublicFile(zip, 'social/og-preview.png', ['images', 'brandkit-og-preview.png']), + ]) + + const body = await zip.generateAsync({ + type: 'arraybuffer', + compression: 'DEFLATE', + compressionOptions: { level: 9 }, + }) + + return new Response(body, { + headers: { + 'Cache-Control': 'public, max-age=3600', + 'Content-Disposition': 'attachment; filename="brandkit-demo-assets.zip"', + 'Content-Type': 'application/zip', + }, + }) +} diff --git a/app/api/feedback/route.ts b/app/api/feedback/route.ts index 43758fd..a05fc54 100644 --- a/app/api/feedback/route.ts +++ b/app/api/feedback/route.ts @@ -5,6 +5,13 @@ import { type NextRequest, NextResponse } from 'next/server' import { createClient } from '@/lib/supabase/server' +import { + checkRateLimit, + getClientIdentifier, + rateLimitExceededResponse, + RATE_LIMITS, + sanitizeUrl, +} from '@/lib/security' interface FeedbackBody { type: 'nps' | 'feedback' @@ -14,25 +21,51 @@ interface FeedbackBody { pageUrl?: string } -export async function POST(request: NextRequest) { - const supabase = await createClient() - const { - data: { user }, - } = await supabase.auth.getUser() +const MAX_NPS_MESSAGE_LENGTH = 1000 +const MAX_FEEDBACK_MESSAGE_LENGTH = 2000 +const ALLOWED_SENTIMENTS = new Set(['positive', 'neutral', 'negative']) +export async function POST(request: NextRequest) { try { - const body: FeedbackBody = await request.json() + const rateLimitResult = checkRateLimit( + `feedback:${getClientIdentifier(request)}`, + RATE_LIMITS.api + ) + if (!rateLimitResult.success) { + return rateLimitExceededResponse(rateLimitResult.resetAt) + } + + const supabase = await createClient() + const { + data: { user }, + } = await supabase.auth.getUser() + + const body = (await request.json().catch(() => null)) as FeedbackBody | null + if (!body) { + return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 }) + } + const userAgent = request.headers.get('user-agent') || undefined if (body.type === 'nps') { - if (body.score === undefined || body.score < 0 || body.score > 10) { + if ( + body.score === undefined || + !Number.isInteger(body.score) || + body.score < 0 || + body.score > 10 + ) { return NextResponse.json({ error: 'Invalid NPS score (0-10 required)' }, { status: 400 }) } + const feedback = normalizeOptionalMessage(body.message, MAX_NPS_MESSAGE_LENGTH) + if (feedback === false) { + return NextResponse.json({ error: 'NPS feedback is too long' }, { status: 400 }) + } + const { error } = await supabase.from('nps_responses').insert({ user_id: user?.id || null, score: body.score, - feedback: body.message || null, + feedback, }) if (error) { @@ -44,7 +77,9 @@ export async function POST(request: NextRequest) { } if (body.type === 'feedback') { - if (!body.sentiment || !body.message?.trim()) { + const message = normalizeRequiredMessage(body.message, MAX_FEEDBACK_MESSAGE_LENGTH) + + if (!isFeedbackSentiment(body.sentiment) || !message) { return NextResponse.json( { error: 'Sentiment and message are required' }, { status: 400 } @@ -54,8 +89,8 @@ export async function POST(request: NextRequest) { const { error } = await supabase.from('feedback').insert({ user_id: user?.id || null, sentiment: body.sentiment, - message: body.message.trim(), - page_url: body.pageUrl || null, + message, + page_url: typeof body.pageUrl === 'string' ? sanitizeUrl(body.pageUrl) : null, user_agent: userAgent, }) @@ -73,3 +108,27 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } } + +function normalizeRequiredMessage(value: unknown, maxLength: number): string | null { + if (typeof value !== 'string') return null + + const trimmed = value.trim() + if (!trimmed || trimmed.length > maxLength) return null + + return trimmed +} + +function normalizeOptionalMessage(value: unknown, maxLength: number): string | null | false { + if (value === undefined || value === null || value === '') return null + if (typeof value !== 'string') return false + + const trimmed = value.trim() + if (!trimmed) return null + if (trimmed.length > maxLength) return false + + return trimmed +} + +function isFeedbackSentiment(value: unknown): value is FeedbackBody['sentiment'] { + return typeof value === 'string' && ALLOWED_SENTIMENTS.has(value) +} diff --git a/app/api/lemonsqueezy/checkout/route.ts b/app/api/lemonsqueezy/checkout/route.ts index 0e46145..aa2ac9e 100644 --- a/app/api/lemonsqueezy/checkout/route.ts +++ b/app/api/lemonsqueezy/checkout/route.ts @@ -68,7 +68,7 @@ export async function POST(request: Request) { logo: true, }, productOptions: { - redirectUrl: `${LEMONSQUEEZY_CONFIG.appUrl}/settings/billing?success=true`, + redirectUrl: `${LEMONSQUEEZY_CONFIG.appUrl}/settings/billing?success=true&interval=${interval}`, }, testMode: process.env.NODE_ENV === 'development', } diff --git a/app/api/notifications/route.ts b/app/api/notifications/route.ts index 4621fd4..26d63f2 100644 --- a/app/api/notifications/route.ts +++ b/app/api/notifications/route.ts @@ -64,7 +64,9 @@ export async function PATCH(request: NextRequest) { if (!notificationId) { return NextResponse.json({ error: 'Missing notificationId' }, { status: 400 }) } - await markAsRead(notificationId) + if (!(await markAsRead(notificationId, user.id))) { + return NextResponse.json({ error: 'Failed to update notification' }, { status: 500 }) + } break case 'mark_all_read': @@ -99,7 +101,7 @@ export async function DELETE(request: NextRequest) { return NextResponse.json({ error: 'Missing notification id' }, { status: 400 }) } - const success = await deleteNotification(notificationId) + const success = await deleteNotification(notificationId, user.id) if (!success) { return NextResponse.json({ error: 'Failed to delete notification' }, { status: 500 }) diff --git a/app/api/og/route.tsx b/app/api/og/route.tsx index af215c8..ad22e55 100644 --- a/app/api/og/route.tsx +++ b/app/api/og/route.tsx @@ -30,8 +30,8 @@ export async function GET(request: Request) { justifyContent: 'center', width: '100%', height: '100%', - backgroundColor: '#09090b', - color: '#fafafa', + backgroundColor: '#f5f1ec', + color: '#111111', fontFamily: 'sans-serif', padding: '80px', }} @@ -48,27 +48,28 @@ export async function GET(request: Request) { style={{ width: '48px', height: '48px', - borderRadius: '12px', - background: 'linear-gradient(135deg, #6366f1, #8b5cf6)', + borderRadius: '8px', + background: '#ff5600', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: '24px', - fontWeight: 'bold', + fontWeight: 500, }} > B
-
+
BrandKit
{title} @@ -77,7 +78,7 @@ export async function GET(request: Request) { style={{ fontSize: '28px', marginTop: '24px', - opacity: 0.7, + color: '#626260', textAlign: 'center', }} > diff --git a/app/globals.css b/app/globals.css index 184edf1..2ab0baf 100644 --- a/app/globals.css +++ b/app/globals.css @@ -2,34 +2,35 @@ @theme { /* -- Brand -- */ - --color-brand: #0a0a0b; - --color-brand-foreground: #fafafa; + --color-brand: #111111; + --color-brand-foreground: #fffdf9; - /* -- Accent (Indigo) -- */ - --color-accent: #6366f1; - --color-accent-light: #818cf8; - --color-accent-dark: #4f46e5; - --color-accent-foreground: #ffffff; + /* -- Accent (AI Product Orange) -- */ + --color-accent: #ff5600; + --color-accent-light: #ff7a1a; + --color-accent-dark: #d94400; + --color-accent-foreground: #fffdf9; /* -- Neutral / Surface -- */ + --color-background: #f5f1ec; --color-surface: #ffffff; - --color-surface-secondary: #f8f8f7; - --color-surface-tertiary: #f0efed; + --color-surface-secondary: #ede8df; + --color-surface-tertiary: #e5dfd6; /* -- Border -- */ - --color-border: #e4e2df; - --color-border-hover: #c8c5c1; + --color-border: #d3cec6; + --color-border-hover: #bdb6ac; /* -- Text -- */ - --color-text-primary: #0c0a09; - --color-text-secondary: #57534e; - --color-text-tertiary: #a8a29e; + --color-text-primary: #111111; + --color-text-secondary: #626260; + --color-text-tertiary: #7b7b78; /* -- Status -- */ - --color-success: #10b981; - --color-warning: #f59e0b; - --color-error: #ef4444; - --color-info: #3b82f6; + --color-success: #11a36a; + --color-warning: #c47a00; + --color-error: #d92d20; + --color-info: #0007cb; /* -- Radius -- */ --radius-sm: 0.375rem; @@ -39,78 +40,76 @@ --radius-2xl: 1.25rem; --radius-full: 9999px; - /* -- Shadows (layered, directional) -- */ - --shadow-sm: 0 1px 2px rgb(0 0 0 / 0.04), 0 1px 1px rgb(0 0 0 / 0.02); - --shadow-md: 0 2px 4px rgb(0 0 0 / 0.04), 0 4px 12px rgb(0 0 0 / 0.06); - --shadow-lg: 0 4px 6px rgb(0 0 0 / 0.04), 0 8px 24px rgb(0 0 0 / 0.08), 0 16px 48px rgb(0 0 0 / 0.04); - --shadow-xl: 0 8px 16px rgb(0 0 0 / 0.06), 0 16px 48px rgb(0 0 0 / 0.12); - --shadow-glow: 0 0 20px rgb(99 102 241 / 0.15), 0 0 60px rgb(99 102 241 / 0.08); + /* -- Shadows (Intercom-like surfaces avoid drop shadows) -- */ + --shadow-sm: none; + --shadow-md: none; + --shadow-lg: none; + --shadow-xl: none; + --shadow-glow: none; - /* -- Font (Pretendard Variable) -- */ - --font-sans: 'Pretendard Variable', Pretendard, -apple-system, BlinkMacSystemFont, system-ui, Roboto, 'Helvetica Neue', 'Segoe UI', 'Apple SD Gothic Neo', 'Noto Sans KR', 'Malgun Gothic', 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', sans-serif; - --font-display: 'Pretendard Variable', Pretendard, -apple-system, BlinkMacSystemFont, system-ui, sans-serif; - --font-mono: var(--font-jet), 'JetBrains Mono', ui-monospace, monospace; -} - -html { - scroll-behavior: smooth; + /* -- Font (Saans substitute stack) -- */ + --font-sans: Inter, 'Pretendard Variable', Pretendard, Geist, -apple-system, BlinkMacSystemFont, system-ui, 'Apple SD Gothic Neo', 'Noto Sans KR', sans-serif; + --font-display: Inter, 'Pretendard Variable', Pretendard, Geist, -apple-system, BlinkMacSystemFont, system-ui, sans-serif; + --font-mono: 'JetBrains Mono', ui-monospace, monospace; } body { font-family: var(--font-sans); color: var(--color-text-primary); - background-color: var(--color-surface); + background-color: var(--color-background); -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; } /* -- Dark Mode -- */ [data-theme="dark"] { - --color-brand: #111114; - --color-brand-foreground: #fafafa; + --color-brand: #111111; + --color-brand-foreground: #fffdf9; - --color-accent: #818cf8; - --color-accent-light: #a5b4fc; - --color-accent-dark: #6366f1; + --color-accent: #ff5600; + --color-accent-light: #ff7a1a; + --color-accent-dark: #d94400; - --color-surface: #0a0a0b; - --color-surface-secondary: #18181b; - --color-surface-tertiary: #27272a; + --color-background: #f5f1ec; + --color-surface: #ffffff; + --color-surface-secondary: #ede8df; + --color-surface-tertiary: #e5dfd6; - --color-border: #3f3f46; - --color-border-hover: #52525b; + --color-border: #d3cec6; + --color-border-hover: #bdb6ac; - --color-text-primary: #fafafa; - --color-text-secondary: #a1a1aa; - --color-text-tertiary: #71717a; + --color-text-primary: #111111; + --color-text-secondary: #626260; + --color-text-tertiary: #7b7b78; - --shadow-sm: 0 1px 2px rgb(0 0 0 / 0.3); - --shadow-md: 0 2px 4px rgb(0 0 0 / 0.3), 0 4px 12px rgb(0 0 0 / 0.4); - --shadow-lg: 0 4px 6px rgb(0 0 0 / 0.3), 0 8px 24px rgb(0 0 0 / 0.4); - --shadow-xl: 0 8px 16px rgb(0 0 0 / 0.4), 0 16px 48px rgb(0 0 0 / 0.5); - --shadow-glow: 0 0 20px rgb(129 140 248 / 0.3), 0 0 60px rgb(129 140 248 / 0.15); + --shadow-sm: none; + --shadow-md: none; + --shadow-lg: none; + --shadow-xl: none; + --shadow-glow: none; } /* 시스템 설정 기반 자동 다크모드 (선택적) */ @media (prefers-color-scheme: dark) { html:not([data-theme="light"]) { - --color-brand: #111114; - --color-brand-foreground: #fafafa; + --color-brand: #111111; + --color-brand-foreground: #fffdf9; - --color-accent: #818cf8; - --color-accent-light: #a5b4fc; - --color-accent-dark: #6366f1; + --color-accent: #ff5600; + --color-accent-light: #ff7a1a; + --color-accent-dark: #d94400; - --color-surface: #0a0a0b; - --color-surface-secondary: #18181b; - --color-surface-tertiary: #27272a; + --color-background: #f5f1ec; + --color-surface: #ffffff; + --color-surface-secondary: #ede8df; + --color-surface-tertiary: #e5dfd6; - --color-border: #3f3f46; - --color-border-hover: #52525b; + --color-border: #d3cec6; + --color-border-hover: #bdb6ac; - --color-text-primary: #fafafa; - --color-text-secondary: #a1a1aa; - --color-text-tertiary: #71717a; + --color-text-primary: #111111; + --color-text-secondary: #626260; + --color-text-tertiary: #7b7b78; } } @@ -205,17 +204,15 @@ body { /* -- Gradient Mesh -- */ .gradient-mesh-dark { - background: - radial-gradient(ellipse 80% 50% at 20% 40%, rgba(99, 102, 241, 0.2), transparent), - radial-gradient(ellipse 60% 60% at 80% 20%, rgba(139, 92, 246, 0.15), transparent), - radial-gradient(ellipse 40% 80% at 50% 90%, rgba(59, 130, 246, 0.1), transparent); + background: none; } /* -- Gradient Text -- */ .text-gradient { - background: linear-gradient(135deg, var(--color-accent) 0%, #c4b5fd 50%, var(--color-accent-light) 100%); + color: var(--color-accent); + background: none; -webkit-background-clip: text; - -webkit-text-fill-color: transparent; + -webkit-text-fill-color: currentColor; background-clip: text; } @@ -229,10 +226,10 @@ body { .glass-light { background: rgba(255, 255, 255, 0.65); - backdrop-filter: blur(12px); - -webkit-backdrop-filter: blur(12px); - border: 1px solid rgba(255, 255, 255, 0.8); - box-shadow: 0 2px 16px rgba(0, 0, 0, 0.04); + backdrop-filter: none; + -webkit-backdrop-filter: none; + border: 1px solid var(--color-border); + box-shadow: none; } /* -- Skeleton Loading -- */ @@ -250,23 +247,21 @@ body { /* -- Hover Lift Utility -- */ .hover-lift { - transition: transform 0.2s cubic-bezier(0.16, 1, 0.3, 1), box-shadow 0.2s ease; + transition: transform 0.2s cubic-bezier(0.16, 1, 0.3, 1), border-color 0.2s ease; } .hover-lift:hover { - transform: translateY(-2px); - box-shadow: var(--shadow-md); + transform: translateY(-1px); + box-shadow: none; } /* -- Gradient Mesh (Light) -- */ .gradient-mesh-light { - background: - radial-gradient(ellipse 60% 40% at 20% 60%, rgba(99, 102, 241, 0.06), transparent), - radial-gradient(ellipse 50% 50% at 80% 30%, rgba(139, 92, 246, 0.04), transparent); + background: none; } /* -- Glow Ring -- */ .glow-ring { - box-shadow: 0 0 0 1px rgba(99, 102, 241, 0.3), var(--shadow-glow); + box-shadow: none; } /* -- Slide Drawer -- */ @@ -290,7 +285,7 @@ body { /* -- Selection -- */ ::selection { - background-color: rgba(99, 102, 241, 0.2); + background-color: rgba(255, 86, 0, 0.18); color: inherit; } @@ -318,29 +313,21 @@ body { transition: all 0.2s cubic-bezier(0.16, 1, 0.3, 1); } .btn-glow::before { - content: ''; - position: absolute; - inset: -1px; - border-radius: inherit; - background: linear-gradient(135deg, var(--color-accent-light), var(--color-accent-dark)); - opacity: 0; - z-index: -1; - filter: blur(8px); - transition: opacity 0.2s ease; + display: none; + content: none; } .btn-glow:hover::before { - opacity: 0.4; + opacity: 0; } /* -- Card Hover Enhancement -- */ .card-interactive { transition: transform 0.2s cubic-bezier(0.16, 1, 0.3, 1), - box-shadow 0.2s cubic-bezier(0.16, 1, 0.3, 1), border-color 0.2s ease; } .card-interactive:hover { - transform: translateY(-2px); - box-shadow: var(--shadow-lg); + transform: translateY(-1px); + box-shadow: none; } /* -- Subtle Grain Overlay -- */ @@ -389,10 +376,10 @@ body { /* -- Pulse Glow Animation -- */ @keyframes pulse-glow { 0%, 100% { - box-shadow: 0 0 0 0 rgba(99, 102, 241, 0.4); + opacity: 1; } 50% { - box-shadow: 0 0 0 8px rgba(99, 102, 241, 0); + opacity: 0.72; } } @@ -544,21 +531,14 @@ body { /* -- OKLCH Gradient Enhancement -- */ .gradient-oklch { - background: linear-gradient( - 135deg, - oklch(0.65 0.25 280) 0%, - oklch(0.55 0.22 300) 50%, - oklch(0.70 0.20 250) 100% - ); + background: var(--color-accent); } /* -- Enhanced Focus Ring with Glow -- */ .focus-ring-glow:focus-visible { - outline: none; - box-shadow: - 0 0 0 2px var(--color-surface), - 0 0 0 4px var(--color-accent), - 0 0 12px rgba(99, 102, 241, 0.3); + outline: 2px solid var(--color-accent); + outline-offset: 2px; + box-shadow: none; } /* -- Hover Scale Micro-interaction -- */ @@ -603,7 +583,7 @@ body { inset: 0; border-radius: inherit; padding: 1px; - background: linear-gradient(135deg, var(--color-accent-light), var(--color-accent-dark)); + background: var(--color-border); -webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0); -webkit-mask-composite: xor; mask-composite: exclude; @@ -621,44 +601,32 @@ body { } .text-shine { - background: linear-gradient( - 90deg, - var(--color-text-primary) 0%, - var(--color-accent) 50%, - var(--color-text-primary) 100% - ); + color: var(--color-text-primary); + background: none; background-size: 200% auto; -webkit-background-clip: text; - -webkit-text-fill-color: transparent; + -webkit-text-fill-color: currentColor; background-clip: text; - animation: text-shine 3s linear infinite; + animation: none; } /* -- Badge Pill Enhancement -- */ .badge-glow { - box-shadow: 0 0 8px currentColor; + box-shadow: none; } /* -- Floating Action Button Shadow -- */ .fab-shadow { - box-shadow: - 0 2px 8px rgba(0, 0, 0, 0.12), - 0 8px 24px rgba(0, 0, 0, 0.08), - 0 0 0 1px rgba(0, 0, 0, 0.04); + box-shadow: none; } .fab-shadow:hover { - box-shadow: - 0 4px 12px rgba(0, 0, 0, 0.16), - 0 12px 32px rgba(0, 0, 0, 0.12), - 0 0 0 1px rgba(0, 0, 0, 0.06); + box-shadow: none; } /* -- Input Glow on Focus -- */ .input-glow:focus { - box-shadow: - 0 0 0 3px rgba(99, 102, 241, 0.15), - inset 0 1px 2px rgba(0, 0, 0, 0.04); + box-shadow: none; } /* -- Striped Loading Background -- */ diff --git a/app/llms.txt/route.ts b/app/llms.txt/route.ts new file mode 100644 index 0000000..9c0e2b1 --- /dev/null +++ b/app/llms.txt/route.ts @@ -0,0 +1,51 @@ +import { getSiteUrl } from '@/lib/seo/site-url' + +export const dynamic = 'force-static' + +export function GET() { + const baseUrl = getSiteUrl() + + return new Response(buildLlmsText(baseUrl), { + headers: { + 'content-type': 'text/plain; charset=utf-8', + 'cache-control': 'public, max-age=3600, s-maxage=86400, stale-while-revalidate=604800', + }, + }) +} + +function buildLlmsText(baseUrl: string): string { + return `# BrandKit + +> BrandKit is an AI brand asset generator for indie makers, developers, and small product teams that need launch-ready favicons, OG images, app icons, social previews, code snippets, and downloadable asset packages from one brand profile. + +## Canonical URLs + +- English homepage: ${baseUrl}/en +- Korean homepage: ${baseUrl}/ko +- English demo: ${baseUrl}/en/demo +- Korean demo: ${baseUrl}/ko/demo +- Sitemap: ${baseUrl}/sitemap.xml +- Robots: ${baseUrl}/robots.txt + +## Product Summary + +- Primary job: prepare launch-ready favicon, OG image, app icon, social preview, and code snippet assets in under 60 seconds. +- Target users: indie makers, developers, solo founders, and small teams shipping web or mobile products. +- Free plan: 3 projects per month, 2 brand profiles, 10 AI headlines per month, and 3 AI icons per month. +- Pro plan: unlimited projects, 5 brand profiles, 50 AI icons per month, all style presets, and priority support. + +## Core Workflows + +- Save a brand profile with colors, style direction, tone, and keywords. +- Start from the Modern SaaS template on the demo page or project empty state. +- Generate brand assets, preview them, download a ZIP, and copy implementation snippets. +- Upgrade when the Free monthly project limit is reached. + +## Indexing Guidance + +- Prefer the localized canonical pages listed above. +- Public indexable pages are homepage, demo, privacy, and terms for each supported locale. +- Do not index authenticated dashboard, project, brand-profile, settings, API, checkout, or account pages. +- Use page metadata, Open Graph images, hreflang alternates, FAQPage, SoftwareApplication, Organization, WebSite, and BreadcrumbList structured data as the authoritative product description. +` +} diff --git a/app/robots.ts b/app/robots.ts index d478292..5eb8ca4 100644 --- a/app/robots.ts +++ b/app/robots.ts @@ -1,7 +1,8 @@ import type { MetadataRoute } from 'next' +import { getSiteUrl } from '@/lib/seo/site-url' export default function robots(): MetadataRoute.Robots { - const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'https://brandkit.app' + const baseUrl = getSiteUrl() return { rules: [ diff --git a/components/analytics/google-analytics.tsx b/components/analytics/google-analytics.tsx index b01a62a..de431bf 100644 --- a/components/analytics/google-analytics.tsx +++ b/components/analytics/google-analytics.tsx @@ -1,12 +1,29 @@ 'use client' +import { useEffect, useState } from 'react' import Script from 'next/script' interface GoogleAnalyticsProps { measurementId?: string + locale?: string } -export function GoogleAnalytics({ measurementId }: GoogleAnalyticsProps) { +const CONSENT_COPY = { + en: { + description: + 'BrandKit uses analytics to understand signup, project creation, and upgrade funnels.', + decline: 'Decline', + allow: 'Allow analytics', + }, + ko: { + description: + 'BrandKit은 회원가입, 프로젝트 생성, 업그레이드 흐름을 개선하기 위해 분석 데이터를 사용합니다.', + decline: '거부', + allow: '분석 허용', + }, +} as const + +export function GoogleAnalytics({ measurementId, locale = 'en' }: GoogleAnalyticsProps) { const gaId = measurementId || process.env.NEXT_PUBLIC_GA_MEASUREMENT_ID if (!gaId || process.env.NODE_ENV !== 'production') { @@ -54,6 +71,69 @@ export function GoogleAnalytics({ measurementId }: GoogleAnalyticsProps) { } catch (e) {} `} + ) } + +function AnalyticsConsentBanner({ locale }: { locale: string }) { + const [isVisible, setIsVisible] = useState(false) + const copy = locale === 'ko' ? CONSENT_COPY.ko : CONSENT_COPY.en + + useEffect(() => { + const timer = window.setTimeout(() => { + try { + setIsVisible(!localStorage.getItem('brandkit_cookie_consent')) + } catch { + setIsVisible(false) + } + }, 0) + + return () => window.clearTimeout(timer) + }, []) + + function updateConsent(status: 'accepted' | 'declined') { + try { + localStorage.setItem( + 'brandkit_cookie_consent', + JSON.stringify({ status, updatedAt: new Date().toISOString() }) + ) + } catch { + // Ignore storage failures; the current page can still receive the consent update. + } + + window.gtag?.('consent', 'update', { + analytics_storage: status === 'accepted' ? 'granted' : 'denied', + ad_storage: 'denied', + }) + setIsVisible(false) + } + + if (!isVisible) return null + + return ( +
+
+

+ {copy.description} +

+
+ + +
+
+
+ ) +} diff --git a/components/billing/plan-card.tsx b/components/billing/plan-card.tsx index 26f653d..e36816c 100644 --- a/components/billing/plan-card.tsx +++ b/components/billing/plan-card.tsx @@ -4,6 +4,7 @@ import { Check } from 'lucide-react' import { Button } from '@/components/ui/button' import { Badge } from '@/components/ui/badge' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { PLAN_PRICING, type BillingInterval } from '@/lib/lemonsqueezy/config' import type { Plan } from '@/types/database' interface PlanCardProps { @@ -11,6 +12,7 @@ interface PlanCardProps { currentPlan: Plan onSubscribe: () => void isLoading?: boolean + billingInterval?: BillingInterval } const PLAN_DETAILS: Record = { @@ -19,8 +21,9 @@ const PLAN_DETAILS: Record @@ -52,8 +67,13 @@ export function PlanCard({ plan, currentPlan, onSubscribe, isLoading }: PlanCard {plan === 'pro' && !isCurrent && 추천}
- {details.price} + {price} /월 + {plan === 'pro' && isYearly && ( +

+ 연 ${PLAN_PRICING.pro.yearly.price} 결제 (${PLAN_PRICING.pro.yearly.savings} 절약) +

+ )}
diff --git a/components/billing/usage-overview.tsx b/components/billing/usage-overview.tsx index 41cc943..feb67a9 100644 --- a/components/billing/usage-overview.tsx +++ b/components/billing/usage-overview.tsx @@ -14,6 +14,17 @@ interface UsageItemProps { locked?: boolean } +function formatResetDate(value: string) { + const date = new Date(value) + if (Number.isNaN(date.getTime())) return null + + return [ + date.getUTCFullYear(), + String(date.getUTCMonth() + 1).padStart(2, '0'), + String(date.getUTCDate()).padStart(2, '0'), + ].join('.') +} + function UsageItem({ label, used, limit, locked }: UsageItemProps) { const isUnlimited = !isFinite(limit) const percentage = isUnlimited ? 0 : limit > 0 ? (used / limit) * 100 : 0 @@ -50,9 +61,7 @@ export function UsageOverview({ user }: UsageOverviewProps) { const plan = user.plan as Plan const limits = PLAN_LIMITS[plan] - const resetDate = user.usage_reset_at - ? new Date(user.usage_reset_at).toLocaleDateString('ko-KR') - : null + const resetDate = user.usage_reset_at ? formatResetDate(user.usage_reset_at) : null return ( @@ -74,7 +83,6 @@ export function UsageOverview({ user }: UsageOverviewProps) { label="AI 아이콘" used={user.ai_icons_used_this_month} limit={limits.ai_icons_per_month} - locked={plan === 'free'} /> {resetDate && (

diff --git a/components/brand-profile/profile-card.tsx b/components/brand-profile/profile-card.tsx index f7a8ae1..3710755 100644 --- a/components/brand-profile/profile-card.tsx +++ b/components/brand-profile/profile-card.tsx @@ -43,7 +43,7 @@ export function ProfileCard({ profile, onEdit, onDelete }: ProfileCardProps) { {menuOpen && ( <>

setMenuOpen(false)} /> -
+
@@ -185,34 +208,28 @@ export function FeedbackWidget({ role="dialog" aria-modal="true" aria-labelledby="feedback-widget-title" - className={`w-80 overflow-hidden rounded-2xl border border-white/10 bg-zinc-900/95 shadow-2xl backdrop-blur-xl transition-all duration-300 ease-[cubic-bezier(0.16,1,0.3,1)] ${ + className={`w-80 overflow-hidden rounded-lg border border-border bg-surface transition-all duration-300 ease-[cubic-bezier(0.16,1,0.3,1)] ${ isAnimating ? 'translate-y-0 scale-100 opacity-100' : 'translate-y-4 scale-95 opacity-0' }`} > - {/* Top gradient line */} -
- - {/* Noise texture */} -
- {/* Header */} -
+

{t.title}

-

+

{t.subtitle}

@@ -223,18 +240,17 @@ export function FeedbackWidget({ {isSubmitted ? (
-
-
+
-

{t.thanks}

-

{t.thanksSubtitle}

+

{t.thanks}

+

{t.thanksSubtitle}

) : ( <> {/* Sentiment selector */} -

+

{t.sentimentLabel}

@@ -254,17 +270,17 @@ export function FeedbackWidget({ }`} >
-
+
{t.low} {t.high}
@@ -267,14 +253,14 @@ export function NPSModal({ {/* Feedback textarea - appears with animation */} {selectedScore !== null && (
-