Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions app/[locale]/(dashboard)/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
export const dynamic = 'force-dynamic'

import AuthGuard from '@/components/layout/auth-guard'
import { Sidebar } from '@/components/layout/sidebar'
import { MobileNav } from '@/components/layout/mobile-nav'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import { useState } from 'react'
import { Download, RefreshCw, Loader2, Copy } from 'lucide-react'
import { toast } from 'sonner'
import { Link, useRouter } from '@/i18n/navigation'
import { useRouter } from '@/i18n/navigation'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Card, CardContent } from '@/components/ui/card'
Expand Down
1 change: 0 additions & 1 deletion app/[locale]/(dashboard)/projects/actions.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
'use server'

import { redirect } from 'next/navigation'
import { createClient } from '@/lib/supabase/server'
import type { Project } from '@/types/database'

Expand Down
2 changes: 1 addition & 1 deletion app/[locale]/(dashboard)/projects/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { createClient } from '@/lib/supabase/server'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Card, CardContent } from '@/components/ui/card'
import { Plus, FolderOpen, ArrowRight, Sparkles } from 'lucide-react'
import { Plus, ArrowRight, Sparkles } from 'lucide-react'
import type { ProjectStatus } from '@/types/database'

const STATUS_CONFIG: Record<ProjectStatus, { variant: 'default' | 'success' | 'warning' | 'error'; label: string }> = {
Expand Down
1 change: 1 addition & 0 deletions app/api/ai/icons/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ export async function POST(request: Request) {
const images = await generateIcon({
description: body.description,
brandProfile: body.brandProfile,
styleModifier: body.styleModifier,
})

await incrementUsage(admin, user.id, 'ai_icons_used_this_month')
Expand Down
2 changes: 1 addition & 1 deletion app/api/email/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
* This route is protected by an internal API key
*/

import { NextRequest, NextResponse } from 'next/server'
import { type NextRequest, NextResponse } from 'next/server'
import { sendEmail } from '@/lib/email/client'
import { WelcomeEmail, ProjectCompleteEmail } from '@/lib/email/templates'

Expand Down
2 changes: 1 addition & 1 deletion app/api/feedback/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
* Handles submission of general feedback and NPS responses
*/

import { NextRequest, NextResponse } from 'next/server'
import { type NextRequest, NextResponse } from 'next/server'
import { createClient } from '@/lib/supabase/server'

interface FeedbackBody {
Expand Down
2 changes: 1 addition & 1 deletion app/api/notifications/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
* Handles fetching and updating notifications for authenticated users
*/

import { NextRequest, NextResponse } from 'next/server'
import { type NextRequest, NextResponse } from 'next/server'
import { createClient } from '@/lib/supabase/server'
import {
getNotifications,
Expand Down
20 changes: 14 additions & 6 deletions app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -66,8 +66,12 @@ body {

/* -- Dark Mode -- */
[data-theme="dark"] {
--color-brand: #fafafa;
--color-brand-foreground: #0a0a0b;
--color-brand: #111114;
--color-brand-foreground: #fafafa;

--color-accent: #818cf8;
--color-accent-light: #a5b4fc;
--color-accent-dark: #6366f1;

--color-surface: #0a0a0b;
--color-surface-secondary: #18181b;
Expand All @@ -84,14 +88,18 @@ body {
--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(99 102 241 / 0.25), 0 0 60px rgb(99 102 241 / 0.15);
--shadow-glow: 0 0 20px rgb(129 140 248 / 0.3), 0 0 60px rgb(129 140 248 / 0.15);
}

/* 시스템 설정 기반 자동 다크모드 (선택적) */
@media (prefers-color-scheme: dark) {
html:not([data-theme="light"]) {
--color-brand: #fafafa;
--color-brand-foreground: #0a0a0b;
--color-brand: #111114;
--color-brand-foreground: #fafafa;

--color-accent: #818cf8;
--color-accent-light: #a5b4fc;
--color-accent-dark: #6366f1;

--color-surface: #0a0a0b;
--color-surface-secondary: #18181b;
Expand Down Expand Up @@ -205,7 +213,7 @@ body {

/* -- Gradient Text -- */
.text-gradient {
background: linear-gradient(135deg, var(--color-accent) 0%, #a78bfa 50%, var(--color-accent-light) 100%);
background: linear-gradient(135deg, var(--color-accent) 0%, #c4b5fd 50%, var(--color-accent-light) 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
Expand Down
24 changes: 12 additions & 12 deletions components/feedback/feedback-widget.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,17 @@ export function FeedbackWidget({

const t = content[locale]

const handleClose = useCallback(() => {
setIsAnimating(false)
setTimeout(() => {
setIsOpen(false)
setSentiment(null)
setMessage('')
// Return focus to trigger button
triggerRef.current?.focus()
}, 300)
}, [])

// Focus trap handler
const handleKeyDown = useCallback(
(e: KeyboardEvent) => {
Expand Down Expand Up @@ -82,7 +93,7 @@ export function FeedbackWidget({
}
}
},
[isOpen]
[isOpen, handleClose]
)

// Add/remove keyboard listener
Expand All @@ -104,17 +115,6 @@ export function FeedbackWidget({
})
}

const handleClose = useCallback(() => {
setIsAnimating(false)
setTimeout(() => {
setIsOpen(false)
setSentiment(null)
setMessage('')
// Return focus to trigger button
triggerRef.current?.focus()
}, 300)
}, [])

const handleSubmit = () => {
if (!sentiment || !message.trim()) return

Expand Down
22 changes: 11 additions & 11 deletions components/feedback/nps-modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,16 @@ export function NPSModal({

const t = content[locale]

const handleDismiss = useCallback(() => {
setIsAnimating(false)
setTimeout(() => {
setIsOpen(false)
// Restore focus to trigger element
triggerRef.current?.focus()
}, 300)
onDismiss?.()
}, [onDismiss])

// Focus trap handler
const handleKeyDown = useCallback(
(e: KeyboardEvent) => {
Expand Down Expand Up @@ -91,7 +101,7 @@ export function NPSModal({
}
}
},
[isOpen]
[isOpen, handleDismiss]
)

// Add/remove keyboard listener
Expand Down Expand Up @@ -147,16 +157,6 @@ export function NPSModal({
}, 2000)
}

const handleDismiss = useCallback(() => {
setIsAnimating(false)
setTimeout(() => {
setIsOpen(false)
// Restore focus to trigger element
triggerRef.current?.focus()
}, 300)
onDismiss?.()
}, [onDismiss])

const getScoreColor = (score: number, isSelected: boolean) => {
if (!isSelected)
return 'bg-white/5 border-white/10 text-white/40 hover:bg-white/10 hover:text-white/60'
Expand Down
2 changes: 1 addition & 1 deletion components/ui/cookie-consent.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
'use client'

import { useState, useEffect } from 'react'
import { Cookie, X, Shield, ChevronRight } from 'lucide-react'
import { Cookie, Shield, ChevronRight } from 'lucide-react'
import { Link } from '@/i18n/navigation'

interface CookieConsentProps {
Expand Down
83 changes: 78 additions & 5 deletions components/wizard/icon-ai-tab.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
'use client'

import { useState } from 'react'
import { Sparkles } from 'lucide-react'
import { useMemo, useState } from 'react'
import { Sparkles, Wand2 } from 'lucide-react'
import Image from 'next/image'
import { useWizardStore } from '@/store/wizard-store'
import { Input } from '@/components/ui/input'
Expand All @@ -10,27 +10,76 @@ import { Button } from '@/components/ui/button'
import { PlanGate } from '@/components/shared/plan-gate'
import { UsageMeter } from '@/components/shared/usage-meter'
import { cn } from '@/lib/utils/cn'
import type { BrandProfile, Plan, User } from '@/types/database'
import type { BrandProfile, Plan, StylePreset, User } from '@/types/database'

interface IconAiTabProps {
plan: Plan
user: User
brandProfile: BrandProfile | null
stylePreset: StylePreset | null
}

interface GeneratedImage {
url: string
seed: number
}

export function IconAiTab({ plan, user, brandProfile }: IconAiTabProps) {
const { icon, setIcon } = useWizardStore()
function buildSuggestions(
projectName: string,
projectDescription: string,
platform: string,
brandProfile: BrandProfile | null,
stylePreset: StylePreset | null,
): string[] {
const suggestions: string[] = []

if (stylePreset && projectName) {
suggestions.push(`${stylePreset.name}-style icon for ${projectName}`)
}

if (brandProfile?.keywords?.length) {
const keyword = brandProfile.keywords[0]
const iconStyle = stylePreset?.icon_style || brandProfile.icon_style
suggestions.push(`${keyword} themed ${iconStyle ? iconStyle + ' ' : ''}icon`)
}

if (projectName && projectDescription) {
suggestions.push(
`modern ${platform} app icon for ${projectName}, ${projectDescription}`,
)
}

if (projectName && !stylePreset) {
suggestions.push(`minimalist icon for ${projectName}`)
}

if (suggestions.length === 0) {
suggestions.push('minimalist app icon, clean and modern')
}

return suggestions
}

export function IconAiTab({ plan, user, brandProfile, stylePreset }: IconAiTabProps) {
const { icon, setIcon, project, platform } = useWizardStore()
const [description, setDescription] = useState('')
const [isGenerating, setIsGenerating] = useState(false)
const [images, setImages] = useState<GeneratedImage[]>([])
const [error, setError] = useState<string | null>(null)
const [iconsUsed, setIconsUsed] = useState(user.ai_icons_used_this_month)

const suggestions = useMemo(
() =>
buildSuggestions(
project.name,
project.description,
platform.platform,
brandProfile,
stylePreset,
),
[project.name, project.description, platform.platform, brandProfile, stylePreset],
)

const iconsLimit = plan === 'pro' ? Infinity : 5
const isExhausted = iconsUsed >= iconsLimit

Expand All @@ -54,6 +103,7 @@ export function IconAiTab({ plan, user, brandProfile }: IconAiTabProps) {
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
description,
styleModifier: stylePreset?.ai_style_modifier ?? undefined,
brandProfile: brandProfile ? {
styleDirection: brandProfile.style_direction,
primaryColor: brandProfile.primary_color,
Expand Down Expand Up @@ -107,6 +157,29 @@ export function IconAiTab({ plan, user, brandProfile }: IconAiTabProps) {
onChange={(e) => setDescription(e.target.value)}
placeholder="e.g., minimalist checkmark icon for task app"
/>
{suggestions.length > 0 && (
<div className="flex flex-wrap items-center gap-2">
<span className="flex items-center gap-1 text-xs text-text-tertiary">
<Wand2 className="h-3 w-3" />
Suggested:
</span>
{suggestions.map((s, i) => (
<button
key={i}
type="button"
onClick={() => setDescription(s)}
className={cn(
'rounded-full border px-3 py-1 text-xs transition-colors',
description === s
? 'border-brand bg-brand/10 text-brand'
: 'border-border text-text-secondary hover:border-border-hover hover:bg-surface-secondary',
)}
>
{s}
</button>
))}
</div>
)}
</div>

<Button
Expand Down
7 changes: 4 additions & 3 deletions components/wizard/step-4-icon.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,16 @@ import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'
import { IconTextTab } from './icon-text-tab'
import { IconSymbolTab } from './icon-symbol-tab'
import { IconAiTab } from './icon-ai-tab'
import type { BrandProfile, Plan, User } from '@/types/database'
import type { BrandProfile, Plan, StylePreset, User } from '@/types/database'

interface Step4IconProps {
plan: Plan
user: User
brandProfile: BrandProfile | null
stylePreset: StylePreset | null
}

export function Step4Icon({ plan, user, brandProfile }: Step4IconProps) {
export function Step4Icon({ plan, user, brandProfile, stylePreset }: Step4IconProps) {
const [activeTab, setActiveTab] = useState('text')

return (
Expand All @@ -38,7 +39,7 @@ export function Step4Icon({ plan, user, brandProfile }: Step4IconProps) {
<IconSymbolTab />
</TabsContent>
<TabsContent value="ai">
<IconAiTab plan={plan} user={user} brandProfile={brandProfile} />
<IconAiTab plan={plan} user={user} brandProfile={brandProfile} stylePreset={stylePreset} />
</TabsContent>
</Tabs>
</div>
Expand Down
6 changes: 4 additions & 2 deletions components/wizard/wizard-shell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -123,8 +123,10 @@ export function WizardShell({ brandProfiles, stylePresets, user }: WizardShellPr
return <Step2ProjectInfo user={user} brandProfile={selectedProfile} />
case 3:
return <Step3Style presets={stylePresets} brandProfile={selectedProfile} plan={plan} />
case 4:
return <Step4Icon plan={plan} user={user} brandProfile={selectedProfile} />
case 4: {
const selectedPreset = stylePresets.find((p) => p.id === store.style.stylePresetId) ?? null
return <Step4Icon plan={plan} user={user} brandProfile={selectedProfile} stylePreset={selectedPreset} />
}
case 5:
return (
<Step5Preview
Expand Down
Loading
Loading