From 10c36de9f51001c315d3b8f52fd25d331b715ec3 Mon Sep 17 00:00:00 2001 From: Younggun Park Date: Tue, 10 Mar 2026 17:52:35 +0900 Subject: [PATCH 1/2] Fix AI prompt wiring and signup trigger --- app/api/ai/headlines/route.ts | 13 +- app/api/assets/generate/route.ts | 36 +- components/wizard/step-5-preview.tsx | 2 +- components/wizard/wizard-shell.tsx | 1 - hooks/use-asset-generation.ts | 258 +++++---- lib/ai/claude.ts | 94 ++- lib/ai/fal.ts | 187 +++++- lib/prompts/composer.ts | 53 ++ lib/prompts/index.ts | 1 + .../013_fix_handle_new_user_email.sql | 25 + tests/lib/ai/prompt-config.test.ts | 95 +++ types/database.ts | 262 +++------ types/supabase-generated.ts | 542 ++++++++++++++++++ types/wizard.ts | 2 + 14 files changed, 1224 insertions(+), 347 deletions(-) create mode 100644 supabase/migrations/013_fix_handle_new_user_email.sql create mode 100644 tests/lib/ai/prompt-config.test.ts create mode 100644 types/supabase-generated.ts diff --git a/app/api/ai/headlines/route.ts b/app/api/ai/headlines/route.ts index 8a9eb97..e01b026 100644 --- a/app/api/ai/headlines/route.ts +++ b/app/api/ai/headlines/route.ts @@ -1,6 +1,6 @@ import { createClient } from '@/lib/supabase/server' import { getSupabaseAdmin } from '@/lib/supabase/admin' -import { generateHeadlines } from '@/lib/ai/claude' +import { brandProfileToMetadataConfig, generateHeadlines } from '@/lib/ai/claude' import { checkUsage, incrementUsage } from '@/lib/utils/rate-limit' import { handleApiError, @@ -32,12 +32,23 @@ export async function POST(request: Request) { throw new UsageLimitError('AI 헤드라인', usage.limit) } + const promptConfig = body.promptConfig ?? ( + body.brandStyleDirection || body.brandKeywords?.length + ? brandProfileToMetadataConfig({ + platform: body.platform, + styleDirection: body.brandStyleDirection, + keywords: body.brandKeywords, + }) + : undefined + ) + const result = await generateHeadlines({ projectName: body.projectName, description: body.description, platform: body.platform, brandKeywords: body.brandKeywords, brandStyleDirection: body.brandStyleDirection, + promptConfig, }) await incrementUsage(getSupabaseAdmin(), user.id, 'ai_headlines_used_this_month') diff --git a/app/api/assets/generate/route.ts b/app/api/assets/generate/route.ts index 04d9113..0601352 100644 --- a/app/api/assets/generate/route.ts +++ b/app/api/assets/generate/route.ts @@ -46,6 +46,37 @@ export async function POST(request: Request) { const project = projectData as Project + const { data: startedProject, error: startError } = await supabase + .from('projects') + .update({ status: 'generating', pipeline_stage: 'icon_resolve' }) + .eq('id', projectId) + .eq('user_id', user.id) + .neq('status', 'generating') + .select('id') + .maybeSingle() + + if (startError) { + throw new AppError( + startError.message, + 'ASSET_GENERATION_START_FAILED', + 500, + '에셋 생성을 시작하지 못했습니다. 잠시 후 다시 시도해주세요.' + ) + } + + if (!startedProject) { + return Response.json( + { + success: false, + error: { + code: 'ASSET_GENERATION_IN_PROGRESS', + message: '이미 에셋 생성이 진행 중입니다.', + }, + }, + { status: 409 } + ) + } + let brandProfile: BrandProfile | null = null if (project.brand_profile_id) { const { data: brandData } = await supabase @@ -68,11 +99,6 @@ export async function POST(request: Request) { const stylePreset = presetData as StylePreset - await supabase - .from('projects') - .update({ status: 'generating', pipeline_stage: 'icon_resolve' }) - .eq('id', projectId) - try { const startedAt = Date.now() const { storageUrl, warnings } = await runAssetPipeline({ diff --git a/components/wizard/step-5-preview.tsx b/components/wizard/step-5-preview.tsx index c0c3664..052e5eb 100644 --- a/components/wizard/step-5-preview.tsx +++ b/components/wizard/step-5-preview.tsx @@ -168,7 +168,7 @@ export function Step5Preview({ document.body.removeChild(link) } - if (status === 'generating' || status === 'idle') { + if (status === 'generating') { return } diff --git a/components/wizard/wizard-shell.tsx b/components/wizard/wizard-shell.tsx index 042b201..677cb66 100644 --- a/components/wizard/wizard-shell.tsx +++ b/components/wizard/wizard-shell.tsx @@ -106,7 +106,6 @@ export function WizardShell({ brandProfiles, stylePresets, user }: WizardShellPr }) setProjectId(project.id) - store.reset() nextStep() } catch { setSaveError(tErrors('projectSaveUnknownError')) diff --git a/hooks/use-asset-generation.ts b/hooks/use-asset-generation.ts index c230218..99ca904 100644 --- a/hooks/use-asset-generation.ts +++ b/hooks/use-asset-generation.ts @@ -18,6 +18,35 @@ interface AssetGenerationState { stage: PipelineStage | null } +interface AssetStatusResponse { + status?: string + url?: string | null + warnings?: string[] + pipelineStage?: PipelineStage | null +} + +function createIdleState(): AssetGenerationState { + return { + status: 'idle', + url: null, + error: null, + warnings: [], + progress: 0, + stage: null, + } +} + +function createGeneratingState(): AssetGenerationState { + return { + status: 'generating', + url: null, + error: null, + warnings: [], + progress: STAGE_PROGRESS.icon_resolve, + stage: 'icon_resolve', + } +} + const STAGE_PROGRESS: Record = { icon_resolve: 5, favicons: 20, @@ -49,18 +78,15 @@ function isPipelineStage(value: unknown): value is PipelineStage { export function useAssetGeneration({ projectId, enabled }: UseAssetGenerationOptions) { const t = useTranslations('wizard.errors') - const [state, setState] = useState({ - status: 'idle', - url: null, - error: null, - warnings: [], - progress: 0, - stage: null, - }) + const [state, setState] = useState(() => + enabled && projectId ? createGeneratingState() : createIdleState(), + ) const startTimeRef = useRef(0) const intervalRef = useRef | null>(null) const statusErrorCountRef = useRef(0) + const generationActiveRef = useRef(false) + const autoStartedProjectIdRef = useRef(null) const cleanup = useCallback(() => { if (intervalRef.current) { @@ -69,102 +95,100 @@ export function useAssetGeneration({ projectId, enabled }: UseAssetGenerationOpt } }, []) - const startGeneration = useCallback(async () => { - if (!projectId) return - + const failGeneration = useCallback((error: string) => { cleanup() - startTimeRef.current = Date.now() - statusErrorCountRef.current = 0 + generationActiveRef.current = false + setState((prev) => + prev.status === 'generating' + ? { ...prev, status: 'failed', error } + : { ...createIdleState(), status: 'failed', error }, + ) + }, [cleanup]) - setState({ - status: 'generating', - url: null, + const completeGeneration = useCallback((data: AssetStatusResponse) => { + cleanup() + generationActiveRef.current = false + setState((prev) => ({ + status: 'completed', + url: data.url ?? prev.url ?? null, error: null, - warnings: [], - progress: STAGE_PROGRESS.icon_resolve, - stage: 'icon_resolve', - }) - - const pollStatus = async () => { - const elapsed = Date.now() - startTimeRef.current - - if (elapsed > MAX_POLL_DURATION) { - cleanup() - setState((prev) => - prev.status === 'generating' - ? { ...prev, status: 'failed', error: t('generationTimeout') } - : prev, - ) - return - } + warnings: Array.isArray(data.warnings) ? data.warnings : prev.warnings, + progress: 100, + stage: null, + })) + }, [cleanup]) - try { - const res = await fetch(`/api/assets/status/${projectId}`) - if (!res.ok) throw new Error('Status check failed') + const pollStatus = useCallback(async (): Promise => { + const elapsed = Date.now() - startTimeRef.current - statusErrorCountRef.current = 0 - const data = (await res.json()) as { - status?: string - url?: string | null - warnings?: string[] - pipelineStage?: PipelineStage | null - } + if (elapsed > MAX_POLL_DURATION) { + failGeneration(t('generationTimeout')) + return 'failed' + } - if (data.status === 'completed') { - cleanup() - setState((prev) => ({ - status: 'completed', - url: data.url ?? prev.url, - error: null, - warnings: Array.isArray(data.warnings) && data.warnings.length > 0 ? data.warnings : prev.warnings, - progress: 100, - stage: null, - })) - return - } + try { + const res = await fetch(`/api/assets/status/${projectId}`) + if (!res.ok) throw new Error('Status check failed') - if (data.status === 'failed') { - cleanup() - setState((prev) => - prev.status === 'generating' - ? { ...prev, status: 'failed', error: t('generationFailed') } - : prev, - ) - return - } + statusErrorCountRef.current = 0 + const data = (await res.json()) as AssetStatusResponse - const serverStage = isPipelineStage(data.pipelineStage) ? data.pipelineStage : null - const stageBase = serverStage ? STAGE_PROGRESS[serverStage] : STAGE_PROGRESS.icon_resolve - const timeFill = Math.min(6, (elapsed / 90_000) * 6) - const stageProgress = Math.min(98, stageBase + timeFill) - - setState((prev) => { - if (prev.status !== 'generating') return prev - return { - ...prev, - stage: serverStage ?? prev.stage, - progress: Math.max(prev.progress, stageProgress), - } - }) - } catch { - statusErrorCountRef.current += 1 - - if (statusErrorCountRef.current >= MAX_STATUS_ERRORS) { - cleanup() - setState((prev) => - prev.status === 'generating' - ? { ...prev, status: 'failed', error: t('statusCheckFailed') } - : prev, - ) + if (data.status === 'completed') { + completeGeneration(data) + return data.status + } + + if (data.status === 'failed') { + failGeneration(t('generationFailed')) + return data.status + } + + const serverStage = isPipelineStage(data.pipelineStage) ? data.pipelineStage : null + const stageBase = serverStage ? STAGE_PROGRESS[serverStage] : STAGE_PROGRESS.icon_resolve + const timeFill = Math.min(6, (elapsed / 90_000) * 6) + const stageProgress = Math.min(98, stageBase + timeFill) + + setState((prev) => { + if (prev.status !== 'generating') return prev + return { + ...prev, + stage: serverStage ?? prev.stage, + progress: Math.max(prev.progress, stageProgress), } + }) + + return data.status ?? 'idle' + } catch { + statusErrorCountRef.current += 1 + + if (statusErrorCountRef.current >= MAX_STATUS_ERRORS) { + failGeneration(t('statusCheckFailed')) + return 'failed' } + + return null } + }, [completeGeneration, failGeneration, projectId, t]) + + const startGeneration = useCallback(async () => { + if (!projectId || generationActiveRef.current) return + + cleanup() + generationActiveRef.current = true + startTimeRef.current = Date.now() + statusErrorCountRef.current = 0 + + setState(createGeneratingState()) + + const initialStatus = await pollStatus() + if (initialStatus === 'completed' || initialStatus === 'failed') return - await pollStatus() intervalRef.current = setInterval(() => { void pollStatus() }, POLL_INTERVAL_MS) + if (initialStatus === 'generating') return + void (async () => { try { const response = await fetch('/api/assets/generate', { @@ -177,61 +201,43 @@ export function useAssetGeneration({ projectId, enabled }: UseAssetGenerationOpt success?: boolean url?: string | null warnings?: string[] - error?: string + error?: string | { code?: string; message?: string } } if (!response.ok) { - cleanup() - setState((prev) => - prev.status === 'generating' - ? { - ...prev, - status: 'failed', - error: typeof data.error === 'string' ? data.error : t('generationRequestFailed'), - } - : prev, - ) + const errorCode = typeof data.error === 'object' ? data.error?.code : null + const errorMessage = typeof data.error === 'string' + ? data.error + : data.error?.message + + if (response.status === 409 && errorCode === 'ASSET_GENERATION_IN_PROGRESS') { + return + } + + failGeneration(errorMessage || t('generationRequestFailed')) return } if (data.success && data.url) { - cleanup() - setState((prev) => ({ - status: 'completed', - url: data.url ?? prev.url ?? null, - error: null, - warnings: Array.isArray(data.warnings) ? data.warnings : prev.warnings, - progress: 100, - stage: null, - })) + completeGeneration(data) } } catch { - cleanup() - setState((prev) => - prev.status === 'generating' - ? { ...prev, status: 'failed', error: t('generationRequestFailed') } - : prev, - ) + failGeneration(t('generationRequestFailed')) } })() - }, [cleanup, projectId, t]) + }, [cleanup, completeGeneration, failGeneration, pollStatus, projectId, t]) const reset = useCallback(() => { cleanup() - setState({ - status: 'idle', - url: null, - error: null, - warnings: [], - progress: 0, - stage: null, - }) + generationActiveRef.current = false + setState(createIdleState()) }, [cleanup]) useEffect(() => { - if (enabled && projectId) { - void startGeneration() - } + if (!enabled || !projectId || autoStartedProjectIdRef.current === projectId) return + + autoStartedProjectIdRef.current = projectId + void startGeneration() return cleanup }, [cleanup, enabled, projectId, startGeneration]) diff --git a/lib/ai/claude.ts b/lib/ai/claude.ts index 0f4a1e4..d1ec495 100644 --- a/lib/ai/claude.ts +++ b/lib/ai/claude.ts @@ -1,6 +1,6 @@ import Anthropic from '@anthropic-ai/sdk' import type { HeadlineResponse } from '@/types/wizard' -import { composeMetadataPrompt } from '@/lib/prompts' +import { composeMetadataPrompt, getDefaultPromptConfig } from '@/lib/prompts' import type { MetadataPromptConfig } from '@/lib/prompts' import { AI_CONFIG } from '@/lib/config/ai' @@ -45,6 +45,98 @@ interface GenerateHeadlinesParams { promptConfig?: MetadataPromptConfig } +interface MetadataBrandProfileInput { + platform: string + styleDirection?: string + keywords?: string[] + language?: MetadataPromptConfig['language'] +} + +const DEFAULT_METADATA_CONFIG = getDefaultPromptConfig().metadata as MetadataPromptConfig + +function normalizeKeywords(keywords?: string[]) { + return keywords + ?.map((keyword) => keyword.trim()) + .filter(Boolean) ?? [] +} + +function inferMetadataAudience(keywords: string[]): MetadataPromptConfig['audience'] { + const lowerKeywords = keywords.map((keyword) => keyword.toLowerCase()) + + if (lowerKeywords.some((keyword) => + ['developer', 'dev', 'api', 'sdk', 'engineering', 'code'].some((term) => keyword.includes(term)) + )) { + return 'developer' + } + + if (lowerKeywords.some((keyword) => + ['design', 'designer', 'creative', 'branding', 'ui', 'ux'].some((term) => keyword.includes(term)) + )) { + return 'designer' + } + + if (lowerKeywords.some((keyword) => + ['student', 'education', 'learning', 'course'].some((term) => keyword.includes(term)) + )) { + return 'student' + } + + if (lowerKeywords.some((keyword) => + ['startup', 'founder', 'launch', 'growth'].some((term) => keyword.includes(term)) + )) { + return 'startup' + } + + if (lowerKeywords.some((keyword) => + ['luxury', 'premium', 'exclusive', 'high-end'].some((term) => keyword.includes(term)) + )) { + return 'b2c-premium' + } + + if (lowerKeywords.some((keyword) => + ['enterprise', 'compliance', 'governance', 'security', 'integration'].some((term) => keyword.includes(term)) + )) { + return 'b2b-enterprise' + } + + if (lowerKeywords.some((keyword) => + ['b2b', 'business', 'saas', 'smb', 'team', 'workspace', 'company'].some((term) => keyword.includes(term)) + )) { + return 'b2b-smb' + } + + return DEFAULT_METADATA_CONFIG.audience +} + +export function brandProfileToMetadataConfig( + input: MetadataBrandProfileInput +): MetadataPromptConfig { + const keywords = normalizeKeywords(input.keywords) + + const toneMap: Record = { + minimal: 'professional', + playful: 'friendly', + corporate: 'formal', + tech: 'technical', + custom: DEFAULT_METADATA_CONFIG.tone, + } + + const contentTypeMap: Record = { + web: 'product-landing', + mobile: 'app-store', + all: 'saas-homepage', + } + + return { + ...DEFAULT_METADATA_CONFIG, + tone: toneMap[input.styleDirection || 'custom'] || DEFAULT_METADATA_CONFIG.tone, + audience: inferMetadataAudience(keywords), + contentType: contentTypeMap[input.platform] || DEFAULT_METADATA_CONFIG.contentType, + focusKeywords: keywords, + language: input.language || DEFAULT_METADATA_CONFIG.language, + } +} + /** * AI 브랜드 카피 생성 * Claude를 사용하여 프로젝트에 맞는 헤드라인, 태그라인, OG 설명을 생성합니다. diff --git a/lib/ai/fal.ts b/lib/ai/fal.ts index 0733fd5..cee79e1 100644 --- a/lib/ai/fal.ts +++ b/lib/ai/fal.ts @@ -2,8 +2,18 @@ import { fal, type RunOptions } from '@fal-ai/client' import sharp from 'sharp' import type { StyleDirection, ColorMode, IconStyle, CornerStyle } from '@/types/database' import type { Project, BrandProfile, StylePreset } from '@/types/database' -import { composeIconPrompt } from '@/lib/prompts' +import { + composeIconPrompt, + composeOgBackgroundPrompt, + getDefaultPromptConfig, + mergePromptConfig, +} from '@/lib/prompts' import type { + OgPromptConfig, + OgLayoutStyle, + OgVisualElement, + OgTypographyStyle, + OgMoodTone, IconPromptConfig, IconVisualStyle, IconShape, @@ -49,6 +59,7 @@ interface FalFluxInput { const ICON_NEGATIVE_PROMPT = 'text, letters, words, typography, watermark, signature, blurry, low quality, ' + 'multiple icons, busy background, photograph, realistic photo' +const DEFAULT_OG_CONFIG = getDefaultPromptConfig().og as OgPromptConfig /** * AI 아이콘 생성 @@ -134,23 +145,18 @@ export async function generateOgBackground(params: GenerateOgBackgroundParams): const { project, brandProfile, stylePreset, width, height } = params const primaryColor = project.primary_color_override || brandProfile?.primary_color || '#6366F1' - const secondaryColors = brandProfile?.secondary_colors?.length - ? brandProfile.secondary_colors.join(', ') - : null - - const ogModifier = stylePreset.og_ai_style_modifier || stylePreset.ai_style_modifier || '' + const secondaryColors = brandProfile?.secondary_colors + const promptConfig = mergePromptConfig(getDefaultPromptConfig(), { + og: stylePresetToOgConfig(stylePreset), + }).og as OgPromptConfig + const composedPrompt = composeOgBackgroundPrompt(promptConfig, { + brandName: project.name, + primaryColor, + secondaryColors, + }) const negativeTerms = stylePreset.icon_ai_negative_prompt || '' - // 구조화된 OG 배경 프롬프트 - const promptParts = [ - `A professional abstract background image for a brand called "${project.name}"`, - ogModifier, - `Primary color: ${primaryColor}`, - secondaryColors ? `Secondary colors: ${secondaryColors}` : null, - 'Abstract decorative background only, no text, no logos, no human faces, no readable words, no letters', - ].filter(Boolean) - - let prompt = promptParts.join('. ') + let prompt = [composedPrompt.systemPrompt, composedPrompt.userPrompt].join(' ') // 네거티브 지시사항 추가 const avoidTerms = [ @@ -220,6 +226,87 @@ function getNumericSeed(value: unknown): number | undefined { return undefined } +export function stylePresetToOgConfig(preset: StylePreset): Partial { + const knownPresets: Record> = { + 'notion-minimal': { + layout: 'minimal-corner', + visual: 'none', + typography: 'minimal-clean', + mood: 'minimal', + }, + 'airbnb-3d': { + layout: 'centered', + visual: 'gradient-blob', + typography: 'playful-rounded', + mood: 'friendly', + }, + 'stripe-gradient': { + layout: 'centered', + visual: 'gradient-blob', + typography: 'bold-modern', + mood: 'technical', + }, + 'linear-dark': { + layout: 'left-aligned', + visual: 'grid-pattern', + typography: 'technical-mono', + mood: 'technical', + }, + 'vercel-sharp': { + layout: 'centered', + visual: 'geometric-shapes', + typography: 'bold-modern', + mood: 'bold', + }, + 'glassmorphism': { + layout: 'centered', + visual: 'gradient-blob', + typography: 'minimal-clean', + mood: 'creative', + }, + 'duolingo-playful': { + layout: 'card-stack', + visual: 'illustration', + typography: 'playful-rounded', + mood: 'energetic', + }, + 'figma-clean': { + layout: 'split-vertical', + visual: 'grid-pattern', + typography: 'minimal-clean', + mood: 'professional', + }, + } + + const layoutSource = preset.og_layout?.toLowerCase() || '' + const typographySource = preset.og_typography?.toLowerCase() || '' + const visualSource = [ + preset.og_background, + preset.og_ai_style_modifier, + preset.ai_style_modifier, + ].filter(Boolean).join(' ').toLowerCase() + const moodSource = [ + preset.slug, + preset.og_background, + preset.og_ai_style_modifier, + ...preset.best_for_styles, + ].join(' ').toLowerCase() + + const inferred: Partial = { + layout: inferOgLayout(layoutSource), + visual: inferOgVisual(visualSource), + typography: inferOgTypography(typographySource), + mood: inferOgMood(moodSource), + customAccent: preset.og_ai_style_modifier || preset.ai_style_modifier || undefined, + } + + return { + ...inferred, + ...knownPresets[preset.slug], + customAccent: inferred.customAccent, + } +} + function brandProfileToPromptConfig(profile: BrandProfileInfo): IconPromptConfig { const visualStyleMap: Record = { outline: 'outline-medium', @@ -257,3 +344,71 @@ function brandProfileToPromptConfig(profile: BrandProfileInfo): IconPromptConfig complexity: 'moderate', } } + +function inferOgLayout(layoutSource: string): OgLayoutStyle { + if (layoutSource.includes('left')) return 'left-aligned' + if (layoutSource.includes('grid')) return 'split-vertical' + if (layoutSource.includes('playful')) return 'card-stack' + if (layoutSource.includes('diagonal')) return 'diagonal-split' + if (layoutSource.includes('corner')) return 'minimal-corner' + if (layoutSource.includes('split') && layoutSource.includes('horizontal')) return 'split-horizontal' + if (layoutSource.includes('split')) return 'split-vertical' + return DEFAULT_OG_CONFIG.layout +} + +function inferOgVisual(visualSource: string): OgVisualElement { + if (visualSource.includes('photo')) return 'photo-background' + if (visualSource.includes('grid')) return 'grid-pattern' + if (visualSource.includes('noise') || visualSource.includes('grain')) return 'noise-texture' + if (visualSource.includes('illustration') || visualSource.includes('mascot')) return 'illustration' + if (visualSource.includes('icon') || visualSource.includes('logo accent')) return 'icon-accent' + if (visualSource.includes('blob') || visualSource.includes('gradient') || visualSource.includes('aurora') || visualSource.includes('glass')) { + return 'gradient-blob' + } + if (visualSource.includes('geometric') || visualSource.includes('line') || visualSource.includes('shape') || visualSource.includes('monochrome')) { + return 'geometric-shapes' + } + if (visualSource.includes('pattern')) return 'abstract-pattern' + return DEFAULT_OG_CONFIG.visual +} + +function inferOgTypography(typographySource: string): OgTypographyStyle { + if (typographySource.includes('mono') || typographySource.includes('code')) return 'technical-mono' + if (typographySource.includes('serif')) return 'elegant-serif' + if (typographySource.includes('editorial')) return 'editorial' + if (typographySource.includes('jakarta') || typographySource.includes('nunito') || typographySource.includes('rounded')) { + return 'playful-rounded' + } + if (typographySource.includes('regular') || typographySource.includes('light') || typographySource.includes('clean')) { + return 'minimal-clean' + } + if (typographySource.includes('handwritten') || typographySource.includes('script')) { + return 'handwritten-accent' + } + return DEFAULT_OG_CONFIG.typography +} + +function inferOgMood(moodSource: string): OgMoodTone { + if (moodSource.includes('playful') || moodSource.includes('friendly') || moodSource.includes('warm') || moodSource.includes('cozy')) { + return 'friendly' + } + if (moodSource.includes('energetic') || moodSource.includes('cheerful') || moodSource.includes('vivid')) { + return 'energetic' + } + if (moodSource.includes('technical') || moodSource.includes('neon') || moodSource.includes('futuristic') || moodSource.includes('grid')) { + return 'technical' + } + if (moodSource.includes('luxury') || moodSource.includes('luxurious') || moodSource.includes('premium')) { + return 'luxurious' + } + if (moodSource.includes('creative') || moodSource.includes('glass') || moodSource.includes('artistic')) { + return 'creative' + } + if (moodSource.includes('minimal') || moodSource.includes('clean') || moodSource.includes('monochrome')) { + return 'minimal' + } + if (moodSource.includes('bold') || moodSource.includes('contrast') || moodSource.includes('brutalist')) { + return 'bold' + } + return DEFAULT_OG_CONFIG.mood +} diff --git a/lib/prompts/composer.ts b/lib/prompts/composer.ts index f686d58..3b79314 100644 --- a/lib/prompts/composer.ts +++ b/lib/prompts/composer.ts @@ -1,11 +1,18 @@ import type { ComposedPrompt, + OgPromptConfig, IconPromptConfig, MetadataPromptConfig, FullPromptConfig, PromptCategory, } from './types' import { resolveConflicts } from './conflict-resolver' +import { + ogLayoutCategories, + ogVisualCategories, + ogTypographyCategories, + ogMoodCategories, +} from './categories/og-image' import { iconVisualStyleCategories, iconShapeCategories, @@ -28,6 +35,52 @@ function findCategory( return categories.find((c) => c.id === id) } +// ======================================== +// OG Background Prompt Composer +// ======================================== + +export function composeOgBackgroundPrompt( + config: OgPromptConfig, + context: { + brandName: string + primaryColor: string + secondaryColors?: string[] + } +): ComposedPrompt { + const selectedCategories = [ + findCategory(ogLayoutCategories, config.layout), + findCategory(ogVisualCategories, config.visual), + findCategory(ogTypographyCategories, config.typography), + findCategory(ogMoodCategories, config.mood), + ].filter(Boolean) as PromptCategory[] + + const { resolved, warnings } = resolveConflicts(selectedCategories) + const fragments = resolved.map((c) => c.promptFragment) + + const colorFragments = [ + `primary brand color: ${context.primaryColor}`, + context.secondaryColors?.length + ? `secondary brand colors: ${context.secondaryColors.join(', ')}` + : null, + config.customAccent?.trim() || null, + ].filter(Boolean) as string[] + + const coreRequirements = [ + `for the brand atmosphere of "${context.brandName}" without rendering the brand name`, + 'abstract decorative background only', + 'no text, no readable words, no letters, no logos', + 'no people, no faces, no products, no UI mockups', + 'optimized for OG and social sharing with clean safe areas for overlay text', + ] + + return { + systemPrompt: 'Generate an abstract brand background image with the following direction:', + userPrompt: [...fragments, ...colorFragments, ...coreRequirements].join(', '), + fragments: [...fragments, ...colorFragments, ...coreRequirements], + warnings, + } +} + // ======================================== // Icon Prompt Composer // ======================================== diff --git a/lib/prompts/index.ts b/lib/prompts/index.ts index 38c9399..c2c4219 100644 --- a/lib/prompts/index.ts +++ b/lib/prompts/index.ts @@ -53,6 +53,7 @@ export { // Composer export { + composeOgBackgroundPrompt, composeIconPrompt, composeMetadataPrompt, getDefaultPromptConfig, diff --git a/supabase/migrations/013_fix_handle_new_user_email.sql b/supabase/migrations/013_fix_handle_new_user_email.sql new file mode 100644 index 0000000..f4031eb --- /dev/null +++ b/supabase/migrations/013_fix_handle_new_user_email.sql @@ -0,0 +1,25 @@ +-- Fix auth signup trigger to use auth.users.email instead of metadata. +-- Some signup flows do not populate raw_user_meta_data.email, which caused +-- public.users.email NOT NULL violations and broke signup/admin user creation. + +CREATE OR REPLACE FUNCTION public.handle_new_user() +RETURNS TRIGGER +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = '' +AS $$ +BEGIN + INSERT INTO public.users (id, email) + VALUES ( + NEW.id, + COALESCE( + NULLIF(NEW.email, ''), + NULLIF(NEW.raw_user_meta_data ->> 'email', '') + ) + ) + ON CONFLICT (id) DO UPDATE + SET email = EXCLUDED.email; + + RETURN NEW; +END; +$$; diff --git a/tests/lib/ai/prompt-config.test.ts b/tests/lib/ai/prompt-config.test.ts new file mode 100644 index 0000000..09af02a --- /dev/null +++ b/tests/lib/ai/prompt-config.test.ts @@ -0,0 +1,95 @@ +import { beforeAll, describe, expect, it } from 'vitest' +import { composeOgBackgroundPrompt, mergePromptConfig, getDefaultPromptConfig } from '@/lib/prompts' +import type { MetadataPromptConfig, OgPromptConfig } from '@/lib/prompts' +import type { StylePreset } from '@/types/database' + +let brandProfileToMetadataConfig: (input: { + platform: string + styleDirection?: string + keywords?: string[] + language?: MetadataPromptConfig['language'] +}) => MetadataPromptConfig + +let stylePresetToOgConfig: (preset: StylePreset) => Partial + +beforeAll(async () => { + process.env.ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY || 'test-key' + process.env.FAL_KEY = process.env.FAL_KEY || 'test-key' + + ;({ brandProfileToMetadataConfig } = await import('@/lib/ai/claude')) + ;({ stylePresetToOgConfig } = await import('@/lib/ai/fal')) +}) + +describe('prompt config mapping', () => { + it('maps brand profile hints to metadata prompt config', () => { + const config = brandProfileToMetadataConfig({ + platform: 'all', + styleDirection: 'tech', + keywords: ['B2B SaaS', 'enterprise security', 'automation'], + }) + + expect(config).toMatchObject({ + tone: 'technical', + audience: 'b2b-enterprise', + contentType: 'saas-homepage', + urgency: 'evergreen', + language: 'ko', + focusKeywords: ['B2B SaaS', 'enterprise security', 'automation'], + }) + }) + + it('maps style preset OG fields into structured OG config', () => { + const preset: StylePreset = { + id: 'preset-1', + name: 'Linear Dark', + slug: 'linear-dark', + is_free: false, + best_for_styles: ['tech', 'minimal'], + icon_style: 'Outline with neon accent', + corner_radius: 12, + shadow_style: 'Subtle neon glow', + color_mode: 'Dark with accent', + og_layout: 'Left-aligned, dark background', + og_typography: 'Inter, Medium', + og_background: 'Dark (#0A0A0A) with subtle gradient', + ai_style_modifier: 'dark background, neon accent lines, futuristic, Linear-style', + og_ai_style_modifier: 'deep dark matte background with subtle neon accent lines', + icon_ai_negative_prompt: 'bright colors, busy, cartoon', + icon_ai_prompt_template: null, + preview_image_url: null, + sort_order: 4, + created_at: '2026-03-10T00:00:00.000Z', + } + + expect(stylePresetToOgConfig(preset)).toMatchObject({ + layout: 'left-aligned', + visual: 'grid-pattern', + typography: 'technical-mono', + mood: 'technical', + customAccent: 'deep dark matte background with subtle neon accent lines', + }) + }) + + it('composes OG prompts with preset overrides and brand colors', () => { + const promptConfig = mergePromptConfig(getDefaultPromptConfig(), { + og: { + layout: 'minimal-corner', + visual: 'grid-pattern', + typography: 'technical-mono', + mood: 'technical', + customAccent: 'thin neon line accents', + }, + }).og as OgPromptConfig + + const composed = composeOgBackgroundPrompt(promptConfig, { + brandName: 'Conductor', + primaryColor: '#0A84FF', + secondaryColors: ['#7C3AED'], + }) + + expect(composed.userPrompt).toContain('technical developer aesthetic') + expect(composed.userPrompt).toContain('thin neon line accents') + expect(composed.userPrompt).toContain('primary brand color: #0A84FF') + expect(composed.userPrompt).toContain('without rendering the brand name') + }) +}) diff --git a/types/database.ts b/types/database.ts index 631a82c..47c3a82 100644 --- a/types/database.ts +++ b/types/database.ts @@ -1,229 +1,99 @@ -export type Plan = 'free' | 'pro' -export type StyleDirection = 'minimal' | 'playful' | 'corporate' | 'tech' | 'custom' -export type ColorMode = 'mono' | 'duotone' | 'gradient' | 'vibrant' -export type IconStyle = 'outline' | 'filled' | '3d_soft' | 'flat' -export type CornerStyle = 'sharp' | 'rounded' | 'pill' -export type Platform = 'web' | 'mobile' | 'all' -export type MobileTarget = 'android' | 'ios' | 'both' -export type IconType = 'text' | 'symbol' | 'ai_generated' -export type ProjectStatus = 'draft' | 'generating' | 'completed' | 'failed' -export type PipelineStage = 'icon_resolve' | 'favicons' | 'og' | 'app_icons' | 'splash' | 'zip' | 'upload' +import type { + Database as SupabaseDatabase, + Tables, + TablesInsert, + TablesUpdate, + Enums, + Json, +} from './supabase-generated' -export interface User { - id: string - email: string - plan: Plan - lemonsqueezy_customer_id: string | null - lemonsqueezy_subscription_id: string | null - projects_used_this_month: number - ai_headlines_used_this_month: number - ai_icons_used_this_month: number - usage_reset_at: string - created_at: string - updated_at: string -} +export type Database = SupabaseDatabase +export type GeneratedDatabase = SupabaseDatabase +export type { Json } -export interface BrandProfile { - id: string - user_id: string - name: string - style_direction: StyleDirection - primary_color: string - secondary_colors: string[] - color_mode: ColorMode - icon_style: IconStyle - corner_style: CornerStyle - typography_mood: string | null - keywords: string[] - is_default: boolean - created_at: string - updated_at: string -} +export type Plan = Enums<'plan_type'> +export type StyleDirection = Enums<'style_direction'> +export type ColorMode = Enums<'color_mode'> +export type IconStyle = Enums<'icon_style'> +export type CornerStyle = Enums<'corner_style'> +export type Platform = Enums<'platform_type'> +export type MobileTarget = Enums<'mobile_target'> +export type IconType = Enums<'icon_type'> +export type ProjectStatus = Enums<'project_status'> -export interface StylePreset { - id: string - name: string - slug: string - is_free: boolean - best_for_styles: string[] - icon_style: string | null - corner_radius: number - shadow_style: string | null - color_mode: string | null - og_layout: string | null - og_typography: string | null - og_background: string | null - ai_style_modifier: string | null - og_ai_style_modifier: string | null - icon_ai_negative_prompt: string | null - icon_ai_prompt_template: string | null - preview_image_url: string | null - sort_order: number - created_at: string -} +// pipeline_stage is stored as TEXT in Postgres, but the app narrows it to known values. +export type PipelineStage = + | 'icon_resolve' + | 'favicons' + | 'og' + | 'app_icons' + | 'splash' + | 'zip' + | 'upload' -export interface Project { - id: string - user_id: string - brand_profile_id: string | null - style_preset_id: string - name: string - description: string | null - platform: Platform - mobile_target: MobileTarget | null - primary_color_override: string | null +export type NotificationType = Enums<'notification_type'> +export type FeedbackSentiment = Enums<'feedback_sentiment'> + +export type User = Tables<'users'> +export type BrandProfile = Tables<'brand_profiles'> +export type StylePreset = Tables<'style_presets'> +export type Notification = Tables<'notifications'> +export type Feedback = Tables<'feedback'> +export type NpsResponse = Tables<'nps_responses'> + +type ProjectOverrides = { icon_type: IconType | null - icon_value: string | null - ai_headline: string | null - ai_tagline: string | null - ai_og_description: string | null - ai_short_slogan: string | null - assets_zip_url: string | null + mobile_target: MobileTarget | null pipeline_stage: PipelineStage | null + platform: Platform status: ProjectStatus - created_at: string - updated_at: string -} - -export interface UserInsert { - id: string - email: string - plan?: Plan -} - -export interface UserUpdate { - email?: string - plan?: Plan - lemonsqueezy_customer_id?: string | null - lemonsqueezy_subscription_id?: string | null - projects_used_this_month?: number - ai_headlines_used_this_month?: number - ai_icons_used_this_month?: number - usage_reset_at?: string } -export interface BrandProfileInsert { - user_id: string - name: string - style_direction?: StyleDirection - primary_color?: string - secondary_colors?: string[] - color_mode?: ColorMode - icon_style?: IconStyle - corner_style?: CornerStyle - typography_mood?: string | null - keywords?: string[] - is_default?: boolean -} - -export interface BrandProfileUpdate { - name?: string - style_direction?: StyleDirection - primary_color?: string - secondary_colors?: string[] - color_mode?: ColorMode - icon_style?: IconStyle - corner_style?: CornerStyle - typography_mood?: string | null - keywords?: string[] - is_default?: boolean -} - -export interface ProjectInsert { - user_id: string - style_preset_id: string - name: string - platform: Platform - brand_profile_id?: string | null - description?: string | null - mobile_target?: MobileTarget | null - primary_color_override?: string | null +type ProjectInsertOverrides = { icon_type?: IconType | null - icon_value?: string | null - ai_headline?: string | null - ai_tagline?: string | null - ai_og_description?: string | null - ai_short_slogan?: string | null - assets_zip_url?: string | null + mobile_target?: MobileTarget | null pipeline_stage?: PipelineStage | null + platform?: Platform status?: ProjectStatus } -export interface ProjectUpdate { - brand_profile_id?: string | null - style_preset_id?: string - name?: string - description?: string | null - platform?: Platform - mobile_target?: MobileTarget | null - primary_color_override?: string | null +type ProjectUpdateOverrides = { icon_type?: IconType | null - icon_value?: string | null - ai_headline?: string | null - ai_tagline?: string | null - ai_og_description?: string | null - ai_short_slogan?: string | null - assets_zip_url?: string | null + mobile_target?: MobileTarget | null pipeline_stage?: PipelineStage | null + platform?: Platform status?: ProjectStatus } -export interface Database { - public: { - Tables: { - users: { - Row: User - Insert: UserInsert - Update: UserUpdate - } - brand_profiles: { - Row: BrandProfile - Insert: BrandProfileInsert - Update: BrandProfileUpdate - } - style_presets: { - Row: StylePreset - Insert: never - Update: never - } - projects: { - Row: Project - Insert: ProjectInsert - Update: ProjectUpdate - } - } - Views: { - [_ in never]: never - } - Functions: { - reset_monthly_usage: { - Args: Record - Returns: void - } - increment_usage: { - Args: { p_user_id: string; p_field_name: string } - Returns: void - } - } - Enums: { - [_ in never]: never - } - } -} +export type Project = Omit, keyof ProjectOverrides> & ProjectOverrides +export type UserInsert = TablesInsert<'users'> +export type UserUpdate = TablesUpdate<'users'> +export type BrandProfileInsert = TablesInsert<'brand_profiles'> +export type BrandProfileUpdate = TablesUpdate<'brand_profiles'> +export type StylePresetInsert = TablesInsert<'style_presets'> +export type StylePresetUpdate = TablesUpdate<'style_presets'> +export type NotificationInsert = TablesInsert<'notifications'> +export type NotificationUpdate = TablesUpdate<'notifications'> +export type FeedbackInsert = TablesInsert<'feedback'> +export type FeedbackUpdate = TablesUpdate<'feedback'> +export type NpsResponseInsert = TablesInsert<'nps_responses'> +export type NpsResponseUpdate = TablesUpdate<'nps_responses'> +export type ProjectInsert = Omit, keyof ProjectInsertOverrides> & ProjectInsertOverrides +export type ProjectUpdate = Omit, keyof ProjectUpdateOverrides> & ProjectUpdateOverrides export const PLAN_LIMITS = { free: { - brand_profiles: 2, // 핵심 가치 경험을 위해 1→2개로 상향 + brand_profiles: 2, projects_per_month: 3, ai_headlines_per_month: 10, - ai_icons_per_month: 3, // Free 사용자도 AI 아이콘 맛보기 허용 + ai_icons_per_month: 3, style_presets: 'free_only' as const, }, pro: { brand_profiles: 5, projects_per_month: Infinity, ai_headlines_per_month: Infinity, - ai_icons_per_month: 50, // Pro 플랜 월간 제한 명시 + ai_icons_per_month: 50, style_presets: 'all' as const, }, } as const diff --git a/types/supabase-generated.ts b/types/supabase-generated.ts new file mode 100644 index 0000000..3acbed9 --- /dev/null +++ b/types/supabase-generated.ts @@ -0,0 +1,542 @@ +export type Json = + | string + | number + | boolean + | null + | { [key: string]: Json | undefined } + | Json[] + +export type Database = { + // Allows to automatically instantiate createClient with right options + // instead of createClient(URL, KEY) + __InternalSupabase: { + PostgrestVersion: "14.1" + } + public: { + Tables: { + brand_profiles: { + Row: { + color_mode: Database["public"]["Enums"]["color_mode"] + corner_style: Database["public"]["Enums"]["corner_style"] + created_at: string + icon_style: Database["public"]["Enums"]["icon_style"] + id: string + is_default: boolean + keywords: string[] + name: string + primary_color: string + secondary_colors: string[] + style_direction: Database["public"]["Enums"]["style_direction"] + typography_mood: string | null + updated_at: string + user_id: string + } + Insert: { + color_mode?: Database["public"]["Enums"]["color_mode"] + corner_style?: Database["public"]["Enums"]["corner_style"] + created_at?: string + icon_style?: Database["public"]["Enums"]["icon_style"] + id?: string + is_default?: boolean + keywords?: string[] + name: string + primary_color?: string + secondary_colors?: string[] + style_direction?: Database["public"]["Enums"]["style_direction"] + typography_mood?: string | null + updated_at?: string + user_id: string + } + Update: { + color_mode?: Database["public"]["Enums"]["color_mode"] + corner_style?: Database["public"]["Enums"]["corner_style"] + created_at?: string + icon_style?: Database["public"]["Enums"]["icon_style"] + id?: string + is_default?: boolean + keywords?: string[] + name?: string + primary_color?: string + secondary_colors?: string[] + style_direction?: Database["public"]["Enums"]["style_direction"] + typography_mood?: string | null + updated_at?: string + user_id?: string + } + Relationships: [ + { + foreignKeyName: "brand_profiles_user_id_fkey" + columns: ["user_id"] + isOneToOne: false + referencedRelation: "users" + referencedColumns: ["id"] + }, + ] + } + feedback: { + Row: { + created_at: string | null + id: string + message: string + page_url: string | null + sentiment: Database["public"]["Enums"]["feedback_sentiment"] + user_agent: string | null + user_id: string | null + } + Insert: { + created_at?: string | null + id?: string + message: string + page_url?: string | null + sentiment: Database["public"]["Enums"]["feedback_sentiment"] + user_agent?: string | null + user_id?: string | null + } + Update: { + created_at?: string | null + id?: string + message?: string + page_url?: string | null + sentiment?: Database["public"]["Enums"]["feedback_sentiment"] + user_agent?: string | null + user_id?: string | null + } + Relationships: [] + } + notifications: { + Row: { + created_at: string | null + data: Json | null + id: string + message: string + read_at: string | null + title: string + type: Database["public"]["Enums"]["notification_type"] + user_id: string + } + Insert: { + created_at?: string | null + data?: Json | null + id?: string + message: string + read_at?: string | null + title: string + type: Database["public"]["Enums"]["notification_type"] + user_id: string + } + Update: { + created_at?: string | null + data?: Json | null + id?: string + message?: string + read_at?: string | null + title?: string + type?: Database["public"]["Enums"]["notification_type"] + user_id?: string + } + Relationships: [] + } + nps_responses: { + Row: { + created_at: string | null + feedback: string | null + id: string + score: number + user_id: string | null + } + Insert: { + created_at?: string | null + feedback?: string | null + id?: string + score: number + user_id?: string | null + } + Update: { + created_at?: string | null + feedback?: string | null + id?: string + score?: number + user_id?: string | null + } + Relationships: [] + } + projects: { + Row: { + ai_headline: string | null + ai_og_description: string | null + ai_short_slogan: string | null + ai_tagline: string | null + assets_zip_url: string | null + brand_profile_id: string | null + created_at: string + description: string | null + icon_type: Database["public"]["Enums"]["icon_type"] | null + icon_value: string | null + id: string + mobile_target: Database["public"]["Enums"]["mobile_target"] | null + name: string + pipeline_stage: string | null + platform: Database["public"]["Enums"]["platform_type"] + primary_color_override: string | null + status: Database["public"]["Enums"]["project_status"] + style_preset_id: string + updated_at: string + user_id: string + } + Insert: { + ai_headline?: string | null + ai_og_description?: string | null + ai_short_slogan?: string | null + ai_tagline?: string | null + assets_zip_url?: string | null + brand_profile_id?: string | null + created_at?: string + description?: string | null + icon_type?: Database["public"]["Enums"]["icon_type"] | null + icon_value?: string | null + id?: string + mobile_target?: Database["public"]["Enums"]["mobile_target"] | null + name: string + pipeline_stage?: string | null + platform?: Database["public"]["Enums"]["platform_type"] + primary_color_override?: string | null + status?: Database["public"]["Enums"]["project_status"] + style_preset_id: string + updated_at?: string + user_id: string + } + Update: { + ai_headline?: string | null + ai_og_description?: string | null + ai_short_slogan?: string | null + ai_tagline?: string | null + assets_zip_url?: string | null + brand_profile_id?: string | null + created_at?: string + description?: string | null + icon_type?: Database["public"]["Enums"]["icon_type"] | null + icon_value?: string | null + id?: string + mobile_target?: Database["public"]["Enums"]["mobile_target"] | null + name?: string + pipeline_stage?: string | null + platform?: Database["public"]["Enums"]["platform_type"] + primary_color_override?: string | null + status?: Database["public"]["Enums"]["project_status"] + style_preset_id?: string + updated_at?: string + user_id?: string + } + Relationships: [ + { + foreignKeyName: "projects_brand_profile_id_fkey" + columns: ["brand_profile_id"] + isOneToOne: false + referencedRelation: "brand_profiles" + referencedColumns: ["id"] + }, + { + foreignKeyName: "projects_style_preset_id_fkey" + columns: ["style_preset_id"] + isOneToOne: false + referencedRelation: "style_presets" + referencedColumns: ["id"] + }, + { + foreignKeyName: "projects_user_id_fkey" + columns: ["user_id"] + isOneToOne: false + referencedRelation: "users" + referencedColumns: ["id"] + }, + ] + } + style_presets: { + Row: { + ai_style_modifier: string | null + best_for_styles: string[] + color_mode: string | null + corner_radius: number + created_at: string + icon_ai_negative_prompt: string | null + icon_ai_prompt_template: string | null + icon_style: string | null + id: string + is_free: boolean + name: string + og_ai_style_modifier: string | null + og_background: string | null + og_layout: string | null + og_typography: string | null + preview_image_url: string | null + shadow_style: string | null + slug: string + sort_order: number + } + Insert: { + ai_style_modifier?: string | null + best_for_styles?: string[] + color_mode?: string | null + corner_radius?: number + created_at?: string + icon_ai_negative_prompt?: string | null + icon_ai_prompt_template?: string | null + icon_style?: string | null + id?: string + is_free?: boolean + name: string + og_ai_style_modifier?: string | null + og_background?: string | null + og_layout?: string | null + og_typography?: string | null + preview_image_url?: string | null + shadow_style?: string | null + slug: string + sort_order?: number + } + Update: { + ai_style_modifier?: string | null + best_for_styles?: string[] + color_mode?: string | null + corner_radius?: number + created_at?: string + icon_ai_negative_prompt?: string | null + icon_ai_prompt_template?: string | null + icon_style?: string | null + id?: string + is_free?: boolean + name?: string + og_ai_style_modifier?: string | null + og_background?: string | null + og_layout?: string | null + og_typography?: string | null + preview_image_url?: string | null + shadow_style?: string | null + slug?: string + sort_order?: number + } + Relationships: [] + } + users: { + Row: { + ai_headlines_used_this_month: number + ai_icons_used_this_month: number + created_at: string + email: string + id: string + lemonsqueezy_customer_id: string | null + lemonsqueezy_subscription_id: string | null + plan: Database["public"]["Enums"]["plan_type"] + projects_used_this_month: number + updated_at: string + usage_reset_at: string + } + Insert: { + ai_headlines_used_this_month?: number + ai_icons_used_this_month?: number + created_at?: string + email: string + id: string + lemonsqueezy_customer_id?: string | null + lemonsqueezy_subscription_id?: string | null + plan?: Database["public"]["Enums"]["plan_type"] + projects_used_this_month?: number + updated_at?: string + usage_reset_at?: string + } + Update: { + ai_headlines_used_this_month?: number + ai_icons_used_this_month?: number + created_at?: string + email?: string + id?: string + lemonsqueezy_customer_id?: string | null + lemonsqueezy_subscription_id?: string | null + plan?: Database["public"]["Enums"]["plan_type"] + projects_used_this_month?: number + updated_at?: string + usage_reset_at?: string + } + Relationships: [] + } + } + Views: { + [_ in never]: never + } + Functions: { + get_unread_notification_count: { + Args: { p_user_id: string } + Returns: number + } + increment_usage: { + Args: { p_field_name: string; p_user_id: string } + Returns: undefined + } + mark_all_notifications_read: { + Args: { p_user_id: string } + Returns: undefined + } + reset_monthly_usage: { Args: never; Returns: undefined } + } + Enums: { + color_mode: "mono" | "duotone" | "gradient" | "vibrant" + corner_style: "sharp" | "rounded" | "pill" + feedback_sentiment: "positive" | "neutral" | "negative" + icon_style: "outline" | "filled" | "3d_soft" | "flat" + icon_type: "text" | "symbol" | "ai_generated" + mobile_target: "android" | "ios" | "both" + notification_type: + | "project_complete" + | "subscription_update" + | "system_announcement" + | "usage_warning" + plan_type: "free" | "pro" + platform_type: "web" | "mobile" | "all" + project_status: "draft" | "generating" | "completed" | "failed" + style_direction: "minimal" | "playful" | "corporate" | "tech" | "custom" + } + CompositeTypes: { + [_ in never]: never + } + } +} + +type DatabaseWithoutInternals = Omit + +type DefaultSchema = DatabaseWithoutInternals[Extract] + +export type Tables< + DefaultSchemaTableNameOrOptions extends + | keyof (DefaultSchema["Tables"] & DefaultSchema["Views"]) + | { schema: keyof DatabaseWithoutInternals }, + TableName extends DefaultSchemaTableNameOrOptions extends { + schema: keyof DatabaseWithoutInternals + } + ? keyof (DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] & + DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Views"]) + : never = never, +> = DefaultSchemaTableNameOrOptions extends { + schema: keyof DatabaseWithoutInternals +} + ? (DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] & + DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Views"])[TableName] extends { + Row: infer R + } + ? R + : never + : DefaultSchemaTableNameOrOptions extends keyof (DefaultSchema["Tables"] & + DefaultSchema["Views"]) + ? (DefaultSchema["Tables"] & + DefaultSchema["Views"])[DefaultSchemaTableNameOrOptions] extends { + Row: infer R + } + ? R + : never + : never + +export type TablesInsert< + DefaultSchemaTableNameOrOptions extends + | keyof DefaultSchema["Tables"] + | { schema: keyof DatabaseWithoutInternals }, + TableName extends DefaultSchemaTableNameOrOptions extends { + schema: keyof DatabaseWithoutInternals + } + ? keyof DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] + : never = never, +> = DefaultSchemaTableNameOrOptions extends { + schema: keyof DatabaseWithoutInternals +} + ? DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"][TableName] extends { + Insert: infer I + } + ? I + : never + : DefaultSchemaTableNameOrOptions extends keyof DefaultSchema["Tables"] + ? DefaultSchema["Tables"][DefaultSchemaTableNameOrOptions] extends { + Insert: infer I + } + ? I + : never + : never + +export type TablesUpdate< + DefaultSchemaTableNameOrOptions extends + | keyof DefaultSchema["Tables"] + | { schema: keyof DatabaseWithoutInternals }, + TableName extends DefaultSchemaTableNameOrOptions extends { + schema: keyof DatabaseWithoutInternals + } + ? keyof DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] + : never = never, +> = DefaultSchemaTableNameOrOptions extends { + schema: keyof DatabaseWithoutInternals +} + ? DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"][TableName] extends { + Update: infer U + } + ? U + : never + : DefaultSchemaTableNameOrOptions extends keyof DefaultSchema["Tables"] + ? DefaultSchema["Tables"][DefaultSchemaTableNameOrOptions] extends { + Update: infer U + } + ? U + : never + : never + +export type Enums< + DefaultSchemaEnumNameOrOptions extends + | keyof DefaultSchema["Enums"] + | { schema: keyof DatabaseWithoutInternals }, + EnumName extends DefaultSchemaEnumNameOrOptions extends { + schema: keyof DatabaseWithoutInternals + } + ? keyof DatabaseWithoutInternals[DefaultSchemaEnumNameOrOptions["schema"]]["Enums"] + : never = never, +> = DefaultSchemaEnumNameOrOptions extends { + schema: keyof DatabaseWithoutInternals +} + ? DatabaseWithoutInternals[DefaultSchemaEnumNameOrOptions["schema"]]["Enums"][EnumName] + : DefaultSchemaEnumNameOrOptions extends keyof DefaultSchema["Enums"] + ? DefaultSchema["Enums"][DefaultSchemaEnumNameOrOptions] + : never + +export type CompositeTypes< + PublicCompositeTypeNameOrOptions extends + | keyof DefaultSchema["CompositeTypes"] + | { schema: keyof DatabaseWithoutInternals }, + CompositeTypeName extends PublicCompositeTypeNameOrOptions extends { + schema: keyof DatabaseWithoutInternals + } + ? keyof DatabaseWithoutInternals[PublicCompositeTypeNameOrOptions["schema"]]["CompositeTypes"] + : never = never, +> = PublicCompositeTypeNameOrOptions extends { + schema: keyof DatabaseWithoutInternals +} + ? DatabaseWithoutInternals[PublicCompositeTypeNameOrOptions["schema"]]["CompositeTypes"][CompositeTypeName] + : PublicCompositeTypeNameOrOptions extends keyof DefaultSchema["CompositeTypes"] + ? DefaultSchema["CompositeTypes"][PublicCompositeTypeNameOrOptions] + : never + +export const Constants = { + public: { + Enums: { + color_mode: ["mono", "duotone", "gradient", "vibrant"], + corner_style: ["sharp", "rounded", "pill"], + feedback_sentiment: ["positive", "neutral", "negative"], + icon_style: ["outline", "filled", "3d_soft", "flat"], + icon_type: ["text", "symbol", "ai_generated"], + mobile_target: ["android", "ios", "both"], + notification_type: [ + "project_complete", + "subscription_update", + "system_announcement", + "usage_warning", + ], + plan_type: ["free", "pro"], + platform_type: ["web", "mobile", "all"], + project_status: ["draft", "generating", "completed", "failed"], + style_direction: ["minimal", "playful", "corporate", "tech", "custom"], + }, + }, +} as const diff --git a/types/wizard.ts b/types/wizard.ts index a304d7c..55715e7 100644 --- a/types/wizard.ts +++ b/types/wizard.ts @@ -1,4 +1,5 @@ import type { Platform, MobileTarget, IconType } from './database' +import type { MetadataPromptConfig } from '@/lib/prompts' export interface WizardBrandData { brandProfileId: string | null @@ -35,6 +36,7 @@ export interface HeadlineRequest { platform: Platform brandKeywords?: string[] brandStyleDirection?: string + promptConfig?: MetadataPromptConfig } export interface HeadlineResponse { From 3d324fe77c51b839ca6d04946869e0b2f379a3b2 Mon Sep 17 00:00:00 2001 From: Younggun Park Date: Tue, 10 Mar 2026 18:00:59 +0900 Subject: [PATCH 2/2] Add lockfile and smoke test artifacts --- .../playwright/smoke-flow-1773131968671.png | Bin 0 -> 86158 bytes output/playwright/step4-debug.png | Bin 0 -> 59056 bytes yarn.lock | 1817 ++++++++--------- 3 files changed, 862 insertions(+), 955 deletions(-) create mode 100644 output/playwright/smoke-flow-1773131968671.png create mode 100644 output/playwright/step4-debug.png diff --git a/output/playwright/smoke-flow-1773131968671.png b/output/playwright/smoke-flow-1773131968671.png new file mode 100644 index 0000000000000000000000000000000000000000..2a7bb583f7cc93f43f70beb8956290ad9361e48c GIT binary patch literal 86158 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