Persano commited on
Commit
5ca475a
·
verified ·
1 Parent(s): e7f5dda

Upload 25 files

Browse files
.env.local ADDED
@@ -0,0 +1 @@
 
 
1
+ GEMINI_API_KEY=PLACEHOLDER_API_KEY
.gitignore ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Logs
2
+ logs
3
+ *.log
4
+ npm-debug.log*
5
+ yarn-debug.log*
6
+ yarn-error.log*
7
+ pnpm-debug.log*
8
+ lerna-debug.log*
9
+
10
+ node_modules
11
+ dist
12
+ dist-ssr
13
+ *.local
14
+
15
+ # Editor directories and files
16
+ .vscode/*
17
+ !.vscode/extensions.json
18
+ .idea
19
+ .DS_Store
20
+ *.suo
21
+ *.ntvs*
22
+ *.njsproj
23
+ *.sln
24
+ *.sw?
App.tsx ADDED
@@ -0,0 +1,230 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useState, useCallback, useEffect } from 'react';
2
+ import type { Session } from '@supabase/gotrue-js';
3
+ import { Header } from '@/components/Header';
4
+ import { PromptForm } from '@/components/PromptForm'; // Reimagined as CreationPanel
5
+ import { SqlViewer } from '@/components/SqlViewer'; // Reimagined as PreviewCanvas
6
+ import { GuideModal } from '@/components/GuideModal';
7
+ import { generateImage, generateAdCopy, generateFeatureDescriptions } from '@/services/geminiService';
8
+ import { supabase } from '@/services/supabaseClient';
9
+ import type { GenerateOptions, AdCopy, BrandConcept, PriceData, FeatureDetails } from '@/types';
10
+ import { RateLimitError } from '@/lib/errors';
11
+ import { compositionPresets } from '@/lib/compositions';
12
+
13
+
14
+ const App: React.FC = () => {
15
+ const [session, setSession] = useState<Session | null>(null);
16
+ const [cooldownUntil, setCooldownUntil] = useState<Date | null>(null);
17
+
18
+ // Generation state
19
+ const [generatedImagesB64, setGeneratedImagesB64] = useState<string[] | null>(null);
20
+ const [isLoading, setIsLoading] = useState<boolean>(false);
21
+ const [error, setError] = useState<string | null>(null);
22
+
23
+ // Post content state (for display and marketing tools)
24
+ const [textOverlay, setTextOverlay] = useState<string>('');
25
+ const [compositionId, setCompositionId] = useState<string>('random');
26
+ const [textPosition, setTextPosition] = useState<GenerateOptions['textPosition']>('center');
27
+ const [subtitleOutline, setSubtitleOutline] = useState<GenerateOptions['subtitleOutline']>('auto');
28
+ const [artStylesForFont, setArtStylesForFont] = useState<string[]>([]);
29
+
30
+ // Context for marketing tools
31
+ const [currentBasePrompt, setCurrentBasePrompt] = useState<string>('');
32
+ const [currentTheme, setCurrentTheme] = useState<string>('');
33
+ const [brandData, setBrandData] = useState<GenerateOptions['brandData']>({ name: '', slogan: '', weight: 25 });
34
+ const [priceData, setPriceData] = useState<PriceData>({ text: '', modelText: '', style: 'circle', position: 'none', color: 'red'});
35
+ const [featureDetails, setFeatureDetails] = useState<FeatureDetails[] | null>(null);
36
+
37
+
38
+ // Marketing suite state
39
+ const [adCopy, setAdCopy] = useState<AdCopy | null>(null);
40
+ const [isAdCopyLoading, setIsAdCopyLoading] = useState<boolean>(false);
41
+ const [adCopyError, setAdCopyError] = useState<string | null>(null);
42
+
43
+ // Modal state
44
+ const [isGuideOpen, setIsGuideOpen] = useState(false);
45
+
46
+ const isAuthEnabled = !!supabase;
47
+
48
+ const triggerCooldown = useCallback((durationMs: number = 60000) => {
49
+ setCooldownUntil(new Date(Date.now() + durationMs));
50
+ }, []);
51
+
52
+ useEffect(() => {
53
+ if (supabase) {
54
+ // Supabase v2: onAuthStateChange handles the initial session check and any subsequent changes.
55
+ const { data: authListener } = supabase.auth.onAuthStateChange((_event, session) => {
56
+ setSession(session);
57
+ });
58
+
59
+ return () => {
60
+ // Cleanup subscription on component unmount
61
+ authListener.subscription?.unsubscribe();
62
+ };
63
+ }
64
+ }, []);
65
+
66
+ const handleLogin = async () => {
67
+ if (!supabase) {
68
+ setError("A funcionalidade de login está desabilitada pois a configuração do serviço está ausente.");
69
+ console.warn("Login attempt failed: Supabase client is not initialized.");
70
+ return;
71
+ }
72
+ // Supabase v2: use signInWithOAuth
73
+ const { error } = await supabase.auth.signInWithOAuth({ provider: 'google' });
74
+ if (error) {
75
+ console.error('Error logging in with Google:', error);
76
+ setError('Falha ao fazer login com o Google.');
77
+ }
78
+ };
79
+
80
+ const handleLogout = async () => {
81
+ if (supabase) {
82
+ await supabase.auth.signOut();
83
+ }
84
+ };
85
+
86
+ const handleGenerate = useCallback(async (options: GenerateOptions) => {
87
+ setIsLoading(true);
88
+ setError(null);
89
+ setGeneratedImagesB64(null);
90
+ setTextOverlay('');
91
+ setAdCopy(null); // Reset ads on new generation
92
+ setAdCopyError(null); // Also reset ad error
93
+ setFeatureDetails(null); // Reset feature details
94
+
95
+ // BUG FIX: Resolve 'random' composition ID to a specific one to prevent layout shifts on re-render.
96
+ let finalCompositionId = options.compositionId;
97
+ if (options.compositionId === 'random') {
98
+ const availablePresets = compositionPresets.filter(p => p.id !== 'random');
99
+ if (availablePresets.length > 0) {
100
+ const randomPreset = availablePresets[Math.floor(Math.random() * availablePresets.length)];
101
+ finalCompositionId = randomPreset.id;
102
+ } else {
103
+ finalCompositionId = 'impacto-light'; // Fallback if all presets are somehow filtered out
104
+ }
105
+ }
106
+
107
+ // Persist UI state from options
108
+ setCompositionId(finalCompositionId);
109
+ setTextPosition(options.textPosition);
110
+ setSubtitleOutline(options.subtitleOutline);
111
+ setArtStylesForFont(options.artStyles);
112
+ setCurrentBasePrompt(options.basePrompt);
113
+ setCurrentTheme(options.theme);
114
+ setBrandData(options.brandData);
115
+ setPriceData(options.priceData);
116
+
117
+ try {
118
+ if (!process.env.API_KEY) {
119
+ throw new Error("A variável de ambiente API_KEY não foi definida.");
120
+ }
121
+
122
+ if (options.scenario === 'isometric_details' && options.concept) {
123
+ // Special dual-call flow for detailed isometric view
124
+ const concept = options.concept;
125
+ setTextOverlay(options.textOverlay);
126
+
127
+ const [imageResults, descriptionResults] = await Promise.all([
128
+ generateImage(options.imagePrompt, options.negativeImagePrompt, 1),
129
+ generateFeatureDescriptions(options.basePrompt, concept)
130
+ ]);
131
+
132
+ setGeneratedImagesB64(imageResults);
133
+ setFeatureDetails(descriptionResults);
134
+
135
+ } else {
136
+ // Standard single-call flow
137
+ const safeNumberOfImages = Math.max(1, Math.min(options.numberOfImages, 4));
138
+ const imageResults = await generateImage(options.imagePrompt, options.negativeImagePrompt, safeNumberOfImages);
139
+ setGeneratedImagesB64(imageResults);
140
+ setTextOverlay(options.textOverlay);
141
+ }
142
+ } catch (e) {
143
+ if (e instanceof RateLimitError) {
144
+ triggerCooldown();
145
+ }
146
+ // The service now provides a complete, user-friendly message.
147
+ setError((e as Error).message);
148
+ console.error(e);
149
+ } finally {
150
+ setIsLoading(false);
151
+ }
152
+ }, [triggerCooldown]);
153
+
154
+ const handleGenerateAds = useCallback(async () => {
155
+ if (!currentBasePrompt || !textOverlay) return;
156
+
157
+ setIsAdCopyLoading(true);
158
+ setAdCopy(null);
159
+ setAdCopyError(null);
160
+ try {
161
+ const result = await generateAdCopy(currentBasePrompt, textOverlay, currentTheme, brandData);
162
+ setAdCopy(result);
163
+ } catch (e) {
164
+ if (e instanceof RateLimitError) {
165
+ triggerCooldown();
166
+ }
167
+ // The service now provides a complete, user-friendly message.
168
+ setAdCopyError((e as Error).message);
169
+ console.error("Failed to generate ad copy:", e);
170
+ } finally {
171
+ setIsAdCopyLoading(false);
172
+ }
173
+ }, [currentBasePrompt, textOverlay, currentTheme, brandData, triggerCooldown]);
174
+
175
+ return (
176
+ <div className="min-h-screen bg-gray-100 text-gray-800 font-sans flex flex-col">
177
+ <Header
178
+ session={session}
179
+ onLogin={handleLogin}
180
+ onLogout={handleLogout}
181
+ isAuthEnabled={isAuthEnabled}
182
+ onOpenGuide={() => setIsGuideOpen(true)}
183
+ />
184
+ <div className="flex-grow container mx-auto p-4 sm:p-6 md:p-8">
185
+ <main className="flex flex-col lg:flex-row gap-8 h-full">
186
+ <div className="lg:w-2/5 flex flex-col">
187
+ <PromptForm
188
+ onGenerate={handleGenerate}
189
+ isLoading={isLoading}
190
+ cooldownUntil={cooldownUntil}
191
+ onCooldown={triggerCooldown}
192
+ />
193
+ </div>
194
+ <div className="lg:w-3/5 flex flex-col">
195
+ <SqlViewer
196
+ imagesB64={generatedImagesB64}
197
+ textOverlay={textOverlay}
198
+ compositionId={compositionId}
199
+ textPosition={textPosition}
200
+ subtitleOutline={subtitleOutline}
201
+ artStyles={artStylesForFont}
202
+ isLoading={isLoading}
203
+ error={error}
204
+ adCopy={adCopy}
205
+ isAdCopyLoading={isAdCopyLoading}
206
+ adCopyError={adCopyError}
207
+ onGenerateAds={handleGenerateAds}
208
+ brandData={brandData}
209
+ priceData={priceData}
210
+ featureDetails={featureDetails}
211
+ // Setters for editing
212
+ setTextOverlay={setTextOverlay}
213
+ setCompositionId={setCompositionId}
214
+ setTextPosition={setTextPosition}
215
+ setSubtitleOutline={setSubtitleOutline}
216
+ setPriceData={setPriceData}
217
+ setFeatureDetails={setFeatureDetails}
218
+ />
219
+ </div>
220
+ </main>
221
+ </div>
222
+ <footer className="text-center p-4 text-gray-500 border-t border-gray-200">
223
+ <p>Powered by Gemini, React, and Supabase</p>
224
+ </footer>
225
+ <GuideModal isOpen={isGuideOpen} onClose={() => setIsGuideOpen(false)} />
226
+ </div>
227
+ );
228
+ };
229
+
230
+ export default App;
README.md CHANGED
@@ -1,10 +1,14 @@
1
- ---
2
- title: Instasaas
3
- emoji: 🦀
4
- colorFrom: pink
5
- colorTo: green
6
- sdk: docker
7
- pinned: false
8
- ---
9
-
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
1
+ # Run and deploy your AI Studio app
2
+
3
+ This contains everything you need to run your app locally.
4
+
5
+ ## Run Locally
6
+
7
+ **Prerequisites:** Node.js
8
+
9
+
10
+ 1. Install dependencies:
11
+ `npm install`
12
+ 2. Set the `GEMINI_API_KEY` in [.env.local](.env.local) to your Gemini API key
13
+ 3. Run the app:
14
+ `npm run dev`
components/GuideModal.tsx ADDED
@@ -0,0 +1,102 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useState } from 'react';
2
+ import { ClipboardIcon, CheckIcon, BookOpenIcon } from '@/components/icons';
3
+ import { masterPromptText } from '@/lib/styles';
4
+
5
+ interface GuideModalProps {
6
+ isOpen: boolean;
7
+ onClose: () => void;
8
+ }
9
+
10
+ export const GuideModal: React.FC<GuideModalProps> = ({ isOpen, onClose }) => {
11
+ const [copied, setCopied] = useState(false);
12
+
13
+ const handleCopy = () => {
14
+ navigator.clipboard.writeText(masterPromptText);
15
+ setCopied(true);
16
+ setTimeout(() => setCopied(false), 2000);
17
+ };
18
+
19
+ if (!isOpen) return null;
20
+
21
+ return (
22
+ <div
23
+ className="fixed inset-0 bg-black/60 backdrop-blur-sm z-50 flex items-center justify-center p-4"
24
+ onClick={onClose}
25
+ role="dialog"
26
+ aria-modal="true"
27
+ aria-labelledby="guide-title"
28
+ >
29
+ <div
30
+ className="bg-white rounded-xl shadow-2xl w-full max-w-4xl max-h-[90vh] flex flex-col"
31
+ onClick={(e) => e.stopPropagation()}
32
+ >
33
+ <header className="flex items-center justify-between p-4 border-b border-gray-200 sticky top-0 bg-white rounded-t-xl z-10">
34
+ <div className="flex items-center gap-3">
35
+ <BookOpenIcon className="w-6 h-6 text-purple-600"/>
36
+ <h2 id="guide-title" className="text-xl font-bold text-gray-800">
37
+ Guia de Prototipagem no AI Studio
38
+ </h2>
39
+ </div>
40
+ <button
41
+ onClick={onClose}
42
+ className="p-1 rounded-full text-gray-400 hover:bg-gray-200 hover:text-gray-600 focus:outline-none focus:ring-2 focus:ring-purple-500"
43
+ aria-label="Fechar"
44
+ >
45
+ <svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M6 18L18 6M6 6l12 12"></path></svg>
46
+ </button>
47
+ </header>
48
+
49
+ <main className="p-6 text-gray-600 overflow-y-auto space-y-6">
50
+ <div>
51
+ <h3 className="text-lg font-semibold text-gray-800 mb-2">Instruções</h3>
52
+ <ol className="list-decimal list-inside space-y-2">
53
+ <li>Acesse o Google AI Studio em <a href="https://aistudio.google.com" target="_blank" rel="noopener noreferrer" className="text-purple-600 hover:underline font-medium">aistudio.google.com</a>.</li>
54
+ <li>No menu, crie um novo "Freeform prompt".</li>
55
+ <li>Copie o "Prompt Mestre" abaixo e cole na área de texto principal.</li>
56
+ <li>Na área de chat, inicie a conversa com sua ideia para o post.</li>
57
+ <li>Analise o resultado: a IA irá gerar a imagem e o conteúdo em JSON.</li>
58
+ </ol>
59
+ </div>
60
+
61
+ <div>
62
+ <div className="flex justify-between items-center mb-2">
63
+ <h3 className="text-lg font-semibold text-gray-800">📝 Prompt Mestre para o AI Studio</h3>
64
+ <button
65
+ onClick={handleCopy}
66
+ className="flex items-center gap-2 px-3 py-1.5 text-sm font-medium bg-gray-100 text-gray-700 rounded-md border border-gray-300 hover:bg-gray-200 transition-colors"
67
+ >
68
+ {copied ? (
69
+ <>
70
+ <CheckIcon className="w-4 h-4 text-green-600"/>
71
+ <span>Copiado!</span>
72
+ </>
73
+ ) : (
74
+ <>
75
+ <ClipboardIcon className="w-4 h-4"/>
76
+ <span>Copiar</span>
77
+ </>
78
+ )}
79
+ </button>
80
+ </div>
81
+ <pre className="bg-gray-50 p-4 rounded-lg border border-gray-200 text-sm text-gray-800 whitespace-pre-wrap break-words max-h-60 overflow-y-auto">
82
+ <code>{masterPromptText}</code>
83
+ </pre>
84
+ </div>
85
+
86
+ <div>
87
+ <h3 className="text-lg font-semibold text-gray-800 mb-2">🚀 Exemplo de Uso</h3>
88
+ <p className="mb-2">Depois de colar o prompt, use o campo de chat para enviar sua solicitação. Por exemplo:</p>
89
+ <pre className="bg-gray-900 text-white p-4 rounded-lg text-sm whitespace-pre-wrap break-words">
90
+ <code>
91
+ Vamos criar um post.<br/><br/>
92
+ - <b>Descrição da Imagem:</b> "Um gato estiloso usando óculos de sol e uma jaqueta de couro, sentado em um café em Paris."<br/>
93
+ - <b>Estilos da Imagem:</b> "Fotorrealista (70%), Cinematico (30%)"<br/>
94
+ - <b>Estilo do Conteúdo:</b> "Descolado e Engraçado"
95
+ </code>
96
+ </pre>
97
+ </div>
98
+ </main>
99
+ </div>
100
+ </div>
101
+ );
102
+ };
components/Header.tsx ADDED
@@ -0,0 +1,89 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from 'react';
2
+ import type { Session } from '@supabase/gotrue-js';
3
+ import { LogoIcon, GoogleIcon, LogoutIcon, BookOpenIcon } from '@/components/icons';
4
+
5
+ interface HeaderProps {
6
+ session: Session | null;
7
+ onLogin: () => void;
8
+ onLogout: () => void;
9
+ isAuthEnabled: boolean;
10
+ onOpenGuide: () => void;
11
+ }
12
+
13
+ export const Header: React.FC<HeaderProps> = ({ session, onLogin, onLogout, isAuthEnabled, onOpenGuide }) => {
14
+ const userName = session?.user?.user_metadata?.full_name || session?.user?.email;
15
+
16
+ const renderAuthButton = () => {
17
+ if (!isAuthEnabled) {
18
+ return (
19
+ <div className="relative group">
20
+ <button
21
+ disabled
22
+ className="flex items-center justify-center gap-2 px-4 py-2 bg-gray-200 text-gray-500 font-medium rounded-lg border border-gray-300 cursor-not-allowed"
23
+ aria-label="Login indisponível"
24
+ >
25
+ <GoogleIcon className="w-5 h-5" />
26
+ <span>Login Indisponível</span>
27
+ </button>
28
+ <div className="absolute top-full left-1/2 -translate-x-1/2 mt-2 w-max px-2 py-1 bg-gray-800 text-white text-xs rounded-md opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none">
29
+ Configuração ausente
30
+ </div>
31
+ </div>
32
+ );
33
+ }
34
+
35
+ if (session) {
36
+ return (
37
+ <>
38
+ <span className="text-sm text-gray-600 hidden sm:block">Olá, {userName}</span>
39
+ <button
40
+ onClick={onLogout}
41
+ className="flex items-center gap-2 p-2 text-sm font-medium text-gray-600 hover:text-purple-600 transition-colors rounded-md"
42
+ aria-label="Logout"
43
+ >
44
+ <LogoutIcon className="w-5 h-5" />
45
+ <span className="hidden sm:inline">Sair</span>
46
+ </button>
47
+ </>
48
+ );
49
+ }
50
+
51
+ return (
52
+ <button
53
+ onClick={onLogin}
54
+ className="flex items-center justify-center gap-2 px-4 py-2 bg-white text-gray-700 font-medium rounded-lg border border-gray-300 hover:bg-gray-50 transition-colors duration-200 shadow-sm"
55
+ aria-label="Login com Google"
56
+ >
57
+ <GoogleIcon className="w-5 h-5" />
58
+ <span>Login com Google</span>
59
+ </button>
60
+ );
61
+ };
62
+
63
+ return (
64
+ <header className="bg-white/80 backdrop-blur-sm sticky top-0 z-20 border-b border-gray-200">
65
+ <div className="container mx-auto px-4 sm:px-6 md:px-8">
66
+ <div className="flex items-center justify-between h-16">
67
+ <div className="flex items-center gap-3">
68
+ <LogoIcon className="w-8 h-8 text-purple-600" />
69
+ <h1 className="text-xl md:text-2xl font-bold text-gray-800 tracking-tight">
70
+ Insta<span className="text-purple-600">Style</span>
71
+ </h1>
72
+ </div>
73
+ <div className="flex items-center gap-4">
74
+ <button
75
+ onClick={onOpenGuide}
76
+ className="flex items-center gap-2 p-2 text-sm font-medium text-gray-600 hover:text-purple-600 transition-colors rounded-md"
77
+ aria-label="Abrir Guia de Prototipagem"
78
+ >
79
+ <BookOpenIcon className="w-5 h-5"/>
80
+ <span className="hidden sm:inline">Guia</span>
81
+ </button>
82
+ <div className="h-6 w-px bg-gray-200" aria-hidden="true"></div>
83
+ {renderAuthButton()}
84
+ </div>
85
+ </div>
86
+ </div>
87
+ </header>
88
+ );
89
+ };
components/HistorySidebar.tsx ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ import React from 'react';
2
+
3
+ // This component is a legacy artifact and is not used in the current application.
4
+ // Its content has been cleared to resolve a build error and remove dead code.
5
+
6
+ export const HistorySidebar: React.FC = () => {
7
+ return null;
8
+ };
components/PromptForm.tsx ADDED
@@ -0,0 +1,610 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useState, useMemo, useEffect } from 'react';
2
+ import { SparklesIcon, LoaderIcon, PlusIcon, XIcon, MegaphoneIcon, ChevronLeftIcon, ChevronRightIcon, AlertTriangleIcon, ProductIcon, UsersIcon, FamilyIcon, LayersIcon, DetailedViewIcon, PosterIcon, BlueprintIcon } from '@/components/icons';
3
+ import { artStyles, professionalThemes } from '@/lib/styles';
4
+ import { generateProductConcepts, analyzeAdTrends, generateSlogan, generateDesignConcepts } from '@/services/geminiService';
5
+ import type { BrandConcept, MixedStyle, RegionalityData, TextPosition, AdTrendAnalysis, SubtitleOutlineStyle, BrandData, PriceData, PriceTagStyleId, PriceTagPosition, PriceTagColor, GenerateOptions } from '@/types';
6
+ import { RateLimitError } from '@/lib/errors';
7
+
8
+ interface CreationPanelProps {
9
+ onGenerate: (options: GenerateOptions) => void;
10
+ isLoading: boolean;
11
+ cooldownUntil: Date | null;
12
+ onCooldown: () => void;
13
+ }
14
+
15
+ const rebalancePercentages = (styles: Omit<MixedStyle, 'percentage'>[]): MixedStyle[] => {
16
+ const count = styles.length;
17
+ if (count === 0) return [];
18
+
19
+ const basePercentage = Math.floor(100 / count);
20
+ let remainder = 100 % count;
21
+
22
+ return styles.map((style, i) => {
23
+ const percentage = basePercentage + (remainder > 0 ? 1 : 0);
24
+ if(remainder > 0) remainder--;
25
+ return { ...style, percentage };
26
+ });
27
+ };
28
+
29
+ export const PromptForm = ({ onGenerate, isLoading, cooldownUntil, onCooldown }: CreationPanelProps): React.JSX.Element => {
30
+ const [basePrompt, setBasePrompt] = useState<string>('');
31
+ const [textOverlay, setTextOverlay] = useState<string>('');
32
+ const [mixedStyles, setMixedStyles] = useState<MixedStyle[]>([]);
33
+ const [styleToAdd, setStyleToAdd] = useState<string>(artStyles[0]);
34
+
35
+ const [regionality, setRegionality] = useState<RegionalityData>({
36
+ country: '',
37
+ city: '',
38
+ neighborhood: '',
39
+ weight: 25,
40
+ });
41
+
42
+ const [brandName, setBrandName] = useState('');
43
+ const [brandSlogan, setBrandSlogan] = useState('');
44
+ const [brandWeight, setBrandWeight] = useState(25);
45
+ const [isSloganLoading, setIsSloganLoading] = useState(false);
46
+ const [sloganError, setSloganError] = useState<string | null>(null);
47
+
48
+ // Price Tag State
49
+ const [priceText, setPriceText] = useState('');
50
+ const [priceModelText, setPriceModelText] = useState('');
51
+ const [priceStyle, setPriceStyle] = useState<PriceTagStyleId>('circle');
52
+ const [pricePosition, setPricePosition] = useState<PriceTagPosition>('none');
53
+ const [priceColor, setPriceColor] = useState<PriceTagColor>('red');
54
+
55
+ const [selectedTheme, setSelectedTheme] = useState<string>(professionalThemes[0]);
56
+
57
+ // Ad Trend Analysis State
58
+ const [adTrendAnalysis, setAdTrendAnalysis] = useState<AdTrendAnalysis | null>(null);
59
+ const [isAdTrendLoading, setIsAdTrendLoading] = useState(false);
60
+ const [adTrendError, setAdTrendError] = useState<string | null>(null);
61
+
62
+ // New Brand Concept State
63
+ const [brandConcepts, setBrandConcepts] = useState<BrandConcept[] | null>(null);
64
+ const [isConceptLoading, setIsConceptLoading] = useState<boolean>(false);
65
+ const [conceptError, setConceptError] = useState<string | null>(null);
66
+ const [carouselOptionsVisible, setCarouselOptionsVisible] = useState<{ [key: number]: boolean }>({});
67
+
68
+ // Cooldown state
69
+ const [countdown, setCountdown] = useState(0);
70
+
71
+ const isInteractionDisabled = isLoading || countdown > 0 || isAdTrendLoading || isSloganLoading || isConceptLoading;
72
+
73
+ const isProductConceptTheme = useMemo(() => ['Nova Marca de:', 'Nova Loja de:'].some(keyword => selectedTheme.startsWith(keyword)), [selectedTheme]);
74
+ const isDesignConceptTheme = useMemo(() => selectedTheme.startsWith('Design de Interiores'), [selectedTheme]);
75
+ const isConceptGeneratorVisible = isProductConceptTheme || isDesignConceptTheme;
76
+
77
+
78
+ useEffect(() => {
79
+ if (!cooldownUntil) {
80
+ setCountdown(0);
81
+ return;
82
+ }
83
+
84
+ const intervalId = setInterval(() => {
85
+ const now = Date.now();
86
+ const remaining = Math.ceil((cooldownUntil.getTime() - now) / 1000);
87
+ if (remaining > 0) {
88
+ setCountdown(remaining);
89
+ } else {
90
+ setCountdown(0);
91
+ clearInterval(intervalId);
92
+ }
93
+ }, 1000);
94
+
95
+ // Set initial value
96
+ const now = Date.now();
97
+ const remaining = Math.ceil((cooldownUntil.getTime() - now) / 1000);
98
+ setCountdown(remaining > 0 ? remaining : 0);
99
+
100
+ return () => clearInterval(intervalId);
101
+ }, [cooldownUntil]);
102
+
103
+ const handleAnalyzeTrends = async () => {
104
+ if (!selectedTheme.trim() || isInteractionDisabled) return;
105
+
106
+ setIsAdTrendLoading(true);
107
+ setAdTrendError(null);
108
+ setAdTrendAnalysis(null);
109
+ try {
110
+ const currentBrandData: BrandData = { name: brandName, slogan: brandSlogan, weight: brandWeight };
111
+ const result = await analyzeAdTrends(selectedTheme, regionality, currentBrandData);
112
+ setAdTrendAnalysis(result);
113
+ } catch (e) {
114
+ if (e instanceof RateLimitError) {
115
+ onCooldown();
116
+ }
117
+ setAdTrendError((e as Error).message);
118
+ } finally {
119
+ setIsAdTrendLoading(false);
120
+ }
121
+ };
122
+
123
+ const handleGenerateSlogan = async () => {
124
+ if (!brandName.trim() || !selectedTheme.trim() || isInteractionDisabled) {
125
+ setSloganError("Preencha o nome da marca e selecione um tema profissional.");
126
+ return;
127
+ }
128
+ setIsSloganLoading(true);
129
+ setSloganError(null);
130
+ try {
131
+ const result = await generateSlogan(brandName, selectedTheme);
132
+ setBrandSlogan(result.slogan);
133
+ } catch (e) {
134
+ if (e instanceof RateLimitError) {
135
+ onCooldown();
136
+ }
137
+ setSloganError((e as Error).message);
138
+ } finally {
139
+ setIsSloganLoading(false);
140
+ }
141
+ };
142
+
143
+ const availableStyles = useMemo(() => {
144
+ const selectedNames = new Set(mixedStyles.map(s => s.name));
145
+ return artStyles.filter(s => !selectedNames.has(s));
146
+ }, [mixedStyles]);
147
+
148
+ const handleAddStyle = () => {
149
+ if (styleToAdd && mixedStyles.length < 3 && !mixedStyles.some(s => s.name === styleToAdd)) {
150
+ const newStyles = [...mixedStyles, { name: styleToAdd, percentage: 0 }];
151
+ setMixedStyles(rebalancePercentages(newStyles));
152
+ if(availableStyles.length > 1) {
153
+ const nextStyle = availableStyles.find(s => s !== styleToAdd) || '';
154
+ setStyleToAdd(nextStyle);
155
+ } else {
156
+ setStyleToAdd('');
157
+ }
158
+ }
159
+ };
160
+
161
+ const handleRemoveStyle = (indexToRemove: number) => {
162
+ const newStyles = mixedStyles.filter((_, i) => i !== indexToRemove);
163
+ setMixedStyles(rebalancePercentages(newStyles));
164
+ };
165
+
166
+ const handleSliderChange = (indexToUpdate: number, newPercentageValue: number) => {
167
+ let styles = [...mixedStyles];
168
+ if (styles.length <= 1) {
169
+ setMixedStyles([{...styles[0], percentage: 100}]);
170
+ return;
171
+ }
172
+
173
+ const oldValue = styles[indexToUpdate].percentage;
174
+
175
+ styles[indexToUpdate].percentage = newPercentageValue;
176
+
177
+ let otherTotal = 100 - oldValue;
178
+ let newOtherTotal = 100 - newPercentageValue;
179
+
180
+ if (otherTotal > 0) {
181
+ for(let i = 0; i < styles.length; i++) {
182
+ if (i !== indexToUpdate) {
183
+ styles[i].percentage = styles[i].percentage * (newOtherTotal / otherTotal);
184
+ }
185
+ }
186
+ } else {
187
+ const share = newOtherTotal / (styles.length - 1);
188
+ for(let i = 0; i < styles.length; i++) {
189
+ if (i !== indexToUpdate) {
190
+ styles[i].percentage = share;
191
+ }
192
+ }
193
+ }
194
+
195
+ let finalStyles = styles.map(s => ({...s, percentage: Math.round(s.percentage)}));
196
+ let roundedTotal = finalStyles.reduce((sum, s) => sum + s.percentage, 0);
197
+
198
+ let diffToDistribute = 100 - roundedTotal;
199
+ if(diffToDistribute !== 0 && finalStyles.length > 0) {
200
+ // Distribute difference to the largest slice that is not the one being updated
201
+ let targetIndex = -1;
202
+ let maxPercent = -1;
203
+ for (let i = 0; i < finalStyles.length; i++) {
204
+ if (i !== indexToUpdate && finalStyles[i].percentage > maxPercent) {
205
+ maxPercent = finalStyles[i].percentage;
206
+ targetIndex = i;
207
+ }
208
+ }
209
+ if(targetIndex !== -1) finalStyles[targetIndex].percentage += diffToDistribute;
210
+ else finalStyles[0].percentage += diffToDistribute;
211
+ }
212
+
213
+ setMixedStyles(finalStyles);
214
+ };
215
+
216
+ const handleRegionalityChange = (e: React.ChangeEvent<HTMLInputElement>) => {
217
+ const { name, value } = e.target;
218
+ setRegionality(prev => ({
219
+ ...prev,
220
+ [name]: name === 'weight' ? parseInt(value, 10) : value,
221
+ }));
222
+ };
223
+
224
+ const handleUseAdConcept = (headline: string, primaryText: string) => {
225
+ const firstLineOfPrimary = primaryText.split('\n')[0] || '';
226
+ setTextOverlay(`${headline}\n${firstLineOfPrimary}`);
227
+ }
228
+
229
+ const handleGenerateConcepts = async () => {
230
+ if (!basePrompt.trim() || isInteractionDisabled) return;
231
+ setIsConceptLoading(true);
232
+ setConceptError(null);
233
+ setBrandConcepts(null);
234
+ try {
235
+ let concepts;
236
+ if (isProductConceptTheme) {
237
+ const productType = selectedTheme.split(': ').pop()?.split('(')[0].trim() || 'produto';
238
+ concepts = await generateProductConcepts(basePrompt, productType);
239
+ } else if (isDesignConceptTheme) {
240
+ const designType = selectedTheme.split(': ').pop() || '';
241
+ concepts = await generateDesignConcepts(basePrompt, designType);
242
+ } else {
243
+ return;
244
+ }
245
+ setBrandConcepts(concepts);
246
+ setCarouselOptionsVisible({});
247
+ } catch (e) {
248
+ if (e instanceof RateLimitError) {
249
+ onCooldown();
250
+ }
251
+ setConceptError((e as Error).message);
252
+ console.error(e);
253
+ } finally {
254
+ setIsConceptLoading(false);
255
+ }
256
+ };
257
+
258
+ const handleToggleCarouselOptions = (conceptIndex: number) => {
259
+ setCarouselOptionsVisible(prev => ({...prev, [conceptIndex]: !prev[conceptIndex]}));
260
+ };
261
+
262
+ const handleGenerateFromConcept = (concept: BrandConcept, scenario: 'product' | 'couple' | 'family' | 'isometric_details' | 'poster' | 'executive_project') => {
263
+ const styleKeywords = mixedStyles.map(s => `${s.name} (${s.percentage}%)`);
264
+ let finalPrompt = '';
265
+ let textForOverlay = `${concept.name}\n${concept.philosophy}`;
266
+ let negativePrompt = '';
267
+
268
+ if (isProductConceptTheme) {
269
+ const productType = selectedTheme.split(': ').pop()?.split('(')[0].trim() || 'produto';
270
+ const baseConceptPrompt = `**Conceito do Produto (${productType}):**\n- **Nome:** ${concept.name}\n- **Filosofia:** "${concept.philosophy}"\n- **Design:** ${concept.visualStyle}\n- **Estilos Adicionais:** ${styleKeywords.join(', ')}.`;
271
+ negativePrompt = "logotipos, logos, marcas comerciais, texto, palavras, imitação, plágio";
272
+
273
+ switch(scenario) {
274
+ case 'product':
275
+ finalPrompt = `Fotografia de produto de alta qualidade para e-commerce. ${baseConceptPrompt}\n**Diretivas Visuais:** Foco absoluto no produto (${productType}) isolado, em um fundo neutro de estúdio (branco ou cinza claro). Iluminação profissional que realça texturas e a forma do produto. Ângulo de 3/4. Imagem limpa e premium para catálogo.`;
276
+ textForOverlay = `${concept.name}`;
277
+ break;
278
+ case 'couple':
279
+ finalPrompt = `Fotografia de lifestyle com um casal estiloso. ${baseConceptPrompt}\n**Diretivas Visuais:** Um casal jovem interagindo autenticamente em um ambiente urbano. **O produto (${productType}) DEVE estar em evidência**, sendo usado ou interagindo com ele de forma natural. A composição guia o olhar para o produto. Estética natural, momento espontâneo.`;
280
+ break;
281
+ case 'family':
282
+ finalPrompt = `Fotografia de lifestyle com uma família moderna. ${baseConceptPrompt}\n**Diretivas Visuais:** Uma família feliz em um momento de lazer (parque, passeio). **O produto (${productType}) DEVE estar em evidência**, sendo usado por um ou mais membros, mostrando conforto e estilo para o dia a dia. Estética vibrante, clara e cheia de vida. Posicionar o produto como a escolha da família.`;
283
+ textForOverlay = `${concept.name}\nPara toda a família`;
284
+ break;
285
+ case 'isometric_details':
286
+ finalPrompt = `Criação de arte técnica e de marketing de alta qualidade. ${baseConceptPrompt}\n**Diretivas Visuais:** Gere UMA ÚNICA imagem no formato de um diagrama isométrico que mostra o produto (${productType}) de forma detalhada. A imagem NÃO deve ter texto. Em vez de texto, use 4 SETAS ou LINHAS DE CHAMADA (callouts) que apontam de características importantes do produto para os 4 cantos da imagem (canto superior esquerdo, superior direito, inferior esquerdo, inferior direito), deixando essas áreas livres para anotações posteriores. A estética deve ser limpa, técnica, mas estilizada para se alinhar ao conceito da marca. Fundo branco ou de cor neutra.`;
287
+ textForOverlay = concept.name;
288
+ negativePrompt = "pessoas, paisagens, cenários complexos, desordem, texto, palavras, logos, marcas comerciais, plágio";
289
+ break;
290
+ case 'poster':
291
+ finalPrompt = `Criação de um cartaz de marketing ou mood board de alta qualidade. ${baseConceptPrompt}\n**Diretivas Visuais:** Gere UMA ÚNICA imagem que seja um pôster ou cartaz. O layout deve ser uma composição limpa e moderna com MÚLTIPLAS imagens de lifestyle menores no estilo "photo dump" ou "trend", mostrando o produto (${productType}) em diferentes contextos autênticos. A estética deve ser de revista de design, com espaço negativo para texto.`;
292
+ textForOverlay = `${concept.name}`;
293
+ negativePrompt = "imagem única, uma foto só, desordem, texto, palavras, logos";
294
+ break;
295
+ case 'executive_project':
296
+ finalPrompt = `Criação de uma folha de projeto técnico (blueprint) de alta qualidade. ${baseConceptPrompt}\n**Diretivas Visuais:** Gere UMA ÚNICA imagem que funciona como uma folha de desenho técnico. O layout deve conter as seguintes vistas do produto (${productType}): uma VISTA ISOMÉTRICA grande e proeminente, e quatro vistas ortográficas menores e alinhadas: VISTA DE TOPO, VISTA FRONTAL, VISTA LATERAL e VISTA DE COSTAS. A estética deve ser limpa, minimalista e profissional, como um desenho de engenharia ou patente, com linhas finas e precisas sobre um fundo branco. A imagem deve ser totalmente livre de textos, números ou dimensões.`;
297
+ textForOverlay = concept.name;
298
+ negativePrompt = "texto, palavras, números, dimensões, logos, marcas, pessoas, paisagens, cenários complexos, desordem, cores vibrantes, sombras, fotorrealismo";
299
+ break;
300
+ }
301
+ } else { // Interior Design Theme
302
+ const designType = selectedTheme.split(': ').pop() || '';
303
+ const baseConceptPrompt = `**Conceito de Design para ${designType}:**\n- **Nome:** ${concept.name}\n- **Filosofia:** "${concept.philosophy}"\n- **Estilo Visual e Materiais:** ${concept.visualStyle}\n- **Estilos Adicionais:** ${styleKeywords.join(', ')}.`;
304
+ negativePrompt = "desordem, bagunça, má qualidade de renderização, deformado, irrealista, feio, desfocado";
305
+
306
+ switch(scenario) {
307
+ case 'product':
308
+ finalPrompt = `Renderização 3D fotorrealista e cinematográfica de um(a) ${designType} com base no conceito a seguir. ${baseConceptPrompt}\n**Diretivas Visuais:** Foco absoluto no móvel/ambiente. Apresentar em um cenário de estúdio minimalista ou com um fundo sutil que complemente o design. Iluminação profissional que destaca materiais, texturas e formas. Qualidade de imagem de revista de arquitetura de luxo.`;
309
+ textForOverlay = `${concept.name}`;
310
+ break;
311
+ case 'couple':
312
+ finalPrompt = `Fotografia de lifestyle fotorrealista. ${baseConceptPrompt}\n**Diretivas Visuais:** Um casal interagindo de forma autêntica e elegante no ambiente projetado (${designType}). Ex: cozinhando juntos, relaxando na sala. A arquitetura e o design do mobiliário são o pano de fundo aspiracional. A atmosfera é de conforto, sofisticação e felicidade. Luz natural invasora.`;
313
+ break;
314
+ case 'family':
315
+ finalPrompt = `Fotografia de lifestyle fotorrealista e calorosa. ${baseConceptPrompt}\n**Diretivas Visuais:** Uma família interagindo em um momento feliz e descontraído no ambiente projetado (${designType}). Ex: pais e filhos lendo na sala, preparando uma refeição na cozinha. A cena deve transmitir a funcionalidade e a beleza do espaço no dia a dia. O design serve como um lar acolhedor.`;
316
+ textForOverlay = `${concept.name}\nPara toda a família`;
317
+ break;
318
+ case 'isometric_details':
319
+ finalPrompt = `Renderização 3D fotorrealista técnica de um(a) ${designType}. ${baseConceptPrompt}\n**Diretivas Visuais:** Gere UMA ÚNICA imagem no formato de uma planta isométrica ou vista de corte (cutaway view) do ambiente (${designType}). A imagem NÃO deve ter texto. Em vez de texto, use 4 SETAS ou LINHAS DE CHAMADA (callouts) para destacar 4 áreas ou detalhes de design chave (ex: um móvel, fluxo de layout, um material) para os 4 cantos da imagem. A estética deve ser limpa e informativa, como de uma revista de arquitetura.`;
320
+ textForOverlay = concept.name;
321
+ negativePrompt = "desordem, má qualidade de renderização, deformado, irrealista, feio, desfocado, texto, palavras";
322
+ break;
323
+ case 'poster':
324
+ finalPrompt = `Criação de um cartaz de marketing ou mood board de design de interiores. ${baseConceptPrompt}\n**Diretivas Visuais:** Gere UMA ÚNICA imagem que seja um pôster ou um mood board. O layout deve conter MÚLTIPLAS imagens menores mostrando diferentes ângulos, detalhes e texturas do ambiente (${designType}). A composição deve ser limpa, profissional, como em uma revista de arquitetura, com espaço negativo para texto.`;
325
+ textForOverlay = `${concept.name}`;
326
+ negativePrompt = "uma foto só, desordem, texto, palavras";
327
+ break;
328
+ case 'executive_project':
329
+ finalPrompt = `Criação de uma folha de projeto de arquitetura de alta qualidade. ${baseConceptPrompt}\n**Diretivas Visuais:** Gere UMA ÚNICA imagem que funciona como uma prancha de apresentação de arquitetura. O layout deve conter as seguintes vistas do ambiente (${designType}): uma VISTA ISOMÉTRICA grande e renderizada de forma limpa, e quatro vistas técnicas menores e alinhadas: PLANTA BAIXA, ELEVAÇÃO FRONTAL, ELEVAÇÃO LATERAL e uma VISTA DE CORTE (cross-section) revelando o interior. A estética deve ser de uma revista de arquitetura de ponta: minimalista, profissional, com linhas finas e precisas sobre um fundo branco. A imagem deve ser totalmente livre de textos, cotas ou anota��ões.`;
330
+ textForOverlay = concept.name;
331
+ negativePrompt = "texto, palavras, cotas, números, anotações, logos, pessoas, desordem, bagunça, cores excessivas, renderização de má qualidade";
332
+ break;
333
+ }
334
+ }
335
+
336
+ const options: GenerateOptions = {
337
+ basePrompt,
338
+ imagePrompt: finalPrompt,
339
+ textOverlay: textForOverlay,
340
+ compositionId: 'impacto-light',
341
+ textPosition: scenario === 'isometric_details' ? 'top-right' : 'center',
342
+ subtitleOutline: 'auto',
343
+ artStyles: styleKeywords,
344
+ theme: selectedTheme,
345
+ brandData: { name: brandName, slogan: brandSlogan, weight: brandWeight },
346
+ priceData: { text: priceText, modelText: priceModelText, style: priceStyle, position: pricePosition, color: priceColor },
347
+ negativeImagePrompt: negativePrompt,
348
+ numberOfImages: 1,
349
+ scenario,
350
+ concept
351
+ };
352
+ onGenerate(options);
353
+ };
354
+
355
+ const handleGenerateCarousel = (concept: BrandConcept, type: 'cta' | 'educational' | 'trend') => {
356
+ const styleKeywords = mixedStyles.map(s => `${s.name} (${s.percentage}%)`);
357
+ let finalPrompt = '';
358
+ const numberOfImagesToGenerate = 4; // API LIMIT: Max is 4
359
+ let negativePrompt = '';
360
+ let scenario: GenerateOptions['scenario'];
361
+
362
+ if (isProductConceptTheme) {
363
+ const productType = selectedTheme.split(': ').pop()?.split('(')[0].trim() || 'produto';
364
+ const baseConceptPrompt = `**Conceito Base do Produto (${productType}):**\n- **Nome:** ${concept.name}\n- **Filosofia:** "${concept.philosophy}"\n- **Design:** ${concept.visualStyle}\n- **Estilos Adicionais:** ${styleKeywords.join(', ')}.`;
365
+ negativePrompt = "logotipos, logos, marcas, texto, palavras, imitação, plágio";
366
+
367
+ switch (type) {
368
+ case 'cta':
369
+ scenario = 'carousel_cta';
370
+ finalPrompt = `Gere um carrossel de ${numberOfImagesToGenerate} imagens de anúncio de alta conversão. ${baseConceptPrompt}\n**Diretriz:** Cada imagem deve ser uma variação de um 'hero shot' do produto (${productType}), com foco em criar desejo imediato. Imagem 1: Produto em close-up extremo, mostrando um detalhe de material premium. Imagem 2: Produto em um ângulo de 3/4 dinâmico com iluminação de estúdio dramática. Imagem 3: O produto flutuando em um fundo de cor vibrante. Imagem 4: O produto em movimento ou em uso, com um leve desfoque para indicar ação. O produto é o herói absoluto em todas as ${numberOfImagesToGenerate} imagens.`;
371
+ break;
372
+ case 'educational':
373
+ scenario = 'carousel_educational';
374
+ finalPrompt = `Gere um carrossel de ${numberOfImagesToGenerate} imagens educativas. ${baseConceptPrompt}\n**Diretriz:** Cada imagem deve destacar um detalhe técnico ou de design diferente do produto (${productType}). Use um estilo limpo, quase diagramático. Imagem 1: Close na textura de um material inovador. Imagem 2: Close em uma característica tecnológica. Imagem 3: Close em um detalhe de design único. Imagem 4: Uma visão explodida dos componentes chave, mostrando como eles se encaixam.`;
375
+ break;
376
+ case 'trend':
377
+ scenario = 'carousel_trend';
378
+ finalPrompt = `Gere um carrossel de ${numberOfImagesToGenerate} imagens de lifestyle no estilo 'photo dump' para redes sociais. ${baseConceptPrompt}\n**Diretriz:** Capture o produto (${productType}) em contextos autênticos e da moda, com estética de filme granulado. Imagem 1: 'Fit check' ou 'setup check' em um espelho, com o look/ambiente completo em foco. Imagem 2: Close no produto em uso, em um local interessante. Imagem 3: 'Unboxing' estético em uma superfície bem composta. Imagem 4: O produto como parte de um 'flat lay' com objetos que complementam seu universo.`;
379
+ break;
380
+ }
381
+ } else { // Interior Design Theme
382
+ const designType = selectedTheme.split(': ').pop() || '';
383
+ const baseConceptPrompt = `**Conceito de Design para ${designType}:**\n- **Nome:** ${concept.name}\n- **Filosofia:** "${concept.philosophy}"\n- **Estilo Visual e Materiais:** ${concept.visualStyle}\n- **Estilos Adicionais:** ${styleKeywords.join(', ')}.`;
384
+ negativePrompt = "desordem, bagunça, má qualidade de renderização, deformado, irrealista, feio, desfocado, texto, palavras";
385
+
386
+ switch (type) {
387
+ case 'cta':
388
+ scenario = 'carousel_cta';
389
+ finalPrompt = `Gere um carrossel fotorrealista de ${numberOfImagesToGenerate} imagens de anúncio para um(a) ${designType}. ${baseConceptPrompt}\n**Diretriz:** Foco em desejo e luxo. Imagem 1: Ângulo amplo mostrando o ambiente completo. Imagem 2: Close-up em um detalhe de material nobre (ex: veio de mármore, textura da madeira). Imagem 3: Close-up em uma solução de design inteligente (ex: gaveta oculta, sistema de iluminação). Imagem 4: O ambiente visto de uma perspectiva humana, como se o espectador estivesse prestes a entrar.`;
390
+ break;
391
+ case 'educational':
392
+ scenario = 'carousel_educational';
393
+ finalPrompt = `Gere um carrossel fotorrealista de ${numberOfImagesToGenerate} imagens educativas sobre um(a) ${designType}. ${baseConceptPrompt}\n**Diretriz:** Foco em funcionalidade e inovação. Imagem 1: Visão geral do design. Imagem 2: Detalhe mostrando a durabilidade ou facilidade de limpeza de um material. Imagem 3: Detalhe mostrando a capacidade de armazenamento ou organização. Imagem 4: Detalhe mostrando a ergonomia ou o conforto do design em uso.`;
394
+ break;
395
+ case 'trend':
396
+ scenario = 'carousel_trend';
397
+ finalPrompt = `Gere um carrossel de ${numberOfImagesToGenerate} imagens de lifestyle para redes sociais, com estética 'clean' e orgânica, para um(a) ${designType}. ${baseConceptPrompt}\n**Diretriz:** Capture momentos autênticos no espaço. Imagem 1: Mãos preparando um café na bancada da cozinha. Imagem 2: Um livro e uma manta sobre uma poltrona na sala. Imagem 3: Um canto do ambiente com uma planta e luz natural. Imagem 4: Detalhe da organização de um armário ou closet. O ambiente é o protagonista silencioso.`;
398
+ break;
399
+ }
400
+ }
401
+
402
+ const options: GenerateOptions = {
403
+ basePrompt,
404
+ imagePrompt: finalPrompt,
405
+ textOverlay: "",
406
+ compositionId: 'impacto-light',
407
+ textPosition: 'center',
408
+ subtitleOutline: 'auto',
409
+ artStyles: styleKeywords,
410
+ theme: selectedTheme,
411
+ brandData: { name: concept.name, slogan: concept.philosophy, weight: 100 },
412
+ priceData: { text: '', modelText: '', style: 'circle', position: 'none', color: 'red' },
413
+ negativeImagePrompt: negativePrompt,
414
+ numberOfImages: numberOfImagesToGenerate,
415
+ scenario,
416
+ concept,
417
+ };
418
+ onGenerate(options);
419
+ };
420
+
421
+ const handleSubmit = (e: React.FormEvent) => {
422
+ e.preventDefault();
423
+ if (isInteractionDisabled || !basePrompt.trim()) return;
424
+
425
+ const artStyleKeywords = mixedStyles.map(style => `${style.name} (${style.percentage}%)`);
426
+
427
+ let imagePrompt = `${basePrompt}, tema: ${selectedTheme}.`;
428
+ if (artStyleKeywords.length > 0) {
429
+ imagePrompt += ` Estilos visuais: ${artStyleKeywords.join(', ')}.`;
430
+ }
431
+
432
+ const regionalityKeywords = [regionality.country, regionality.city, regionality.neighborhood].filter(Boolean).join(', ');
433
+ if (regionalityKeywords && regionality.weight > 10) {
434
+ imagePrompt += ` Influência regional de ${regionalityKeywords} (${regionality.weight}%).`;
435
+ }
436
+ if (brandName && brandWeight > 10) {
437
+ imagePrompt += ` Associado à marca ${brandName} (${brandWeight}%).`;
438
+ }
439
+
440
+ const options: GenerateOptions = {
441
+ basePrompt,
442
+ imagePrompt,
443
+ textOverlay,
444
+ compositionId: 'random',
445
+ textPosition: 'center',
446
+ subtitleOutline: 'auto',
447
+ artStyles: artStyleKeywords,
448
+ theme: selectedTheme,
449
+ brandData: { name: brandName, slogan: brandSlogan, weight: brandWeight },
450
+ priceData: { text: priceText, modelText: priceModelText, style: priceStyle, position: pricePosition, color: priceColor },
451
+ numberOfImages: 1
452
+ };
453
+
454
+ onGenerate(options);
455
+ };
456
+
457
+ return (
458
+ <form onSubmit={handleSubmit} className="bg-white p-4 sm:p-6 rounded-lg shadow-md border border-gray-200 flex flex-col h-full overflow-y-auto">
459
+ <div className="space-y-4 flex-grow">
460
+ <h2 className="text-xl font-bold text-gray-800 tracking-tight">Painel de Criação</h2>
461
+
462
+ {/* --- SEÇÃO 1: IDEIA CENTRAL --- */}
463
+ <div className="space-y-3">
464
+ <label htmlFor="basePrompt" className="block text-sm font-bold text-gray-700">1. Qual é a sua ideia?</label>
465
+ <textarea
466
+ id="basePrompt"
467
+ value={basePrompt}
468
+ onChange={(e) => setBasePrompt(e.target.value)}
469
+ placeholder="Ex: um tênis futurista para corrida noturna, uma cozinha com ilha central em estilo industrial..."
470
+ className="w-full h-20 p-2 bg-gray-50 rounded-md border border-gray-300 focus:ring-2 focus:ring-purple-500 focus:outline-none transition-shadow duration-200"
471
+ required
472
+ />
473
+ </div>
474
+
475
+ {/* --- SEÇÃO 2: TEMA PROFISSIONAL --- */}
476
+ <div className="space-y-3">
477
+ <label htmlFor="selectedTheme" className="block text-sm font-bold text-gray-700">2. Qual é o seu nicho profissional?</label>
478
+ <select
479
+ id="selectedTheme"
480
+ value={selectedTheme}
481
+ onChange={(e) => setSelectedTheme(e.target.value)}
482
+ className="w-full p-2 bg-gray-50 rounded-md border border-gray-300 focus:ring-2 focus:ring-purple-500 focus:outline-none"
483
+ >
484
+ {professionalThemes.map(theme => <option key={theme} value={theme}>{theme}</option>)}
485
+ </select>
486
+ </div>
487
+
488
+ {/* --- SEÇÃO 3: ESTILOS VISUAIS --- */}
489
+ <div className="space-y-3">
490
+ <label className="block text-sm font-bold text-gray-700">3. Misture até 3 estilos visuais</label>
491
+ {mixedStyles.map((style, i) => (
492
+ <div key={i} className="flex items-center gap-2">
493
+ <span className="text-sm font-medium text-gray-600 w-1/3 truncate" title={style.name}>{style.name}</span>
494
+ <input
495
+ type="range"
496
+ min="0"
497
+ max="100"
498
+ value={style.percentage}
499
+ onChange={(e) => handleSliderChange(i, parseInt(e.target.value, 10))}
500
+ className="w-2/3 h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer accent-purple-600"
501
+ disabled={isInteractionDisabled}
502
+ />
503
+ <span className="text-sm font-semibold w-12 text-right">{style.percentage}%</span>
504
+ <button type="button" onClick={() => handleRemoveStyle(i)} className="p-1 text-gray-400 hover:text-red-500" disabled={isInteractionDisabled}>
505
+ <XIcon className="w-4 h-4"/>
506
+ </button>
507
+ </div>
508
+ ))}
509
+ {mixedStyles.length < 3 && (
510
+ <div className="flex items-center gap-2">
511
+ <select value={styleToAdd} onChange={(e) => setStyleToAdd(e.target.value)} className="w-full p-2 bg-gray-50 rounded-md border border-gray-300 text-sm" disabled={!availableStyles.length || isInteractionDisabled}>
512
+ {availableStyles.map(s => <option key={s} value={s}>{s}</option>)}
513
+ </select>
514
+ <button type="button" onClick={handleAddStyle} className="flex-shrink-0 p-2 bg-purple-100 text-purple-600 rounded-md hover:bg-purple-200 disabled:opacity-50" disabled={!styleToAdd || isInteractionDisabled}>
515
+ <PlusIcon className="w-5 h-5"/>
516
+ </button>
517
+ </div>
518
+ )}
519
+ </div>
520
+
521
+ {/* --- SEÇÃO 4: TEXTO DA ARTE --- */}
522
+ <div className="space-y-3">
523
+ <label htmlFor="textOverlay" className="block text-sm font-bold text-gray-700">4. Texto para a Imagem (opcional)</label>
524
+ <textarea
525
+ id="textOverlay"
526
+ value={textOverlay}
527
+ onChange={(e) => setTextOverlay(e.target.value)}
528
+ placeholder="Título (primeira linha)&#10;Subtítulo (opcional)"
529
+ className="w-full h-20 p-2 bg-gray-50 rounded-md border border-gray-300 focus:ring-2 focus:ring-purple-500 focus:outline-none transition-shadow duration-200"
530
+ maxLength={280}
531
+ />
532
+ </div>
533
+
534
+ {/* --- FERRAMENTAS DE IA --- */}
535
+ {isConceptGeneratorVisible && (
536
+ <div className="p-3 bg-purple-50 border border-purple-200 rounded-lg space-y-3">
537
+ <h3 className="font-bold text-purple-800">{isProductConceptTheme ? 'Protótipo de Nova Marca' : 'Laboratório de Conceitos de Design'}</h3>
538
+ <p className="text-sm text-purple-700">Use a sua ideia do passo 1 para gerar conceitos completos e depois gerar a imagem.</p>
539
+ <button type="button" onClick={handleGenerateConcepts} disabled={isInteractionDisabled || !basePrompt.trim()} className="w-full flex items-center justify-center gap-2 px-4 py-2 bg-purple-600 text-white font-semibold rounded-lg hover:bg-purple-700 transition-colors disabled:bg-purple-300">
540
+ {isConceptLoading ? <LoaderIcon className="animate-spin w-5 h-5"/> : <SparklesIcon className="w-5 h-5"/>}
541
+ <span>{isProductConceptTheme ? 'Gerar Conceitos de Marca' : 'Gerar Conceitos de Design'}</span>
542
+ </button>
543
+ {conceptError && <p className="text-xs text-red-600 text-center">{conceptError}</p>}
544
+ {brandConcepts && (
545
+ <div className="space-y-4 pt-2">
546
+ {brandConcepts.map((concept, i) => (
547
+ <div key={i} className="p-3 bg-white rounded-md border border-purple-200 space-y-3">
548
+ <div>
549
+ <h4 className="font-bold text-gray-800">{concept.name}</h4>
550
+ <p className="text-xs text-gray-500 italic">"{concept.philosophy}"</p>
551
+ <p className="text-sm text-gray-700 mt-1">{concept.visualStyle}</p>
552
+ </div>
553
+
554
+ <div className="grid grid-cols-3 gap-2 text-xs">
555
+ <button type="button" onClick={() => handleGenerateFromConcept(concept, 'product')} disabled={isInteractionDisabled} className="flex items-center justify-center gap-1.5 px-2 py-2 bg-green-500 text-white font-semibold rounded-md hover:bg-green-600 transition-colors disabled:bg-green-300">
556
+ <ProductIcon className="w-4 h-4" /><span>Produto</span>
557
+ </button>
558
+ <button type="button" onClick={() => handleGenerateFromConcept(concept, 'couple')} disabled={isInteractionDisabled} className="flex items-center justify-center gap-1.5 px-2 py-2 bg-blue-500 text-white font-semibold rounded-md hover:bg-blue-600 transition-colors disabled:bg-blue-300">
559
+ <UsersIcon className="w-4 h-4" /><span>Casal</span>
560
+ </button>
561
+ <button type="button" onClick={() => handleGenerateFromConcept(concept, 'family')} disabled={isInteractionDisabled} className="flex items-center justify-center gap-1.5 px-2 py-2 bg-orange-500 text-white font-semibold rounded-md hover:bg-orange-600 transition-colors disabled:bg-orange-300">
562
+ <FamilyIcon className="w-4 h-4" /><span>Família</span>
563
+ </button>
564
+ <button type="button" onClick={() => handleGenerateFromConcept(concept, 'isometric_details')} disabled={isInteractionDisabled} className="flex items-center justify-center gap-1.5 px-2 py-2 bg-teal-500 text-white font-semibold rounded-md hover:bg-teal-600 transition-colors disabled:bg-teal-300">
565
+ <DetailedViewIcon className="w-4 h-4" /><span>Detalhes</span>
566
+ </button>
567
+ <button type="button" onClick={() => handleGenerateFromConcept(concept, 'executive_project')} disabled={isInteractionDisabled} className="flex items-center justify-center gap-1.5 px-2 py-2 bg-gray-700 text-white font-semibold rounded-md hover:bg-gray-800 transition-colors disabled:bg-gray-400">
568
+ <BlueprintIcon className="w-4 h-4" /><span>Executivo</span>
569
+ </button>
570
+ <button type="button" onClick={() => handleGenerateFromConcept(concept, 'poster')} disabled={isInteractionDisabled} className="flex items-center justify-center gap-1.5 px-2 py-2 bg-indigo-500 text-white font-semibold rounded-md hover:bg-indigo-600 transition-colors disabled:bg-indigo-300">
571
+ <PosterIcon className="w-4 h-4" /><span>Cartaz</span>
572
+ </button>
573
+ <button type="button" onClick={() => handleToggleCarouselOptions(i)} disabled={isInteractionDisabled} className={`col-span-3 flex items-center justify-center gap-1.5 px-2 py-2 font-semibold rounded-md border-2 transition-colors disabled:opacity-50 ${carouselOptionsVisible[i] ? 'bg-purple-500 text-white border-purple-500' : 'bg-transparent text-gray-600 border-gray-400 hover:bg-gray-100'}`}>
574
+ <LayersIcon className="w-4 h-4" /><span>Gerar Carrossel</span>
575
+ </button>
576
+ </div>
577
+
578
+ {carouselOptionsVisible[i] && (
579
+ <div className="pt-2">
580
+ <div className="p-3 bg-purple-100/50 rounded-lg border border-purple-200">
581
+ <h5 className="text-xs font-bold text-center text-purple-800 mb-2">Gerar Carrossel de 4 Imagens</h5>
582
+ <div className="grid grid-cols-3 gap-2 text-xs">
583
+ <button onClick={() => handleGenerateCarousel(concept, 'cta')} disabled={isInteractionDisabled} className="px-2 py-1.5 bg-purple-600 text-white font-semibold rounded hover:bg-purple-700 disabled:bg-purple-300">CTA</button>
584
+ <button onClick={() => handleGenerateCarousel(concept, 'educational')} disabled={isInteractionDisabled} className="px-2 py-1.5 bg-purple-600 text-white font-semibold rounded hover:bg-purple-700 disabled:bg-purple-300">Educativo</button>
585
+ <button onClick={() => handleGenerateCarousel(concept, 'trend')} disabled={isInteractionDisabled} className="px-2 py-1.5 bg-purple-600 text-white font-semibold rounded hover:bg-purple-700 disabled:bg-purple-300">Trend</button>
586
+ </div>
587
+ </div>
588
+ </div>
589
+ )}
590
+ </div>
591
+ ))}
592
+ </div>
593
+ )}
594
+ </div>
595
+ )}
596
+ </div>
597
+
598
+ <div className="flex-shrink-0 pt-4 border-t border-gray-200">
599
+ <button type="submit" disabled={isInteractionDisabled || !basePrompt.trim()} className="w-full flex items-center justify-center gap-2 px-4 py-3 bg-purple-600 text-white text-lg font-bold rounded-lg hover:bg-purple-700 focus:outline-none focus:ring-4 focus:ring-purple-300 transition-all duration-200 disabled:bg-purple-300 disabled:cursor-not-allowed">
600
+ {isLoading ? (
601
+ <LoaderIcon className="w-6 h-6 animate-spin" />
602
+ ) : (
603
+ <SparklesIcon className="w-6 h-6" />
604
+ )}
605
+ <span>{countdown > 0 ? `Aguarde (${countdown}s)` : 'Gerar Arte Principal'}</span>
606
+ </button>
607
+ </div>
608
+ </form>
609
+ );
610
+ };
components/SqlViewer.tsx ADDED
@@ -0,0 +1,1273 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useRef, useEffect, useState } from 'react';
2
+ import { AlertTriangleIcon, DownloadIcon, ImageIcon, PublishIcon, SparklesIcon, LoaderIcon, ClipboardIcon, CheckIcon, LightbulbIcon, GoogleIcon, EditIcon, MegaphoneIcon, TagIcon, ChevronLeftIcon, ChevronRightIcon, TextQuoteIcon } from '@/components/icons';
3
+ import { compositionPresets } from '@/lib/compositions';
4
+ import type { TextPosition, AdCopy, SubtitleOutlineStyle, CompositionPreset, BrandData, PriceData, FeatureDetails } from '@/types';
5
+ import { positionOptions, subtitleOutlineOptions, priceColorOptions, pricePositionOptions, priceStyleOptions } from '@/lib/options';
6
+
7
+
8
+ interface ImageCanvasProps {
9
+ imagesB64: string[] | null;
10
+ textOverlay: string;
11
+ compositionId: string;
12
+ textPosition: TextPosition;
13
+ subtitleOutline: SubtitleOutlineStyle;
14
+ artStyles: string[];
15
+ isLoading: boolean;
16
+ error: string | null;
17
+ adCopy: AdCopy | null;
18
+ isAdCopyLoading: boolean;
19
+ adCopyError: string | null;
20
+ onGenerateAds: () => void;
21
+ brandData: BrandData;
22
+ priceData: PriceData;
23
+ featureDetails: FeatureDetails[] | null;
24
+ // Setters for editing
25
+ setTextOverlay: (value: string) => void;
26
+ setCompositionId: (value: string) => void;
27
+ setTextPosition: (value: TextPosition) => void;
28
+ setSubtitleOutline: (value: SubtitleOutlineStyle) => void;
29
+ setPriceData: (value: React.SetStateAction<PriceData>) => void;
30
+ setFeatureDetails: (value: React.SetStateAction<FeatureDetails[] | null>) => void;
31
+ }
32
+
33
+ const CANVAS_SIZE = 1080; // For Instagram post resolution
34
+
35
+ // --- COLOR HELPER FUNCTIONS ---
36
+ type RGB = { r: number; g: number; b: number; };
37
+
38
+ // Heavily simplified color quantization
39
+ const getProminentColors = (image: HTMLImageElement, count = 5): RGB[] => {
40
+ const canvas = document.createElement('canvas');
41
+ const ctx = canvas.getContext('2d', { willReadFrequently: true });
42
+ if (!ctx) return [{ r: 255, g: 255, b: 255 }, {r: 0, g: 0, b: 0}];
43
+
44
+ const scale = Math.min(100 / image.width, 100 / image.height);
45
+ canvas.width = image.width * scale;
46
+ canvas.height = image.height * scale;
47
+ ctx.drawImage(image, 0, 0, canvas.width, canvas.height);
48
+
49
+ const data = ctx.getImageData(0, 0, canvas.width, canvas.height).data;
50
+ const colorCounts: { [key: string]: { color: RGB; count: number } } = {};
51
+
52
+ // Bucket colors to reduce dimensionality (8x8x8 cube)
53
+ for (let i = 0; i < data.length; i += 4) {
54
+ if(data[i+3] < 128) continue; // Skip transparent/semi-transparent pixels
55
+ const r = data[i];
56
+ const g = data[i + 1];
57
+ const b = data[i + 2];
58
+ const key = `${Math.round(r/32)}_${Math.round(g/32)}_${Math.round(b/32)}`;
59
+ if (!colorCounts[key]) {
60
+ colorCounts[key] = { color: { r, g, b }, count: 0 };
61
+ }
62
+ colorCounts[key].count++;
63
+ }
64
+
65
+ const sortedColors = Object.values(colorCounts).sort((a, b) => b.count - a.count);
66
+ return sortedColors.slice(0, count).map(c => c.color);
67
+ }
68
+
69
+
70
+ const rgbToHsl = ({r,g,b}: RGB): [number, number, number] => {
71
+ r /= 255; g /= 255; b /= 255;
72
+ const max = Math.max(r, g, b), min = Math.min(r, g, b);
73
+ let h = 0, s = 0, l = (max + min) / 2;
74
+ if (max !== min) {
75
+ const d = max - min;
76
+ s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
77
+ switch (max) {
78
+ case r: h = (g - b) / d + (g < b ? 6 : 0); break;
79
+ case g: h = (b - r) / d + 2; break;
80
+ case b: h = (r - g) / d + 4; break;
81
+ }
82
+ h /= 6;
83
+ }
84
+ return [h, s, l];
85
+ };
86
+
87
+ const getPalette = (image: HTMLImageElement, paletteType: string) => {
88
+ const prominentColors = getProminentColors(image);
89
+ const sortedByLuminance = [...prominentColors].sort((a, b) => {
90
+ const lumA = 0.2126 * a.r + 0.7152 * a.g + 0.0722 * a.b;
91
+ const lumB = 0.2126 * b.r + 0.7152 * b.g + 0.0722 * b.b;
92
+ return lumB - lumA;
93
+ });
94
+
95
+ const darkest = sortedByLuminance[sortedByLuminance.length - 1] || {r:0, g:0, b:0};
96
+ const lightest = sortedByLuminance[0] || {r:255, g:255, b:255};
97
+
98
+ const palette = { fill1: '#FFFFFF', fill2: '#E0E0E0', stroke: '#000000' };
99
+
100
+ switch(paletteType) {
101
+ case 'light':
102
+ palette.fill1 = `rgb(${lightest.r}, ${lightest.g}, ${lightest.b})`;
103
+ palette.fill2 = `rgba(${(lightest.r + 200)/2}, ${(lightest.g + 200)/2}, ${(lightest.b + 200)/2}, 1)`;
104
+ palette.stroke = `rgb(${darkest.r}, ${darkest.g}, ${darkest.b})`;
105
+ break;
106
+ case 'dark':
107
+ palette.fill1 = `rgb(${darkest.r}, ${darkest.g}, ${darkest.b})`;
108
+ palette.fill2 = `rgba(${(darkest.r + 50)/2}, ${(darkest.g + 50)/2}, ${(darkest.b + 50)/2}, 1)`;
109
+ palette.stroke = `rgb(${lightest.r}, ${lightest.g}, ${lightest.b})`;
110
+ break;
111
+ case 'complementary': {
112
+ const primary = prominentColors[0] || {r:128, g:128, b:128};
113
+ const [h, s, l] = rgbToHsl(primary);
114
+ const compH = (h + 0.5) % 1;
115
+ const compRgb = hslToRgb(compH, s, Math.max(0.5, l));
116
+ palette.fill1 = `rgb(${primary.r}, ${primary.g}, ${primary.b})`;
117
+ palette.fill2 = `rgb(${compRgb.r}, ${compRgb.g}, ${compRgb.b})`;
118
+ palette.stroke = `rgb(${lightest.r}, ${lightest.g}, ${lightest.b})`;
119
+ break;
120
+ }
121
+ case 'analogous': {
122
+ const primary = prominentColors[0] || {r:128, g:128, b:128};
123
+ const secondary = prominentColors[1] || primary;
124
+ palette.fill1 = `rgb(${primary.r}, ${primary.g}, ${primary.b})`;
125
+ palette.fill2 = `rgb(${secondary.r}, ${secondary.g}, ${secondary.b})`;
126
+ palette.stroke = `rgb(${lightest.r}, ${lightest.g}, ${lightest.b})`;
127
+ break;
128
+ }
129
+ default:
130
+ palette.fill1 = `rgb(${lightest.r}, ${lightest.g}, ${lightest.b})`;
131
+ palette.stroke = `rgb(${darkest.r}, ${darkest.g}, ${darkest.b})`;
132
+ }
133
+
134
+ // Ensure sufficient contrast for stroke
135
+ const fillLum = 0.2126 * parseInt(palette.fill1.slice(1,3), 16) + 0.7152 * parseInt(palette.fill1.slice(3,5), 16) + 0.0722 * parseInt(palette.fill1.slice(5,7), 16);
136
+ const strokeLum = 0.2126 * parseInt(palette.stroke.slice(1,3), 16) + 0.7152 * parseInt(palette.stroke.slice(3,5), 16) + 0.0722 * parseInt(palette.stroke.slice(5,7), 16);
137
+ if(Math.abs(fillLum - strokeLum) < 50) {
138
+ palette.stroke = strokeLum > 128 ? '#000000' : '#FFFFFF';
139
+ }
140
+
141
+ return palette;
142
+ }
143
+ const hslToRgb = (h: number, s: number, l: number): RGB => {
144
+ let r, g, b;
145
+ if (s === 0) { r = g = b = l; }
146
+ else {
147
+ const hue2rgb = (p: number, q: number, t: number) => {
148
+ if (t < 0) t += 1;
149
+ if (t > 1) t -= 1;
150
+ if (t < 1/6) return p + (q - p) * 6 * t;
151
+ if (t < 1/2) return q;
152
+ if (t < 2/3) return p + (q - p) * (2/3 - t) * 6;
153
+ return p;
154
+ }
155
+ const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
156
+ const p = 2 * l - q;
157
+ r = hue2rgb(p, q, h + 1/3);
158
+ g = hue2rgb(p, q, h);
159
+ b = hue2rgb(p, q, h - 1/3);
160
+ }
161
+ return { r: Math.round(r * 255), g: Math.round(g * 255), b: Math.round(b * 255) };
162
+ };
163
+
164
+ // --- FONT HELPER ---
165
+ const FONT_MAP: { [key: string]: { titleFont: string; subtitleFont: string } } = {
166
+ 'default': { titleFont: "'Anton', sans-serif", subtitleFont: "'Poppins', sans-serif" },
167
+ 'Comic-book': { titleFont: "'Bangers', cursive", subtitleFont: "'Poppins', sans-serif" },
168
+ 'Meme': { titleFont: "'Bangers', cursive", subtitleFont: "'Poppins', sans-serif" },
169
+ 'Lobster': { titleFont: "'Lobster', cursive", subtitleFont: "'Poppins', sans-serif" },
170
+ 'Playfair Display': { titleFont: "'Playfair Display', serif", subtitleFont: "'Poppins', sans-serif" },
171
+ 'Old Money': { titleFont: "'Playfair Display', serif", subtitleFont: "'Poppins', sans-serif" },
172
+ 'Art Déco': { titleFont: "'Playfair Display', serif", subtitleFont: "'Poppins', sans-serif" },
173
+ 'Bauhaus': { titleFont: "'Poppins', sans-serif", subtitleFont: "'Poppins', sans-serif" },
174
+ 'Minimalista': { titleFont: "'Poppins', sans-serif", subtitleFont: "'Poppins', sans-serif" },
175
+ };
176
+
177
+ const getFontForStyle = (styles: string[]): { titleFont: string; subtitleFont: string } => {
178
+ if (!styles || styles.length === 0) return FONT_MAP['default'];
179
+ for (const style of styles) {
180
+ for (const key in FONT_MAP) {
181
+ if (style.includes(key)) {
182
+ return FONT_MAP[key];
183
+ }
184
+ }
185
+ }
186
+ return FONT_MAP['default'];
187
+ };
188
+ // --- END FONT HELPER ---
189
+
190
+
191
+ const getWrappedLines = (ctx: CanvasRenderingContext2D, text: string, maxWidth: number): string[] => {
192
+ const lines: string[] = [];
193
+ if (!text) return lines;
194
+
195
+ const paragraphs = text.split('\n');
196
+ paragraphs.forEach(paragraph => {
197
+ const words = paragraph.split(' ');
198
+ let currentLine = '';
199
+ for (const word of words) {
200
+ const testLine = currentLine ? `${currentLine} ${word}` : word;
201
+ if (ctx.measureText(testLine).width > maxWidth && currentLine) {
202
+ lines.push(currentLine);
203
+ currentLine = word;
204
+ } else {
205
+ currentLine = testLine;
206
+ }
207
+ }
208
+ if (currentLine) {
209
+ lines.push(currentLine);
210
+ }
211
+ });
212
+ return lines;
213
+ };
214
+
215
+ const drawPriceTag = (ctx: CanvasRenderingContext2D, priceData: PriceData) => {
216
+ if (!priceData || (!priceData.text.trim() && !priceData.modelText.trim()) || priceData.position === 'none') {
217
+ return;
218
+ }
219
+
220
+ ctx.save();
221
+
222
+ const colorOption = priceColorOptions.find(c => c.id === priceData.color) || priceColorOptions[0];
223
+ ctx.fillStyle = colorOption.hex;
224
+ ctx.strokeStyle = 'white';
225
+ ctx.lineWidth = 6;
226
+
227
+ const priceText = priceData.text.trim();
228
+ const modelText = priceData.modelText.trim();
229
+
230
+ const priceFontSize = CANVAS_SIZE * 0.06;
231
+ const modelFontSize = CANVAS_SIZE * 0.035;
232
+
233
+ ctx.font = `900 ${priceFontSize}px 'Poppins', sans-serif`;
234
+ const priceMetrics = ctx.measureText(priceText);
235
+
236
+ ctx.font = `500 ${modelFontSize}px 'Poppins', sans-serif`;
237
+ const modelMetrics = ctx.measureText(modelText);
238
+
239
+ const textWidth = Math.max(priceMetrics.width, modelMetrics.width);
240
+ const priceHeight = priceText ? priceFontSize : 0;
241
+ const modelHeight = modelText ? modelFontSize : 0;
242
+ const verticalPadding = priceFontSize * 0.1;
243
+ const totalTextHeight = priceHeight + modelHeight + (priceText && modelText ? verticalPadding : 0);
244
+
245
+ const horizontalPadding = priceFontSize * 0.5;
246
+ const verticalPaddingForShape = priceFontSize * 0.4;
247
+
248
+ let tagWidth, tagHeight;
249
+
250
+ if (priceData.style === 'circle' || priceData.style === 'burst') {
251
+ const diameter = Math.max(textWidth, totalTextHeight) + horizontalPadding * 2;
252
+ tagWidth = diameter;
253
+ tagHeight = diameter;
254
+ } else { // 'tag'
255
+ tagWidth = textWidth + horizontalPadding * 2;
256
+ tagHeight = totalTextHeight + verticalPaddingForShape * 2;
257
+ }
258
+
259
+ const radius = tagWidth / 2;
260
+ const margin = CANVAS_SIZE * 0.05;
261
+ let x = 0, y = 0; // Center of the tag
262
+
263
+ switch(priceData.position) {
264
+ case 'top-left':
265
+ x = margin + tagWidth / 2;
266
+ y = margin + tagHeight / 2;
267
+ break;
268
+ case 'top-right':
269
+ x = CANVAS_SIZE - margin - tagWidth / 2;
270
+ y = margin + tagHeight / 2;
271
+ break;
272
+ case 'bottom-left':
273
+ x = margin + tagWidth / 2;
274
+ y = CANVAS_SIZE - margin - tagHeight / 2;
275
+ break;
276
+ case 'bottom-right':
277
+ x = CANVAS_SIZE - margin - tagHeight / 2;
278
+ y = CANVAS_SIZE - margin - tagHeight / 2;
279
+ break;
280
+ }
281
+
282
+ // Draw shape
283
+ ctx.beginPath();
284
+ switch(priceData.style) {
285
+ case 'circle':
286
+ ctx.arc(x, y, radius, 0, Math.PI * 2);
287
+ break;
288
+ case 'tag':
289
+ ctx.rect(x - tagWidth/2, y - tagHeight/2, tagWidth, tagHeight);
290
+ break;
291
+ case 'burst':
292
+ const points = 12;
293
+ const inset = 0.7;
294
+ ctx.translate(x, y);
295
+ ctx.moveTo(0, 0 - radius);
296
+ for (let i = 0; i < points; i++) {
297
+ ctx.rotate(Math.PI / points);
298
+ ctx.lineTo(0, 0 - (radius * inset));
299
+ ctx.rotate(Math.PI / points);
300
+ ctx.lineTo(0, 0 - radius);
301
+ }
302
+ ctx.translate(-x, -y);
303
+ break;
304
+ }
305
+ ctx.closePath();
306
+ ctx.stroke();
307
+ ctx.fill();
308
+
309
+ // Draw text
310
+ ctx.fillStyle = 'white';
311
+ ctx.textAlign = 'center';
312
+ ctx.textBaseline = 'middle';
313
+
314
+ if (priceText && modelText) {
315
+ const priceY = y - (totalTextHeight / 2) + (priceHeight / 2);
316
+ const modelY = priceY + (priceHeight / 2) + verticalPadding + (modelHeight / 2);
317
+
318
+ ctx.font = `900 ${priceFontSize}px 'Poppins', sans-serif`;
319
+ ctx.fillText(priceText, x, priceY);
320
+
321
+ ctx.font = `500 ${modelFontSize}px 'Poppins', sans-serif`;
322
+ ctx.fillText(modelText, x, modelY);
323
+
324
+ } else {
325
+ // Only one line of text
326
+ const singleText = priceText || modelText;
327
+ const singleFontSize = priceText ? priceFontSize : modelFontSize;
328
+ const fontWeight = priceText ? '900' : '500';
329
+
330
+ ctx.font = `${fontWeight} ${singleFontSize}px 'Poppins', sans-serif`;
331
+ ctx.fillText(singleText, x, y);
332
+ }
333
+
334
+ ctx.restore();
335
+ }
336
+
337
+ const drawFeatureDetails = (ctx: CanvasRenderingContext2D, details: FeatureDetails[]) => {
338
+ if (!details || details.length === 0) return;
339
+
340
+ ctx.save();
341
+
342
+ const cornerPositions = [
343
+ { x: CANVAS_SIZE * 0.05, y: CANVAS_SIZE * 0.05, align: 'left' as const, baseline: 'top' as const },
344
+ { x: CANVAS_SIZE * 0.95, y: CANVAS_SIZE * 0.05, align: 'right' as const, baseline: 'top' as const },
345
+ { x: CANVAS_SIZE * 0.95, y: CANVAS_SIZE * 0.95, align: 'right' as const, baseline: 'bottom' as const },
346
+ { x: CANVAS_SIZE * 0.05, y: CANVAS_SIZE * 0.95, align: 'left' as const, baseline: 'bottom' as const },
347
+ ];
348
+
349
+ const maxWidth = CANVAS_SIZE * 0.4;
350
+
351
+ details.slice(0, 4).forEach((detail, index) => {
352
+ const pos = cornerPositions[index];
353
+ if (!pos) return;
354
+
355
+ ctx.textAlign = pos.align;
356
+ ctx.textBaseline = pos.baseline;
357
+
358
+ const titleFontSize = CANVAS_SIZE * 0.025;
359
+ const descFontSize = CANVAS_SIZE * 0.02;
360
+ const lineHeight = 1.25;
361
+
362
+ // Get wrapped lines for both title and description
363
+ ctx.font = `700 ${titleFontSize}px 'Poppins', sans-serif`;
364
+ const titleLines = getWrappedLines(ctx, detail.title, maxWidth);
365
+
366
+ ctx.font = `400 ${descFontSize}px 'Poppins', sans-serif`;
367
+ const descLines = getWrappedLines(ctx, detail.description, maxWidth);
368
+
369
+ // Calculate block dimensions
370
+ const blockWidth = Math.max(
371
+ ...titleLines.map(line => ctx.measureText(line).width),
372
+ ...descLines.map(line => ctx.measureText(line).width)
373
+ );
374
+ const titleHeight = titleLines.length * titleFontSize;
375
+ const descHeight = descLines.length * descFontSize * lineHeight;
376
+ const spacing = titleFontSize * 0.25;
377
+ const totalTextHeight = titleHeight + spacing + descHeight;
378
+
379
+ const padding = titleFontSize * 0.75;
380
+ const boxWidth = blockWidth + padding * 2;
381
+ const boxHeight = totalTextHeight + padding * 2;
382
+
383
+ let boxX = pos.align === 'left' ? pos.x : pos.x - boxWidth;
384
+ let boxY = pos.baseline === 'top' ? pos.y : pos.y - boxHeight;
385
+
386
+ // Draw the semi-transparent box
387
+ ctx.fillStyle = 'rgba(0, 0, 0, 0.65)';
388
+ ctx.strokeStyle = 'rgba(255, 255, 255, 0.5)';
389
+ ctx.lineWidth = 1;
390
+ ctx.beginPath();
391
+ ctx.roundRect(boxX, boxY, boxWidth, boxHeight, [8]);
392
+ ctx.fill();
393
+ ctx.stroke();
394
+
395
+ // Draw the text
396
+ ctx.fillStyle = '#FFFFFF';
397
+ let currentY = boxY + padding;
398
+
399
+ // Draw title
400
+ ctx.font = `700 ${titleFontSize}px 'Poppins', sans-serif`;
401
+ titleLines.forEach(line => {
402
+ const lineX = pos.align === 'left' ? boxX + padding : boxX + boxWidth - padding;
403
+ ctx.fillText(line, lineX, currentY);
404
+ currentY += titleFontSize;
405
+ });
406
+
407
+ currentY += spacing;
408
+
409
+ // Draw description
410
+ ctx.font = `400 ${descFontSize}px 'Poppins', sans-serif`;
411
+ ctx.fillStyle = '#E0E0E0';
412
+ descLines.forEach(line => {
413
+ const lineX = pos.align === 'left' ? boxX + padding : boxX + boxWidth - padding;
414
+ ctx.fillText(line, lineX, currentY);
415
+ currentY += descFontSize * lineHeight;
416
+ });
417
+ });
418
+
419
+ ctx.restore();
420
+ };
421
+
422
+ const drawCanvas = (
423
+ ctx: CanvasRenderingContext2D,
424
+ image: HTMLImageElement,
425
+ text: string,
426
+ compositionId: string,
427
+ textPosition: TextPosition,
428
+ subtitleOutline: SubtitleOutlineStyle,
429
+ artStyles: string[],
430
+ brandData: BrandData,
431
+ priceData: PriceData,
432
+ featureDetails: FeatureDetails[] | null,
433
+ ) => {
434
+ ctx.clearRect(0, 0, CANVAS_SIZE, CANVAS_SIZE);
435
+ ctx.drawImage(image, 0, 0, CANVAS_SIZE, CANVAS_SIZE);
436
+
437
+ const isIsometricDetailsView = featureDetails && featureDetails.length > 0;
438
+ const palette = getPalette(image, 'light'); // Use a default palette for brand name
439
+
440
+ if (isIsometricDetailsView && text.trim()) {
441
+ // Special drawing logic for the product name in "Details" view
442
+ ctx.save();
443
+ const productName = text.split('\n')[0].toUpperCase();
444
+ const titleSize = CANVAS_SIZE * 0.03; // A bit larger for a title
445
+ ctx.font = `700 ${titleSize}px 'Poppins', sans-serif`;
446
+
447
+ const textPalette = getPalette(image, 'dark');
448
+ ctx.fillStyle = textPalette.fill1;
449
+ ctx.strokeStyle = textPalette.stroke;
450
+ ctx.lineWidth = titleSize * 0.1;
451
+ ctx.lineJoin = 'round';
452
+
453
+ ctx.shadowColor = 'rgba(0, 0, 0, 0.6)';
454
+ ctx.shadowBlur = 4;
455
+ ctx.shadowOffsetX = 2;
456
+ ctx.shadowOffsetY = 2;
457
+
458
+ const margin = CANVAS_SIZE * 0.05;
459
+ const x = CANVAS_SIZE / 2;
460
+ const y = margin;
461
+
462
+ ctx.textAlign = 'center';
463
+ ctx.textBaseline = 'top';
464
+
465
+ ctx.strokeText(productName, x, y);
466
+ ctx.fillText(productName, x, y);
467
+ ctx.restore();
468
+
469
+ } else if (text.trim()) {
470
+ // Existing logic for all other text drawing
471
+ let selectedPreset = compositionPresets.find(p => p.id === compositionId);
472
+ if (!selectedPreset || compositionId === 'random') {
473
+ const availablePresets = compositionPresets.filter(p => p.id !== 'random');
474
+ selectedPreset = availablePresets[Math.floor(Math.random() * availablePresets.length)];
475
+ }
476
+ const preset = selectedPreset.config;
477
+ const margin = CANVAS_SIZE * 0.07;
478
+
479
+ const textPalette = getPalette(image, preset.style.palette);
480
+ const { titleFont, subtitleFont } = getFontForStyle(artStyles);
481
+
482
+ const textLines = text.split('\n');
483
+ const titleText = (textLines[0] || '').toUpperCase();
484
+ const subtitleText = preset.subtitle ? textLines.slice(1).join('\n') : '';
485
+
486
+ const maxTextWidth = (textPosition === 'left' || textPosition === 'right')
487
+ ? CANVAS_SIZE * 0.4
488
+ : CANVAS_SIZE * 0.8;
489
+
490
+ // --- Robust Font Size Calculation ---
491
+ let optimalSize = 10;
492
+ const maxTextHeight = CANVAS_SIZE * 0.8;
493
+ for (let currentSize = 250; currentSize >= 10; currentSize -= 5) {
494
+ // Check width constraints first for both title and subtitle
495
+ ctx.font = `900 ${currentSize}px ${titleFont}`;
496
+ const titleLinesForWidthCheck = getWrappedLines(ctx, titleText, maxTextWidth);
497
+ const isTitleWidthOk = titleLinesForWidthCheck.every(line => ctx.measureText(line).width <= maxTextWidth);
498
+
499
+ ctx.font = `500 ${currentSize * 0.4}px ${subtitleFont}`;
500
+ const subtitleLinesForWidthCheck = getWrappedLines(ctx, subtitleText, maxTextWidth);
501
+ const isSubtitleWidthOk = subtitleLinesForWidthCheck.every(line => ctx.measureText(line).width <= maxTextWidth);
502
+
503
+ if (!isTitleWidthOk || !isSubtitleWidthOk) {
504
+ continue; // Font size too large for width, try smaller
505
+ }
506
+
507
+ // If width is OK, check height
508
+ const titleHeight = titleLinesForWidthCheck.length * currentSize * 1.1;
509
+ const subtitleHeight = subtitleText ? subtitleLinesForWidthCheck.length * (currentSize * 0.4) * 1.2 : 0;
510
+ const totalHeight = titleHeight + (subtitleHeight > 0 ? subtitleHeight + (currentSize * 0.2) : 0);
511
+
512
+ if (totalHeight <= maxTextHeight) {
513
+ optimalSize = currentSize; // This size fits both width and height
514
+ break;
515
+ }
516
+ }
517
+ // --- End Font Size Calculation ---
518
+
519
+ ctx.save();
520
+
521
+ const titleSize = optimalSize;
522
+ const subtitleSize = optimalSize * 0.4;
523
+
524
+ if (preset.rotation) {
525
+ const angle = (Math.random() * 4 - 2) * (Math.PI / 180);
526
+ ctx.translate(CANVAS_SIZE / 2, CANVAS_SIZE / 2);
527
+ ctx.rotate(angle);
528
+ ctx.translate(-CANVAS_SIZE / 2, -CANVAS_SIZE / 2);
529
+ }
530
+
531
+ ctx.font = `900 ${titleSize}px ${titleFont}`;
532
+ const titleLines = getWrappedLines(ctx, titleText, maxTextWidth);
533
+ const titleBlockHeight = titleLines.length * titleSize * 1.1;
534
+
535
+ ctx.font = `500 ${subtitleSize}px ${subtitleFont}`;
536
+ const subtitleLines = getWrappedLines(ctx, subtitleText, maxTextWidth);
537
+ const subtitleBlockHeight = subtitleText ? subtitleLines.length * subtitleSize * 1.2 : 0;
538
+
539
+ const totalBlockHeight = titleBlockHeight + (subtitleBlockHeight > 0 ? subtitleBlockHeight + (titleSize * 0.2) : 0);
540
+
541
+ let startX = 0, startY = 0;
542
+ ctx.textBaseline = 'top';
543
+
544
+ switch (textPosition) {
545
+ case 'top':
546
+ startX = CANVAS_SIZE / 2;
547
+ startY = margin;
548
+ ctx.textAlign = 'center';
549
+ break;
550
+ case 'top-right':
551
+ startX = CANVAS_SIZE - margin;
552
+ startY = margin;
553
+ ctx.textAlign = 'right';
554
+ // Offset to avoid overlap with feature detail box in the same corner
555
+ if (featureDetails && featureDetails.length > 0) {
556
+ startY += CANVAS_SIZE * 0.15;
557
+ }
558
+ break;
559
+ case 'bottom':
560
+ startX = CANVAS_SIZE / 2;
561
+ startY = CANVAS_SIZE - margin - totalBlockHeight;
562
+ ctx.textAlign = 'center';
563
+ break;
564
+ case 'left':
565
+ startX = margin;
566
+ startY = (CANVAS_SIZE / 2) - (totalBlockHeight / 2);
567
+ ctx.textAlign = 'left';
568
+ break;
569
+ case 'right':
570
+ startX = CANVAS_SIZE - margin;
571
+ startY = (CANVAS_SIZE / 2) - (totalBlockHeight / 2);
572
+ ctx.textAlign = 'right';
573
+ break;
574
+ case 'center':
575
+ default:
576
+ startX = CANVAS_SIZE / 2;
577
+ startY = (CANVAS_SIZE / 2) - (totalBlockHeight / 2);
578
+ ctx.textAlign = 'center';
579
+ break;
580
+ }
581
+
582
+
583
+ let currentY = startY;
584
+
585
+ // Draw Title
586
+ ctx.font = `900 ${titleSize}px ${titleFont}`;
587
+ titleLines.forEach(line => {
588
+ const textMetrics = ctx.measureText(line);
589
+ let xPos = startX;
590
+ if(ctx.textAlign === 'left') xPos = startX;
591
+ if(ctx.textAlign === 'center') xPos = startX - textMetrics.width / 2;
592
+ if(ctx.textAlign === 'right') xPos = startX - textMetrics.width;
593
+
594
+ const drawX = ctx.textAlign === 'center' ? startX : startX;
595
+
596
+ if (preset.style.background) {
597
+ ctx.fillStyle = preset.style.background.color;
598
+ const blockPadding = titleSize * (preset.style.background.padding || 0.1);
599
+ ctx.fillRect(
600
+ xPos - blockPadding,
601
+ currentY - blockPadding,
602
+ textMetrics.width + blockPadding * 2,
603
+ titleSize * 1.1 + blockPadding * 2
604
+ );
605
+ }
606
+
607
+ if (preset.style.name === 'gradient-on-block') {
608
+ const gradient = ctx.createLinearGradient(xPos, currentY, xPos + textMetrics.width, currentY);
609
+ gradient.addColorStop(0, textPalette.fill1);
610
+ gradient.addColorStop(1, textPalette.fill2);
611
+ ctx.fillStyle = gradient;
612
+ } else {
613
+ ctx.fillStyle = textPalette.fill1;
614
+ }
615
+ ctx.strokeStyle = textPalette.stroke;
616
+ if (preset.style.forcedStroke) {
617
+ ctx.strokeStyle = preset.style.forcedStroke;
618
+ }
619
+
620
+ if (preset.style.name === 'gradient-on-block') {
621
+ ctx.lineWidth = titleSize * 0.04;
622
+ } else {
623
+ ctx.lineWidth = titleSize * 0.05;
624
+ }
625
+
626
+ const needsFill = ['fill', 'fill-stroke', 'gradient-on-block', 'vertical'].includes(preset.style.name);
627
+ const needsStroke = ['stroke', 'fill-stroke', 'gradient-on-block'].includes(preset.style.name);
628
+
629
+ if (needsFill) {
630
+ ctx.fillText(line, drawX, currentY);
631
+ }
632
+ if (needsStroke) {
633
+ ctx.strokeText(line, drawX, currentY);
634
+ }
635
+ currentY += titleSize * 1.1;
636
+ });
637
+
638
+ // Draw Subtitle
639
+ if (subtitleText) {
640
+ currentY += titleSize * 0.2;
641
+ ctx.font = `500 ${subtitleSize}px ${subtitleFont}`;
642
+
643
+ ctx.shadowColor = 'transparent';
644
+ ctx.shadowBlur = 0;
645
+ ctx.shadowOffsetX = 0;
646
+ ctx.shadowOffsetY = 0;
647
+ ctx.strokeStyle = 'transparent';
648
+ ctx.lineWidth = 0;
649
+ ctx.fillStyle = textPalette.fill1;
650
+ ctx.lineJoin = 'round';
651
+
652
+ switch (subtitleOutline) {
653
+ case 'white':
654
+ ctx.strokeStyle = 'white';
655
+ ctx.lineWidth = subtitleSize * 0.2;
656
+ ctx.fillStyle = textPalette.stroke;
657
+ break;
658
+ case 'black':
659
+ ctx.strokeStyle = 'black';
660
+ ctx.lineWidth = subtitleSize * 0.2;
661
+ ctx.fillStyle = textPalette.fill1;
662
+ break;
663
+ case 'soft_shadow':
664
+ ctx.shadowColor = 'rgba(0, 0, 0, 0.7)';
665
+ ctx.shadowBlur = subtitleSize * 0.1;
666
+ ctx.shadowOffsetX = subtitleSize * 0.05;
667
+ ctx.shadowOffsetY = subtitleSize * 0.05;
668
+ ctx.fillStyle = textPalette.fill1;
669
+ break;
670
+ case 'transparent_box':
671
+ ctx.fillStyle = textPalette.fill1;
672
+ break;
673
+ case 'auto':
674
+ default:
675
+ ctx.fillStyle = textPalette.fill1;
676
+ ctx.strokeStyle = textPalette.stroke;
677
+ ctx.lineWidth = subtitleSize * 0.15;
678
+ break;
679
+ }
680
+
681
+ subtitleLines.forEach(line => {
682
+ const drawX = ctx.textAlign === 'center' ? startX : startX;
683
+ if (subtitleOutline === 'transparent_box') {
684
+ const textMetrics = ctx.measureText(line);
685
+ const textWidth = textMetrics.width;
686
+ const textHeight = subtitleSize;
687
+ const padding = subtitleSize * 0.25;
688
+
689
+ let boxX;
690
+ switch (ctx.textAlign) {
691
+ case 'left': boxX = startX; break;
692
+ case 'right': boxX = startX - textWidth; break;
693
+ default: boxX = startX - textWidth / 2; break;
694
+ }
695
+
696
+ const boxRgb = textPalette.stroke.match(/\d+/g)?.map(Number) || [0, 0, 0];
697
+ ctx.fillStyle = `rgba(${boxRgb[0]}, ${boxRgb[1]}, ${boxRgb[2]}, 0.6)`;
698
+
699
+ ctx.fillRect(boxX - padding, currentY - (padding / 2), textWidth + (padding * 2), textHeight + padding);
700
+
701
+ ctx.fillStyle = textPalette.fill1;
702
+ ctx.fillText(line, drawX, currentY);
703
+
704
+ } else if (subtitleOutline === 'soft_shadow') {
705
+ ctx.fillText(line, drawX, currentY);
706
+ } else {
707
+ ctx.strokeText(line, drawX, currentY);
708
+ ctx.fillText(line, drawX, currentY);
709
+ }
710
+ currentY += subtitleSize * 1.2;
711
+ });
712
+ }
713
+
714
+ ctx.restore();
715
+ }
716
+
717
+ // Draw Feature Details
718
+ drawFeatureDetails(ctx, featureDetails || []);
719
+
720
+ // Draw Price Tag
721
+ drawPriceTag(ctx, priceData);
722
+
723
+ // Draw brand name watermark
724
+ if (brandData && brandData.name.trim()) {
725
+ ctx.save();
726
+ const brandName = brandData.name.trim();
727
+ const brandSize = CANVAS_SIZE * 0.02;
728
+ ctx.font = `600 ${brandSize}px 'Poppins', sans-serif`;
729
+
730
+ // Use a semi-transparent version of the stroke color from the main palette
731
+ const brandColor = palette.stroke.startsWith('#')
732
+ ? `${palette.stroke}B3` // Append 70% opacity in hex
733
+ : `rgba(0,0,0,0.7)`; // Default fallback
734
+
735
+ ctx.fillStyle = brandColor;
736
+ ctx.textAlign = 'right';
737
+ ctx.textBaseline = 'bottom';
738
+
739
+ const brandMargin = CANVAS_SIZE * 0.03;
740
+ ctx.fillText(brandName, CANVAS_SIZE - brandMargin, CANVAS_SIZE - brandMargin);
741
+
742
+ ctx.restore();
743
+ }
744
+ };
745
+
746
+ const CopyButton: React.FC<{textToCopy: string}> = ({ textToCopy }) => {
747
+ const [copied, setCopied] = useState(false);
748
+ const handleCopy = () => {
749
+ navigator.clipboard.writeText(textToCopy);
750
+ setCopied(true);
751
+ setTimeout(() => setCopied(false), 2000);
752
+ };
753
+ return (
754
+ <button
755
+ onClick={handleCopy}
756
+ className="p-1.5 rounded-md text-gray-400 hover:bg-gray-200 hover:text-gray-600 transition-colors"
757
+ aria-label={copied ? "Copiado!" : "Copiar"}
758
+ >
759
+ {copied ? <CheckIcon className="w-4 h-4 text-green-600" /> : <ClipboardIcon className="w-4 h-4" />}
760
+ </button>
761
+ );
762
+ };
763
+
764
+ const MarketingSuite: React.FC<Pick<ImageCanvasProps, 'adCopy' | 'isAdCopyLoading' | 'onGenerateAds' | 'adCopyError'>> = ({ adCopy, isAdCopyLoading, onGenerateAds, adCopyError }) => {
765
+ if (isAdCopyLoading) {
766
+ return (
767
+ <div className="text-center p-8">
768
+ <LoaderIcon className="w-8 h-8 mx-auto animate-spin text-purple-600"/>
769
+ <p className="mt-2 text-sm text-gray-500 font-medium">Gerando textos de marketing...</p>
770
+ </div>
771
+ );
772
+ }
773
+
774
+ if (adCopyError) {
775
+ // Check for rate limit by looking for the specific phrase from our custom error.
776
+ const isRateLimitError = adCopyError.includes("excedeu sua cota");
777
+
778
+ if (isRateLimitError) {
779
+ return (
780
+ <div className="text-center p-6 bg-yellow-50 rounded-lg border border-yellow-200">
781
+ <AlertTriangleIcon className="w-8 h-8 mx-auto text-yellow-500 mb-3" />
782
+ <h4 className="font-semibold text-yellow-900">Limite Atingido</h4>
783
+ <p className="mt-1 text-sm text-yellow-800 max-w-sm mx-auto">{adCopyError}</p>
784
+ <p className="text-xs text-yellow-700 mt-2">Aguarde o contador no botão principal zerar para tentar novamente.</p>
785
+ </div>
786
+ );
787
+ }
788
+
789
+ return (
790
+ <div className="text-center p-6 bg-red-50 rounded-lg border border-red-200">
791
+ <AlertTriangleIcon className="w-8 h-8 mx-auto text-red-500 mb-3" />
792
+ <h4 className="font-semibold text-red-700">Erro ao Gerar Anúncios</h4>
793
+ <p className="mt-1 text-sm text-red-600 max-w-sm mx-auto">{adCopyError}</p>
794
+ <button
795
+ onClick={onGenerateAds}
796
+ className="mt-4 flex items-center justify-center gap-2 px-6 py-2.5 bg-purple-600 text-white font-bold rounded-lg hover:bg-purple-700 transition-colors duration-200"
797
+ >
798
+ <SparklesIcon className="w-5 h-5" />
799
+ <span>Tentar Novamente</span>
800
+ </button>
801
+ </div>
802
+ );
803
+ }
804
+
805
+ if (adCopy) {
806
+ return (
807
+ <div className="space-y-6">
808
+ <div className="p-4 bg-purple-50 border border-purple-200 rounded-lg">
809
+ <h4 className="flex items-center gap-2 font-semibold text-purple-800 text-base">
810
+ <LightbulbIcon className="w-5 h-5"/>
811
+ Dica de Estratégia
812
+ </h4>
813
+ <p className="mt-2 text-sm text-purple-700">{adCopy.strategyTip}</p>
814
+ </div>
815
+
816
+ <div className="space-y-4">
817
+ <h4 className="font-semibold text-gray-700 text-base">Google Ads</h4>
818
+ <div className="space-y-2 text-sm">
819
+ {adCopy.google.headlines.map((text, i) => (
820
+ <div key={i} className="flex items-center justify-between gap-2 p-2 bg-gray-100 rounded-md">
821
+ <span className="text-gray-800"><span className="font-bold text-gray-500">T{i+1}:</span> {text}</span>
822
+ <CopyButton textToCopy={text} />
823
+ </div>
824
+ ))}
825
+ {adCopy.google.descriptions.map((text, i) => (
826
+ <div key={i} className="flex items-start justify-between gap-2 p-2 bg-gray-100 rounded-md">
827
+ <p className="text-gray-800"><span className="font-bold text-gray-500">D{i+1}:</span> {text}</p>
828
+ <CopyButton textToCopy={text} />
829
+ </div>
830
+ ))}
831
+ </div>
832
+ </div>
833
+
834
+ <div className="space-y-4">
835
+ <h4 className="font-semibold text-gray-700 text-base">Facebook Ads</h4>
836
+ <div className="space-y-2 text-sm">
837
+ <div className="flex items-start justify-between gap-2 p-2 bg-gray-100 rounded-md">
838
+ <p className="text-gray-800 whitespace-pre-wrap"><span className="font-bold text-gray-500">Texto Principal:</span><br/>{adCopy.facebook.primaryText}</p>
839
+ <CopyButton textToCopy={adCopy.facebook.primaryText} />
840
+ </div>
841
+ <div className="flex items-center justify-between gap-2 p-2 bg-gray-100 rounded-md">
842
+ <span className="text-gray-800"><span className="font-bold text-gray-500">Título:</span> {adCopy.facebook.headline}</span>
843
+ <CopyButton textToCopy={adCopy.facebook.headline} />
844
+ </div>
845
+ <div className="flex items-center justify-between gap-2 p-2 bg-gray-100 rounded-md">
846
+ <span className="text-gray-800"><span className="font-bold text-gray-500">Descrição:</span> {adCopy.facebook.description}</span>
847
+ <CopyButton textToCopy={adCopy.facebook.description} />
848
+ </div>
849
+ </div>
850
+ </div>
851
+ </div>
852
+ )
853
+ }
854
+
855
+ return (
856
+ <div className="text-center p-8">
857
+ <h3 className="text-lg font-bold text-gray-700">Transforme sua Arte em Anúncios</h3>
858
+ <p className="mt-2 text-sm text-gray-500 max-w-sm mx-auto">Gere textos de marketing para Google e Facebook baseados na sua criação, otimizados para conversão.</p>
859
+ <button
860
+ onClick={onGenerateAds}
861
+ className="mt-4 flex items-center justify-center gap-2 px-6 py-2.5 bg-purple-600 text-white font-bold rounded-lg hover:bg-purple-700 transition-colors duration-200"
862
+ >
863
+ <SparklesIcon className="w-5 h-5" />
864
+ <span>Gerar Anúncios</span>
865
+ </button>
866
+ </div>
867
+ )
868
+ };
869
+
870
+ const FeatureDetailEditor: React.FC<{
871
+ details: FeatureDetails[] | null;
872
+ setDetails: (value: React.SetStateAction<FeatureDetails[] | null>) => void;
873
+ }> = ({ details, setDetails }) => {
874
+ if (!details) return null;
875
+
876
+ const handleDetailChange = (index: number, field: keyof FeatureDetails, value: string) => {
877
+ setDetails(currentDetails => {
878
+ if (!currentDetails) return null;
879
+ const newDetails = [...currentDetails];
880
+ newDetails[index] = { ...newDetails[index], [field]: value };
881
+ return newDetails;
882
+ });
883
+ };
884
+
885
+ return (
886
+ <div className="space-y-4">
887
+ <div className="flex items-center gap-2">
888
+ <TextQuoteIcon className="w-5 h-5 text-gray-600" />
889
+ <h3 className="text-base font-semibold text-gray-800">Detalhes do Produto</h3>
890
+ </div>
891
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
892
+ {details.map((detail, index) => (
893
+ <div key={index} className="space-y-2">
894
+ <input
895
+ type="text"
896
+ value={detail.title}
897
+ onChange={(e) => handleDetailChange(index, 'title', e.target.value)}
898
+ placeholder={`Título do Detalhe ${index + 1}`}
899
+ className="w-full p-2 bg-gray-50 rounded-md border border-gray-300 focus:ring-2 focus:ring-purple-500 focus:outline-none text-sm font-semibold"
900
+ />
901
+ <textarea
902
+ value={detail.description}
903
+ onChange={(e) => handleDetailChange(index, 'description', e.target.value)}
904
+ placeholder={`Descrição do detalhe ${index + 1}...`}
905
+ className="w-full h-20 p-2 bg-gray-50 rounded-md border border-gray-300 focus:ring-2 focus:ring-purple-500 focus:outline-none text-sm resize-none"
906
+ rows={3}
907
+ />
908
+ </div>
909
+ ))}
910
+ </div>
911
+ </div>
912
+ );
913
+ };
914
+
915
+ const EditingPanel: React.FC<Pick<ImageCanvasProps, 'textOverlay' | 'compositionId' | 'textPosition' | 'subtitleOutline' | 'priceData' | 'featureDetails' | 'setTextOverlay' | 'setCompositionId' | 'setTextPosition' | 'setSubtitleOutline' | 'setPriceData' | 'setFeatureDetails' >> = (props) => {
916
+ const { textOverlay, compositionId, textPosition, subtitleOutline, priceData, featureDetails, setTextOverlay, setCompositionId, setTextPosition, setSubtitleOutline, setPriceData, setFeatureDetails } = props;
917
+
918
+ // If feature details are present, show the specialized editor. Otherwise, show the standard editor.
919
+ if (featureDetails) {
920
+ return <FeatureDetailEditor details={featureDetails} setDetails={setFeatureDetails} />;
921
+ }
922
+
923
+ return (
924
+ <div className="space-y-6">
925
+ <div>
926
+ <label htmlFor="text-editor-overlay" className="block text-sm font-medium text-gray-700 mb-1">Texto da Arte</label>
927
+ <div className="relative">
928
+ <textarea
929
+ id="text-editor-overlay"
930
+ value={textOverlay}
931
+ onChange={(e) => setTextOverlay(e.target.value)}
932
+ placeholder="Título (primeira linha)&#10;Subtítulo (linhas seguintes)"
933
+ className="w-full h-24 p-2 bg-gray-50 rounded-md border border-gray-300 focus:ring-2 focus:ring-purple-500 focus:outline-none transition-shadow duration-200 resize-none text-gray-800 placeholder-gray-400"
934
+ maxLength={280}
935
+ />
936
+ <span className="absolute bottom-2 right-3 text-xs text-gray-400">{textOverlay.length} / 280</span>
937
+ </div>
938
+ </div>
939
+
940
+ <div>
941
+ <label className="block text-sm font-medium text-gray-700 mb-2">Estilo do Texto</label>
942
+ <div className="grid grid-cols-3 sm:grid-cols-5 gap-2">
943
+ {compositionPresets.map(preset => (
944
+ <button
945
+ key={preset.id}
946
+ type="button"
947
+ onClick={() => setCompositionId(preset.id)}
948
+ className={`flex flex-col items-center justify-center p-2 rounded-lg border-2 transition-all duration-200 ${compositionId === preset.id ? 'border-purple-500 bg-purple-50' : 'border-gray-200 bg-white hover:border-gray-300'}`}
949
+ title={preset.name}
950
+ >
951
+ <preset.icon className={`w-10 h-10 mb-1 ${compositionId === preset.id ? 'text-purple-600' : 'text-gray-500'}`} />
952
+ <span className={`text-xs text-center font-medium ${compositionId === preset.id ? 'text-purple-700' : 'text-gray-500'}`}>{preset.name}</span>
953
+ </button>
954
+ ))}
955
+ </div>
956
+ </div>
957
+
958
+ <div>
959
+ <label className="block text-sm font-medium text-gray-700 mb-2">Posição do Texto</label>
960
+ <div className="grid grid-cols-5 gap-2">
961
+ {positionOptions.map(option => (
962
+ <button
963
+ key={option.id}
964
+ type="button"
965
+ onClick={() => setTextPosition(option.id)}
966
+ className={`flex flex-col items-center justify-center p-2 rounded-lg border-2 transition-all duration-200 ${textPosition === option.id ? 'border-purple-500 bg-purple-50' : 'border-gray-200 bg-white hover:border-gray-300'}`}
967
+ title={option.name}
968
+ aria-label={`Posicionar texto: ${option.name}`}
969
+ >
970
+ <option.icon className={`w-8 h-8 mb-1 ${textPosition === option.id ? 'text-purple-600' : 'text-gray-400'}`} />
971
+ <span className={`text-xs font-medium ${textPosition === option.id ? 'text-purple-700' : 'text-gray-500'}`}>{option.name}</span>
972
+ </button>
973
+ ))}
974
+ </div>
975
+ </div>
976
+
977
+ <div>
978
+ <label className="block text-sm font-medium text-gray-700 mb-2">Estilo do Subtítulo</label>
979
+ <div className="grid grid-cols-5 gap-2">
980
+ {subtitleOutlineOptions.map(option => (
981
+ <button
982
+ key={option.id}
983
+ type="button"
984
+ onClick={() => setSubtitleOutline(option.id)}
985
+ className={`flex flex-col items-center justify-center p-2 rounded-lg border-2 transition-all duration-200 ${subtitleOutline === option.id ? 'border-purple-500 bg-purple-50' : 'border-gray-200 bg-white hover:border-gray-300'}`}
986
+ title={option.name}
987
+ aria-label={`Estilo do subtítulo: ${option.name}`}
988
+ >
989
+ <option.icon className={`w-8 h-8 mb-1 ${subtitleOutline === option.id ? 'text-purple-600' : 'text-gray-500'}`} />
990
+ <span className={`text-xs text-center font-medium ${subtitleOutline === option.id ? 'text-purple-700' : 'text-gray-500'}`}>{option.name}</span>
991
+ </button>
992
+ ))}
993
+ </div>
994
+ </div>
995
+
996
+ <div className="pt-4 border-t border-gray-200">
997
+ <label className="block text-sm font-medium text-gray-700 mb-1 flex items-center gap-2">
998
+ <TagIcon className="w-4 h-4" />
999
+ Etiqueta de Preço
1000
+ </label>
1001
+ <div className="space-y-4 mt-2">
1002
+ <input name="priceText" value={priceData.text} onChange={(e) => setPriceData(p => ({ ...p, text: e.target.value }))} placeholder="Ex: R$ 99,90" className="w-full p-2 bg-gray-50 rounded-md border border-gray-300 focus:ring-2 focus:ring-purple-500 focus:outline-none" />
1003
+ <input name="modelText" value={priceData.modelText} onChange={(e) => setPriceData(p => ({ ...p, modelText: e.target.value }))} placeholder="Modelo do produto/serviço" className="w-full p-2 bg-gray-50 rounded-md border border-gray-300 focus:ring-2 focus:ring-purple-500 focus:outline-none" />
1004
+ <div>
1005
+ <label className="text-xs text-gray-500 mb-2 block">Estilo da Etiqueta</label>
1006
+ <div className="grid grid-cols-3 gap-2">
1007
+ {priceStyleOptions.map(option => (
1008
+ <button key={option.id} type="button" onClick={() => setPriceData(p => ({ ...p, style: option.id }))} className={`flex flex-col items-center justify-center p-2 rounded-lg border-2 transition-all duration-200 ${priceData.style === option.id ? 'border-purple-500 bg-purple-50' : 'border-gray-200 bg-white hover:border-gray-300'}`} title={option.name}>
1009
+ <option.icon className={`w-8 h-8 mb-1 ${priceData.style === option.id ? 'text-purple-600' : 'text-gray-500'}`} />
1010
+ <span className={`text-xs font-medium ${priceData.style === option.id ? 'text-purple-700' : 'text-gray-500'}`}>{option.name}</span>
1011
+ </button>
1012
+ ))}
1013
+ </div>
1014
+ </div>
1015
+ <div>
1016
+ <label className="text-xs text-gray-500 mb-2 block">Cor</label>
1017
+ <div className="grid grid-cols-4 gap-2">
1018
+ {priceColorOptions.map(option => (
1019
+ <button key={option.id} type="button" onClick={() => setPriceData(p => ({ ...p, color: option.id }))} className={`flex items-center justify-center p-2 rounded-lg border-2 transition-all duration-200 ${priceData.color === option.id ? 'border-purple-500 bg-purple-50' : 'border-gray-200 bg-white hover:border-gray-300'}`} title={option.name}>
1020
+ <span className="w-6 h-6 rounded-full border border-gray-300" style={{ backgroundColor: option.hex }}></span>
1021
+ </button>
1022
+ ))}
1023
+ </div>
1024
+ </div>
1025
+ <div>
1026
+ <label className="text-xs text-gray-500 mb-2 block">Posição</label>
1027
+ <div className="grid grid-cols-5 gap-2">
1028
+ {pricePositionOptions.map(option => (
1029
+ <button key={option.id} type="button" onClick={() => setPriceData(p => ({ ...p, position: option.id }))} className={`flex flex-col items-center justify-center p-2 rounded-lg border-2 transition-all duration-200 ${priceData.position === option.id ? 'border-purple-500 bg-purple-50' : 'border-gray-200 bg-white hover:border-gray-300'}`} title={option.name}>
1030
+ <option.icon className={`w-8 h-8 mb-1 ${priceData.position === option.id ? 'text-purple-600' : 'text-gray-400'}`} />
1031
+ <span className={`text-xs font-medium ${priceData.position === option.id ? 'text-purple-700' : 'text-gray-500'}`}>{option.name}</span>
1032
+ </button>
1033
+ ))}
1034
+ </div>
1035
+ </div>
1036
+ </div>
1037
+ </div>
1038
+ </div>
1039
+ );
1040
+ };
1041
+
1042
+ export const SqlViewer: React.FC<ImageCanvasProps> = (props) => {
1043
+ const { imagesB64, textOverlay, compositionId, textPosition, subtitleOutline, artStyles, isLoading, error, adCopy, isAdCopyLoading, onGenerateAds, adCopyError, brandData, priceData, featureDetails, setTextOverlay, setCompositionId, setTextPosition, setSubtitleOutline, setPriceData, setFeatureDetails } = props;
1044
+ const canvasRef = useRef<HTMLCanvasElement>(null);
1045
+ const [activeTab, setActiveTab] = useState<'edit' | 'marketing'>('edit');
1046
+ const [activeImageIndex, setActiveImageIndex] = useState(0);
1047
+
1048
+ // Reset index when image set changes
1049
+ useEffect(() => {
1050
+ setActiveImageIndex(0);
1051
+ }, [imagesB64]);
1052
+
1053
+ // When feature details are loaded, switch to the edit tab automatically
1054
+ useEffect(() => {
1055
+ if (featureDetails) {
1056
+ setActiveTab('edit');
1057
+ }
1058
+ }, [featureDetails]);
1059
+
1060
+ const currentImageB64 = imagesB64 ? imagesB64[activeImageIndex] : null;
1061
+
1062
+ useEffect(() => {
1063
+ if (currentImageB64 && canvasRef.current) {
1064
+ const canvas = canvasRef.current;
1065
+ const ctx = canvas.getContext('2d');
1066
+ if (!ctx) return;
1067
+
1068
+ const image = new Image();
1069
+ image.crossOrigin = 'anonymous'; // Important for reading pixel data
1070
+ image.src = `data:image/jpeg;base64,${currentImageB64}`;
1071
+ image.onload = () => {
1072
+ drawCanvas(ctx, image, textOverlay, compositionId, textPosition, subtitleOutline, artStyles, brandData, priceData, featureDetails);
1073
+ };
1074
+ image.onerror = () => {
1075
+ console.error("Failed to load the generated image.");
1076
+ }
1077
+ }
1078
+ }, [currentImageB64, textOverlay, compositionId, textPosition, subtitleOutline, artStyles, brandData, priceData, featureDetails]);
1079
+
1080
+ const handleDownload = () => {
1081
+ const canvas = canvasRef.current;
1082
+ if (!canvas) return;
1083
+ const link = document.createElement('a');
1084
+ const fileName = `instastyle-post-${activeImageIndex + 1}.jpg`;
1085
+ link.download = fileName;
1086
+ link.href = canvas.toDataURL('image/jpeg', 0.9);
1087
+ link.click();
1088
+ };
1089
+
1090
+ const renderContent = () => {
1091
+ if (isLoading) {
1092
+ return (
1093
+ <div className="flex items-center justify-center h-full text-gray-500">
1094
+ <div className="text-center">
1095
+ <div className="w-12 h-12 border-4 border-dashed rounded-full animate-spin border-purple-500 mx-auto"></div>
1096
+ <p className="mt-4 text-lg font-medium">Gerando sua arte...</p>
1097
+ <p className="text-sm">Isso pode levar alguns segundos.</p>
1098
+ </div>
1099
+ </div>
1100
+ )
1101
+ }
1102
+
1103
+ if (error) {
1104
+ // Check for rate limit by looking for the specific phrase from our custom error.
1105
+ const isRateLimitError = error.includes("excedeu sua cota");
1106
+
1107
+ if (isRateLimitError) {
1108
+ return (
1109
+ <div className="flex items-center justify-center h-full text-yellow-800">
1110
+ <div className="text-center bg-yellow-50 p-6 rounded-lg border border-yellow-200 max-w-md">
1111
+ <AlertTriangleIcon className="w-12 h-12 mx-auto mb-4 text-yellow-500"/>
1112
+ <h3 className="font-semibold text-lg text-yellow-900">Limite de Requisições Atingido</h3>
1113
+ <p className="text-sm text-yellow-800 mt-2">{error}</p>
1114
+ <p className="text-xs text-yellow-700 mt-3">
1115
+ Aguarde o contador no botão de geração zerar para tentar novamente.
1116
+ </p>
1117
+ </div>
1118
+ </div>
1119
+ );
1120
+ }
1121
+
1122
+ return (
1123
+ <div className="flex items-center justify-center h-full text-red-600">
1124
+ <div className="text-center bg-red-50 p-6 rounded-lg border border-red-200 max-w-md">
1125
+ <AlertTriangleIcon className="w-12 h-12 mx-auto mb-4 text-red-500"/>
1126
+ <p className="font-semibold text-red-700">Ocorreu um Erro</p>
1127
+ <p className="text-sm text-red-600 mt-2">{error}</p>
1128
+ </div>
1129
+ </div>
1130
+ )
1131
+ }
1132
+
1133
+ if (imagesB64 && imagesB64.length > 0) {
1134
+ const isCarousel = imagesB64.length > 1;
1135
+ return (
1136
+ <div className="relative h-full w-full flex flex-col items-center justify-center gap-4">
1137
+ <div className="relative w-full max-w-[500px]">
1138
+ <canvas
1139
+ ref={canvasRef}
1140
+ width={CANVAS_SIZE}
1141
+ height={CANVAS_SIZE}
1142
+ className="w-full h-auto aspect-square object-contain rounded-lg bg-gray-50 border border-gray-200 shadow-md"
1143
+ />
1144
+ {isCarousel && (
1145
+ <>
1146
+ <button
1147
+ onClick={() => setActiveImageIndex(i => Math.max(0, i - 1))}
1148
+ disabled={activeImageIndex === 0}
1149
+ className="absolute top-1/2 -translate-y-1/2 -left-4 z-10 p-2 bg-white/80 hover:bg-white rounded-full shadow-lg border border-gray-200 transition-all disabled:opacity-30 disabled:cursor-not-allowed"
1150
+ aria-label="Imagem anterior"
1151
+ >
1152
+ <ChevronLeftIcon className="w-6 h-6 text-gray-800" />
1153
+ </button>
1154
+ <button
1155
+ onClick={() => setActiveImageIndex(i => Math.min(imagesB64.length - 1, i + 1))}
1156
+ disabled={activeImageIndex === imagesB64.length - 1}
1157
+ className="absolute top-1/2 -translate-y-1/2 -right-4 z-10 p-2 bg-white/80 hover:bg-white rounded-full shadow-lg border border-gray-200 transition-all disabled:opacity-30 disabled:cursor-not-allowed"
1158
+ aria-label="Próxima imagem"
1159
+ >
1160
+ <ChevronRightIcon className="w-6 h-6 text-gray-800" />
1161
+ </button>
1162
+ </>
1163
+ )}
1164
+ </div>
1165
+
1166
+ {isCarousel && (
1167
+ <div className="flex justify-center items-center gap-2">
1168
+ {imagesB64.map((_, index) => (
1169
+ <button
1170
+ key={index}
1171
+ onClick={() => setActiveImageIndex(index)}
1172
+ className={`w-2.5 h-2.5 rounded-full transition-colors ${activeImageIndex === index ? 'bg-purple-600' : 'bg-gray-300 hover:bg-gray-400'}`}
1173
+ aria-label={`Ir para imagem ${index + 1}`}
1174
+ />
1175
+ ))}
1176
+ </div>
1177
+ )}
1178
+
1179
+
1180
+ <div className="flex items-center gap-3">
1181
+ <button
1182
+ onClick={handleDownload}
1183
+ className="flex items-center justify-center gap-2 px-5 py-2.5 bg-purple-600 text-white font-bold rounded-lg hover:bg-purple-700 transition-colors duration-200"
1184
+ aria-label="Baixar arte gerada"
1185
+ >
1186
+ <DownloadIcon className="w-5 h-5" />
1187
+ <span>Baixar {isCarousel ? `(${activeImageIndex + 1}/${imagesB64.length})` : ''}</span>
1188
+ </button>
1189
+ <div className="relative group">
1190
+ <button
1191
+ disabled
1192
+ className="flex items-center justify-center gap-2 px-5 py-2.5 bg-gray-300 text-gray-500 font-bold rounded-lg cursor-not-allowed"
1193
+ aria-label="Publicar no Instagram (em breve)"
1194
+ >
1195
+ <PublishIcon className="w-5 h-5" />
1196
+ <span>Publicar</span>
1197
+ </button>
1198
+ <div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 w-max px-2 py-1 bg-gray-800 text-white text-xs rounded-md opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none">
1199
+ Em breve!
1200
+ </div>
1201
+ </div>
1202
+ </div>
1203
+ <div className="w-full max-w-2xl mt-4 p-4 sm:p-6 bg-white rounded-lg border border-gray-200 shadow-sm">
1204
+ <div className="border-b border-gray-200 mb-4">
1205
+ <nav className="-mb-px flex gap-6" aria-label="Tabs">
1206
+ <button
1207
+ onClick={() => setActiveTab('edit')}
1208
+ className={`flex items-center gap-2 py-3 px-1 border-b-2 font-medium text-sm transition-colors ${
1209
+ activeTab === 'edit'
1210
+ ? 'border-purple-500 text-purple-600'
1211
+ : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
1212
+ }`}
1213
+ >
1214
+ <EditIcon className="w-5 h-5" />
1215
+ Editar Arte
1216
+ </button>
1217
+ <button
1218
+ onClick={() => setActiveTab('marketing')}
1219
+ className={`flex items-center gap-2 py-3 px-1 border-b-2 font-medium text-sm transition-colors ${
1220
+ activeTab === 'marketing'
1221
+ ? 'border-purple-500 text-purple-600'
1222
+ : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
1223
+ }`}
1224
+ >
1225
+ <MegaphoneIcon className="w-5 h-5" />
1226
+ Marketing
1227
+ </button>
1228
+ </nav>
1229
+ </div>
1230
+
1231
+ {activeTab === 'edit' && (
1232
+ <EditingPanel
1233
+ textOverlay={textOverlay}
1234
+ compositionId={compositionId}
1235
+ textPosition={textPosition}
1236
+ subtitleOutline={subtitleOutline}
1237
+ priceData={priceData}
1238
+ featureDetails={featureDetails}
1239
+ setTextOverlay={setTextOverlay}
1240
+ setCompositionId={setCompositionId}
1241
+ setTextPosition={setTextPosition}
1242
+ setSubtitleOutline={setSubtitleOutline}
1243
+ setPriceData={setPriceData}
1244
+ setFeatureDetails={setFeatureDetails}
1245
+ />
1246
+ )}
1247
+
1248
+ {activeTab === 'marketing' && (
1249
+ <MarketingSuite adCopy={adCopy} isAdCopyLoading={isAdCopyLoading} adCopyError={adCopyError} onGenerateAds={onGenerateAds} />
1250
+ )}
1251
+
1252
+ </div>
1253
+ </div>
1254
+ );
1255
+ }
1256
+
1257
+ return (
1258
+ <div className="flex items-center justify-center h-full text-gray-400">
1259
+ <div className="text-center p-4">
1260
+ <ImageIcon className="w-16 h-16 mx-auto mb-4" />
1261
+ <p className="font-bold text-lg text-gray-600">Sua arte aparecerá aqui</p>
1262
+ <p className="text-sm">Preencha o painel ao lado para começar.</p>
1263
+ </div>
1264
+ </div>
1265
+ );
1266
+ };
1267
+
1268
+ return (
1269
+ <div className="h-full bg-gray-50 rounded-lg p-4 flex flex-col items-center justify-center flex-grow min-h-[500px] lg:min-h-0 overflow-y-auto">
1270
+ {renderContent()}
1271
+ </div>
1272
+ );
1273
+ };
components/icons.tsx ADDED
@@ -0,0 +1,432 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from 'react';
2
+
3
+ // A helper type for component props
4
+ type IconProps = React.SVGProps<SVGSVGElement>;
5
+
6
+ export const LogoIcon: React.FC<IconProps> = (props) => (
7
+ <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" {...props}>
8
+ <rect width="18" height="18" x="3" y="3" rx="2" ry="2"></rect>
9
+ <circle cx="8.5" cy="8.5" r="1.5"></circle>
10
+ <path d="M21 15l-5-5L5 21"></path>
11
+ </svg>
12
+ );
13
+
14
+ export const DownloadIcon: React.FC<IconProps> = (props) => (
15
+ <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" {...props}>
16
+ <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
17
+ <polyline points="7 10 12 15 17 10"></polyline>
18
+ <line x1="12" y1="15" x2="12" y2="3"></line>
19
+ </svg>
20
+ );
21
+
22
+ export const SparklesIcon: React.FC<IconProps> = (props) => (
23
+ <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" {...props}>
24
+ <path d="m12 3-1.9 5.8-5.8 1.9 5.8 1.9 1.9 5.8 1.9-5.8 5.8-1.9-5.8-1.9z"></path>
25
+ </svg>
26
+ );
27
+
28
+ export const LoaderIcon: React.FC<IconProps> = (props) => (
29
+ <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" {...props}>
30
+ <path d="M21 12a9 9 0 1 1-6.219-8.56"></path>
31
+ </svg>
32
+ );
33
+
34
+ export const AlertTriangleIcon: React.FC<IconProps> = (props) => (
35
+ <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" {...props}>
36
+ <path d="m21.73 18-8-14a2 2 0 0 0-3.46 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z"></path>
37
+ <path d="M12 9v4"></path>
38
+ <path d="M12 17h.01"></path>
39
+ </svg>
40
+ );
41
+
42
+ export const GoogleIcon: React.FC<IconProps> = (props) => (
43
+ <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="currentColor" {...props}>
44
+ <path d="M21.35,11.1H12.18V13.83H18.69C18.36,17.64 15.19,19.27 12.19,19.27C8.36,19.27 5,16.25 5,12C5,7.9 8.2,4.73 12.19,4.73C14.03,4.73 15.69,5.36 16.95,6.55L19.05,4.44C17.22,2.77 15,2 12.19,2C6.92,2 2.71,6.6 2.71,12C2.71,17.4 6.92,22 12.19,22C17.6,22 21.7,18.35 21.7,12.33C21.7,11.77 21.52,11.44 21.35,11.1Z" />
45
+ </svg>
46
+ );
47
+
48
+ export const LogoutIcon: React.FC<IconProps> = (props) => (
49
+ <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" {...props}>
50
+ <path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"></path>
51
+ <polyline points="16 17 21 12 16 7"></polyline>
52
+ <line x1="21" y1="12" x2="9" y2="12"></line>
53
+ </svg>
54
+ );
55
+
56
+ export const PublishIcon: React.FC<IconProps> = (props) => (
57
+ <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" {...props}>
58
+ <path d="M4 12v8a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-8"></path>
59
+ <polyline points="16 6 12 2 8 6"></polyline>
60
+ <line x1="12" y1="2" x2="12" y2="15"></line>
61
+ </svg>
62
+ );
63
+
64
+ export const ImageIcon: React.FC<IconProps> = (props) => (
65
+ <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" {...props}>
66
+ <rect width="18" height="18" x="3" y="3" rx="2" ry="2" />
67
+ <circle cx="9" cy="9" r="2" />
68
+ <path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21" />
69
+ </svg>
70
+ );
71
+
72
+ export const BookOpenIcon: React.FC<IconProps> = (props) => (
73
+ <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" {...props}>
74
+ <path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z"></path>
75
+ <path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z"></path>
76
+ </svg>
77
+ );
78
+
79
+ export const ClipboardIcon: React.FC<IconProps> = (props) => (
80
+ <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" {...props}>
81
+ <rect width="8" height="4" x="8" y="2" rx="1" ry="1"></rect>
82
+ <path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"></path>
83
+ </svg>
84
+ );
85
+
86
+ export const CheckIcon: React.FC<IconProps> = (props) => (
87
+ <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" {...props}>
88
+ <polyline points="20 6 9 17 4 12"></polyline>
89
+ </svg>
90
+ );
91
+
92
+ export const PlusIcon: React.FC<IconProps> = (props) => (
93
+ <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" {...props}>
94
+ <line x1="12" y1="5" x2="12" y2="19"></line>
95
+ <line x1="5" y1="12" x2="19" y2="12"></line>
96
+ </svg>
97
+ );
98
+
99
+ export const XIcon: React.FC<IconProps> = (props) => (
100
+ <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" {...props}>
101
+ <line x1="18" y1="6" x2="6" y2="18"></line>
102
+ <line x1="6" y1="6" x2="18" y2="18"></line>
103
+ </svg>
104
+ );
105
+
106
+ export const MegaphoneIcon: React.FC<IconProps> = (props) => (
107
+ <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" {...props}>
108
+ <path d="m3 11 18-5v12L3 14v-3z"></path>
109
+ <path d="M11.6 16.8a3 3 0 1 1-5.8-1.6"></path>
110
+ </svg>
111
+ );
112
+
113
+ export const GlobeIcon: React.FC<IconProps> = (props) => (
114
+ <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" {...props}>
115
+ <circle cx="12" cy="12" r="10"></circle>
116
+ <line x1="2" y1="12" x2="22" y2="12"></line>
117
+ <path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"></path>
118
+ </svg>
119
+ );
120
+
121
+ export const TagIcon: React.FC<IconProps> = (props) => (
122
+ <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" {...props}>
123
+ <path d="M12 2H2v10l9.29 9.29a1 1 0 0 0 1.42 0l9.29-9.29L12 2z"></path>
124
+ <path d="M7 7h.01"></path>
125
+ </svg>
126
+ );
127
+
128
+ export const ProductIcon: React.FC<IconProps> = (props) => (
129
+ <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" {...props}>
130
+ <path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z" />
131
+ <path d="m3.27 6.96 8.73 5.05 8.73-5.05" />
132
+ <path d="M12 22.08V12" />
133
+ </svg>
134
+ );
135
+
136
+ export const UsersIcon: React.FC<IconProps> = (props) => (
137
+ <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" {...props}>
138
+ <path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"></path>
139
+ <circle cx="9" cy="7" r="4"></circle>
140
+ <path d="M22 21v-2a4 4 0 0 0-3-3.87"></path>
141
+ <path d="M16 3.13a4 4 0 0 1 0 7.75"></path>
142
+ </svg>
143
+ );
144
+
145
+ export const FamilyIcon: React.FC<IconProps> = (props) => (
146
+ <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" {...props}>
147
+ <path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"></path>
148
+ <circle cx="9" cy="7" r="4"></circle>
149
+ <path d="M22 21v-2a4 4 0 0 0-3-3.87"></path>
150
+ <path d="M16 3.13a4 4 0 0 1 0 7.75"></path>
151
+ <path d="M19.5 14.5a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5z"></path>
152
+ <path d="M22 21v-1a2 2 0 0 0-2-2h-1"></path>
153
+ </svg>
154
+ );
155
+
156
+ export const LayersIcon: React.FC<IconProps> = (props) => (
157
+ <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" {...props}>
158
+ <polygon points="12 2 2 7 12 12 22 7 12 2"></polygon>
159
+ <polyline points="2 17 12 22 22 17"></polyline>
160
+ <polyline points="2 12 12 17 22 12"></polyline>
161
+ </svg>
162
+ );
163
+
164
+ export const DetailedViewIcon: React.FC<IconProps> = (props) => (
165
+ <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" {...props}>
166
+ <rect x="3" y="3" width="7" height="7" rx="1"></rect>
167
+ <rect x="14" y="3" width="7" height="7" rx="1"></rect>
168
+ <rect x="14" y="14" width="7" height="7" rx="1"></rect>
169
+ <rect x="3" y="14" width="7" height="7" rx="1"></rect>
170
+ </svg>
171
+ );
172
+
173
+ export const PosterIcon: React.FC<IconProps> = (props) => (
174
+ <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" {...props}>
175
+ <rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
176
+ <rect x="7" y="7" width="5" height="5" rx="1"></rect>
177
+ <path d="M14 7h3v10h-3z"></path>
178
+ <rect x="7" y="14" width="5" height="3" rx="1"></rect>
179
+ </svg>
180
+ );
181
+
182
+ export const BlueprintIcon: React.FC<IconProps> = (props) => (
183
+ <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" {...props}>
184
+ <rect x="8" y="2" width="8" height="4" rx="1" ry="1"></rect>
185
+ <path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"></path>
186
+ <path d="M12 11h4"></path>
187
+ <path d="M12 16h4"></path>
188
+ <path d="M8 11h.01"></path>
189
+ <path d="M8 16h.01"></path>
190
+ </svg>
191
+ );
192
+
193
+ export const ChevronLeftIcon: React.FC<IconProps> = (props) => (
194
+ <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" {...props}>
195
+ <polyline points="15 18 9 12 15 6"></polyline>
196
+ </svg>
197
+ );
198
+
199
+ export const ChevronRightIcon: React.FC<IconProps> = (props) => (
200
+ <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" {...props}>
201
+ <polyline points="9 18 15 12 9 6"></polyline>
202
+ </svg>
203
+ );
204
+
205
+
206
+ // --- Layout Icons ---
207
+ export const LayoutRandomIcon: React.FC<IconProps> = (props) => (
208
+ <svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" {...props}>
209
+ <path d="M15.1629 5.83709C15.9014 6.57556 16.5291 7.42444 17.0454 8.38444M6.95462 15.6156C7.47089 16.5756 8.09861 17.4244 8.83709 18.1629" strokeOpacity="0.7"/>
210
+ <path d="M19 10C19 12.1667 18.1667 14.8333 16.5 17" strokeOpacity="0.7"/>
211
+ <path d="M5 14C5 11.8333 5.83333 9.16667 7.5 7" strokeOpacity="0.7"/>
212
+ <path d="M17 5L19 5L19 7"/>
213
+ <path d="M7 19L5 19L5 17"/>
214
+ <path d="M10 5L14 5" strokeOpacity="0.7"/>
215
+ <path d="M10 19L14 19" strokeOpacity="0.7"/>
216
+ <path d="M5 10L5 14" strokeOpacity="0.7"/>
217
+ <path d="M19 10L19 14" strokeOpacity="0.7"/>
218
+ </svg>
219
+ );
220
+
221
+ export const LayoutImpactoIcon: React.FC<IconProps> = (props) => (
222
+ <svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
223
+ <rect x="3" y="3" width="18" height="18" rx="2" fill="#7C3AED" fillOpacity="0.2"/>
224
+ <text x="12" y="15" fontFamily="Arial, sans-serif" fontSize="8" fontWeight="bold" textAnchor="middle" stroke="white" strokeWidth="0.8" fill="black">Aa</text>
225
+ </svg>
226
+ );
227
+
228
+ export const LayoutDegradeIcon: React.FC<IconProps> = (props) => (
229
+ <svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
230
+ <rect x="3" y="3" width="18" height="18" rx="2" fill="#7C3AED" fillOpacity="0.2"/>
231
+ <rect x="5" y="9" width="14" height="6" rx="1" fill="black" fillOpacity="0.3"/>
232
+ <defs>
233
+ <linearGradient id="grad1" x1="0%" y1="0%" x2="100%" y2="0%">
234
+ <stop offset="0%" stopColor="#A78BFA"/>
235
+ <stop offset="100%" stopColor="#F472B6"/>
236
+ </linearGradient>
237
+ </defs>
238
+ <text x="12" y="15" fontFamily="Arial, sans-serif" fontSize="8" fontWeight="bold" textAnchor="middle" fill="url(#grad1)">Aa</text>
239
+ </svg>
240
+ );
241
+
242
+ export const LayoutContornoIcon: React.FC<IconProps> = (props) => (
243
+ <svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
244
+ <rect x="3" y="3" width="18" height="18" rx="2" fill="#7C3AED" fillOpacity="0.2"/>
245
+ <text x="12" y="15" fontFamily="Arial, sans-serif" fontSize="8" fontWeight="bold" textAnchor="middle" stroke="white" strokeWidth="0.5" fill="none">Aa</text>
246
+ </svg>
247
+ );
248
+
249
+ export const LayoutLegivelIcon: React.FC<IconProps> = (props) => (
250
+ <svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
251
+ <rect x="3" y="3" width="18" height="18" rx="2" fill="#7C3AED" fillOpacity="0.2"/>
252
+ <rect x="5" y="9" width="14" height="6" rx="1" fill="black" fillOpacity="0.5"/>
253
+ <text x="12" y="15" fontFamily="Arial, sans-serif" fontSize="8" fontWeight="bold" textAnchor="middle" fill="white">Aa</text>
254
+ </svg>
255
+ );
256
+
257
+ export const LayoutVerticalIcon: React.FC<IconProps> = (props) => (
258
+ <svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
259
+ <rect x="3" y="3" width="18" height="18" rx="2" fill="#7C3AED" fillOpacity="0.2"/>
260
+ <text x="8" y="17" fontFamily="Arial, sans-serif" fontSize="8" fontWeight="bold" transform="rotate(-90 8 12)" fill="white">Aa</text>
261
+ </svg>
262
+ );
263
+
264
+ // --- NEW Position Icons ---
265
+ export const PositionCenterIcon: React.FC<IconProps> = (props) => {
266
+ return (
267
+ <svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
268
+ <rect x="3" y="3" width="18" height="18" rx="2" stroke="currentColor" strokeWidth="1.5" strokeOpacity="0.3" strokeDasharray="3 3"/>
269
+ <rect x="7" y="9" width="10" height="6" rx="1" fill="currentColor" />
270
+ </svg>
271
+ );
272
+ };
273
+
274
+ export const PositionTopIcon: React.FC<IconProps> = (props) => {
275
+ return (
276
+ <svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
277
+ <rect x="3" y="3" width="18" height="18" rx="2" stroke="currentColor" strokeWidth="1.5" strokeOpacity="0.3" strokeDasharray="3 3"/>
278
+ <rect x="7" y="5" width="10" height="6" rx="1" fill="currentColor" />
279
+ </svg>
280
+ );
281
+ };
282
+
283
+ export const PositionBottomIcon: React.FC<IconProps> = (props) => {
284
+ return (
285
+ <svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
286
+ <rect x="3" y="3" width="18" height="18" rx="2" stroke="currentColor" strokeWidth="1.5" strokeOpacity="0.3" strokeDasharray="3 3"/>
287
+ <rect x="7" y="13" width="10" height="6" rx="1" fill="currentColor" />
288
+ </svg>
289
+ );
290
+ };
291
+
292
+ export const PositionLeftIcon: React.FC<IconProps> = (props) => {
293
+ return (
294
+ <svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
295
+ <rect x="3" y="3" width="18" height="18" rx="2" stroke="currentColor" strokeWidth="1.5" strokeOpacity="0.3" strokeDasharray="3 3"/>
296
+ <rect x="5" y="7" width="6" height="10" rx="1" fill="currentColor" />
297
+ </svg>
298
+ );
299
+ };
300
+
301
+ export const PositionRightIcon: React.FC<IconProps> = (props) => {
302
+ return (
303
+ <svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
304
+ <rect x="3" y="3" width="18" height="18" rx="2" stroke="currentColor" strokeWidth="1.5" strokeOpacity="0.3" strokeDasharray="3 3"/>
305
+ <rect x="13" y="7" width="6" height="10" rx="1" fill="currentColor" />
306
+ </svg>
307
+ );
308
+ };
309
+
310
+ export const TrendingUpIcon: React.FC<IconProps> = (props) => (
311
+ <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" {...props}>
312
+ <polyline points="23 6 13.5 15.5 8.5 10.5 1 18"></polyline>
313
+ <polyline points="17 6 23 6 23 12"></polyline>
314
+ </svg>
315
+ );
316
+
317
+ export const LightbulbIcon: React.FC<IconProps> = (props) => (
318
+ <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" {...props}>
319
+ <path d="M15.09 16.05A6.47 6.47 0 0 1 9 12.46a6.47 6.47 0 0 1 6.09-3.59"></path>
320
+ <path d="M12 2a7 7 0 0 0-7 7c0 2.35 1.12 4.45 2.86 5.74"></path>
321
+ <path d="M12 21a2 2 0 0 1-2-2v-1a2 2 0 0 1 2-2h0a2 2 0 0 1 2 2v1a2 2 0 0 1-2 2Z"></path>
322
+ </svg>
323
+ );
324
+
325
+ export const OutlineBlackIcon: React.FC<IconProps> = (props) => (
326
+ <svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
327
+ <rect x="3" y="3" width="18" height="18" rx="2" fill="currentColor" fillOpacity="0.2"/>
328
+ <text x="12" y="15.5" fontFamily="Arial, sans-serif" fontSize="9" fontWeight="bold" textAnchor="middle" stroke="black" strokeWidth="1.2" strokeLinejoin="round" fill="white">Aa</text>
329
+ </svg>
330
+ );
331
+
332
+ export const OutlineWhiteIcon: React.FC<IconProps> = (props) => (
333
+ <svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
334
+ <rect x="3" y="3" width="18" height="18" rx="2" fill="currentColor" fillOpacity="0.2"/>
335
+ <text x="12" y="15.5" fontFamily="Arial, sans-serif" fontSize="9" fontWeight="bold" textAnchor="middle" stroke="white" strokeWidth="1.2" strokeLinejoin="round" fill="black">Aa</text>
336
+ </svg>
337
+ );
338
+
339
+ export const OutlineShadowIcon: React.FC<IconProps> = (props) => (
340
+ <svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
341
+ <defs>
342
+ <filter id="icon_shadow_filter" x="-20%" y="-20%" width="140%" height="140%">
343
+ <feOffset dx="0.5" dy="0.5" in="SourceAlpha" result="off"/>
344
+ <feGaussianBlur in="off" stdDeviation="0.5" result="blur"/>
345
+ <feFlood floodColor="black" floodOpacity="0.7" result="color"/>
346
+ <feComposite in="color" in2="blur" operator="in" result="shadow"/>
347
+ <feMerge>
348
+ <feMergeNode in="shadow"/>
349
+ <feMergeNode in="SourceGraphic"/>
350
+ </feMerge>
351
+ </filter>
352
+ </defs>
353
+ <rect x="3" y="3" width="18" height="18" rx="2" fill="currentColor" fillOpacity="0.2"/>
354
+ <text x="12" y="15.5" fontFamily="Arial, sans-serif" fontSize="9" fontWeight="bold" textAnchor="middle" fill="white" filter="url(#icon_shadow_filter)">Aa</text>
355
+ </svg>
356
+ );
357
+
358
+ export const OutlineBoxIcon: React.FC<IconProps> = (props) => (
359
+ <svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
360
+ <rect x="3" y="3" width="18" height="18" rx="2" fill="currentColor" fillOpacity="0.2"/>
361
+ <rect x="5" y="10" width="14" height="7" rx="1.5" fill="black" fillOpacity="0.5" />
362
+ <text x="12" y="15.5" fontFamily="Arial, sans-serif" fontSize="7" fontWeight="bold" textAnchor="middle" fill="white">Aa</text>
363
+ </svg>
364
+ );
365
+
366
+ export const EditIcon: React.FC<IconProps> = (props) => (
367
+ <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" {...props}>
368
+ <path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path>
369
+ <path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"></path>
370
+ </svg>
371
+ );
372
+
373
+ export const TextQuoteIcon: React.FC<IconProps> = (props) => (
374
+ <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" {...props}>
375
+ <path d="M17 6H3"></path>
376
+ <path d="M21 12H3"></path>
377
+ <path d="M15 18H3"></path>
378
+ </svg>
379
+ );
380
+
381
+
382
+ // --- Price Tag Icons ---
383
+ export const PriceTagCircleIcon: React.FC<IconProps> = (props) => (
384
+ <svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
385
+ <circle cx="12" cy="12" r="9" fill="currentColor" fillOpacity="0.8"/>
386
+ <text x="12" y="14" textAnchor="middle" fontSize="6" fontWeight="bold" fill="white">R$</text>
387
+ </svg>
388
+ );
389
+ export const PriceTagRectIcon: React.FC<IconProps> = (props) => (
390
+ <svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
391
+ <rect x="4" y="8" width="16" height="8" rx="2" fill="currentColor" fillOpacity="0.8"/>
392
+ <text x="12" y="14" textAnchor="middle" fontSize="6" fontWeight="bold" fill="white">R$</text>
393
+ </svg>
394
+ );
395
+ export const PriceTagBurstIcon: React.FC<IconProps> = (props) => (
396
+ <svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
397
+ <path d="M12 2.5l1.9 4.3 4.8.7-3.5 3.4.8 4.8-4.3-2.3-4.3 2.3.8-4.8-3.5-3.4 4.8-.7L12 2.5z" fill="currentColor" fillOpacity="0.8" transform="scale(1.2) translate(-2, -2)"/>
398
+ <text x="12" y="14" textAnchor="middle" fontSize="5" fontWeight="bold" fill="white">R$</text>
399
+ </svg>
400
+ );
401
+
402
+ export const PositionTopLeftIcon: React.FC<IconProps> = (props) => (
403
+ <svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
404
+ <rect x="3" y="3" width="18" height="18" rx="2" stroke="currentColor" strokeWidth="1.5" strokeOpacity="0.3" strokeDasharray="3 3"/>
405
+ <circle cx="7" cy="7" r="4" fill="currentColor"/>
406
+ </svg>
407
+ );
408
+ export const PositionTopRightIcon: React.FC<IconProps> = (props) => (
409
+ <svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
410
+ <rect x="3" y="3" width="18" height="18" rx="2" stroke="currentColor" strokeWidth="1.5" strokeOpacity="0.3" strokeDasharray="3 3"/>
411
+ <circle cx="17" cy="7" r="4" fill="currentColor"/>
412
+ </svg>
413
+ );
414
+ export const PositionBottomLeftIcon: React.FC<IconProps> = (props) => (
415
+ <svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
416
+ <rect x="3" y="3" width="18" height="18" rx="2" stroke="currentColor" strokeWidth="1.5" strokeOpacity="0.3" strokeDasharray="3 3"/>
417
+ <circle cx="7" cy="17" r="4" fill="currentColor"/>
418
+ </svg>
419
+ );
420
+ export const PositionBottomRightIcon: React.FC<IconProps> = (props) => (
421
+ <svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
422
+ <rect x="3" y="3" width="18" height="18" rx="2" stroke="currentColor" strokeWidth="1.5" strokeOpacity="0.3" strokeDasharray="3 3"/>
423
+ <circle cx="17" cy="17" r="4" fill="currentColor"/>
424
+ </svg>
425
+ );
426
+ export const XCircleIcon: React.FC<IconProps> = (props) => (
427
+ <svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
428
+ <circle cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="1.5" strokeOpacity="0.3"/>
429
+ <line x1="8" y1="8" x2="16" y2="16" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"/>
430
+ <line x1="16" y1="8" x2="8" y2="16" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"/>
431
+ </svg>
432
+ );
entrypoint.sh ADDED
File without changes
index.html ADDED
@@ -0,0 +1,50 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="pt-BR">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>InstaStyle - Crie Posts com IA</title>
7
+ <link rel="preconnect" href="https://fonts.googleapis.com">
8
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
9
+ <link href="https://fonts.googleapis.com/css2?family=Anton&family=Bangers&family=Lobster&family=Playfair+Display:ital,wght@0,400..900;1,400..900&family=Poppins:wght@400;500;700;900&display=swap" rel="stylesheet">
10
+ <script src="https://cdn.tailwindcss.com"></script>
11
+ <style>
12
+ body {
13
+ font-family: 'Poppins', sans-serif;
14
+ }
15
+ /* Base scrollbar styles */
16
+ ::-webkit-scrollbar {
17
+ width: 8px;
18
+ height: 8px;
19
+ }
20
+ ::-webkit-scrollbar-track {
21
+ background: #e5e7eb; /* bg-gray-200 */
22
+ }
23
+ ::-webkit-scrollbar-thumb {
24
+ background: #9ca3af; /* bg-gray-400 */
25
+ border-radius: 4px;
26
+ }
27
+ ::-webkit-scrollbar-thumb:hover {
28
+ background: #6b7280; /* bg-gray-500 */
29
+ }
30
+ </style>
31
+ <script type="importmap">
32
+ {
33
+ "imports": {
34
+ "react/": "https://esm.sh/react@^19.1.0/",
35
+ "react": "https://esm.sh/react@^19.1.0",
36
+ "react-dom/": "https://esm.sh/react-dom@^19.1.0/",
37
+ "@supabase/supabase-js": "https://esm.sh/@supabase/supabase-js@^2.50.5",
38
+ "@google/genai": "https://esm.sh/@google/genai@^1.9.0",
39
+ "@supabase/gotrue-js": "https://esm.sh/@supabase/gotrue-js@^2.71.1"
40
+ }
41
+ }
42
+ </script>
43
+ <script src="/config.js"></script>
44
+ <link rel="stylesheet" href="/index.css">
45
+ </head>
46
+ <body>
47
+ <div id="root"></div>
48
+ <script type="module" src="/index.tsx"></script>
49
+ </body>
50
+ </html>
index.tsx ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ import React from 'react';
3
+ import ReactDOM from 'react-dom/client';
4
+ import App from './App';
5
+
6
+ const rootElement = document.getElementById('root');
7
+ if (!rootElement) {
8
+ throw new Error("Could not find root element to mount to");
9
+ }
10
+
11
+ const root = ReactDOM.createRoot(rootElement);
12
+ root.render(
13
+ <React.StrictMode>
14
+ <App />
15
+ </React.StrictMode>
16
+ );
lib/compositions.ts ADDED
@@ -0,0 +1,129 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { CompositionPreset } from '@/types';
2
+ import {
3
+ LayoutRandomIcon,
4
+ LayoutImpactoIcon,
5
+ LayoutDegradeIcon,
6
+ LayoutContornoIcon,
7
+ LayoutLegivelIcon,
8
+ LayoutVerticalIcon,
9
+ OutlineWhiteIcon
10
+ } from '@/components/icons';
11
+
12
+ export const compositionPresets: CompositionPreset[] = [
13
+ {
14
+ id: 'random',
15
+ name: 'Aleatório',
16
+ icon: LayoutRandomIcon,
17
+ config: { // Config is a placeholder, logic is handled in the component
18
+ style: { name: 'fill-stroke', palette: 'light' },
19
+ rotation: true,
20
+ subtitle: true
21
+ }
22
+ },
23
+ {
24
+ id: 'impacto-light',
25
+ name: 'Impacto (Claro)',
26
+ icon: LayoutImpactoIcon,
27
+ config: {
28
+ style: { name: 'fill-stroke', palette: 'light' },
29
+ rotation: true,
30
+ subtitle: true
31
+ }
32
+ },
33
+ {
34
+ id: 'impacto-dark',
35
+ name: 'Impacto (Escuro)',
36
+ icon: LayoutImpactoIcon,
37
+ config: {
38
+ style: { name: 'fill-stroke', palette: 'dark' },
39
+ rotation: true,
40
+ subtitle: true
41
+ }
42
+ },
43
+ {
44
+ id: 'impacto-vibrant',
45
+ name: 'Impacto (Vibrante)',
46
+ icon: LayoutImpactoIcon,
47
+ config: {
48
+ style: { name: 'fill-stroke', palette: 'complementary' },
49
+ rotation: true,
50
+ subtitle: true
51
+ }
52
+ },
53
+ {
54
+ id: 'impacto-contorno-branco',
55
+ name: 'Impacto (Contorno Branco)',
56
+ icon: OutlineWhiteIcon,
57
+ config: {
58
+ style: {
59
+ name: 'fill-stroke',
60
+ palette: 'dark',
61
+ forcedStroke: 'white',
62
+ },
63
+ rotation: true,
64
+ subtitle: true
65
+ }
66
+ },
67
+ {
68
+ id: 'legivel-light',
69
+ name: 'Legível (Fundo Escuro)',
70
+ icon: LayoutLegivelIcon,
71
+ config: {
72
+ style: {
73
+ name: 'fill',
74
+ palette: 'light',
75
+ background: { color: 'rgba(0, 0, 0, 0.5)', padding: 0.2 }
76
+ },
77
+ rotation: false,
78
+ subtitle: true
79
+ }
80
+ },
81
+ {
82
+ id: 'legivel-dark',
83
+ name: 'Legível (Fundo Claro)',
84
+ icon: LayoutLegivelIcon,
85
+ config: {
86
+ style: {
87
+ name: 'fill',
88
+ palette: 'dark',
89
+ background: { color: 'rgba(255, 255, 255, 0.6)', padding: 0.2 }
90
+ },
91
+ rotation: false,
92
+ subtitle: true
93
+ }
94
+ },
95
+ {
96
+ id: 'degrade',
97
+ name: 'Degradê',
98
+ icon: LayoutDegradeIcon,
99
+ config: {
100
+ style: {
101
+ name: 'gradient-on-block',
102
+ palette: 'complementary',
103
+ background: { color: 'rgba(0, 0, 0, 0.4)', padding: 0.15 }
104
+ },
105
+ rotation: false,
106
+ subtitle: true
107
+ }
108
+ },
109
+ {
110
+ id: 'contorno',
111
+ name: 'Contorno',
112
+ icon: LayoutContornoIcon,
113
+ config: {
114
+ style: { name: 'stroke', palette: 'light' },
115
+ rotation: false,
116
+ subtitle: true
117
+ }
118
+ },
119
+ {
120
+ id: 'vertical',
121
+ name: 'Vertical',
122
+ icon: LayoutVerticalIcon,
123
+ config: {
124
+ style: { name: 'vertical', palette: 'light' },
125
+ rotation: false,
126
+ subtitle: false
127
+ }
128
+ },
129
+ ];
lib/ctas.ts ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ // This file is reserved for Call-to-Action (CTA) related constants or functions.
2
+ // It is currently not in use but is kept for future development.
3
+ export {};
lib/errors.ts ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Custom error for API rate limiting (429 errors).
3
+ */
4
+ export class RateLimitError extends Error {
5
+ constructor(message: string) {
6
+ super(message);
7
+ this.name = 'RateLimitError';
8
+ }
9
+ }
lib/options.ts ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { TextPosition, SubtitleOutlineStyle, PriceTagStyleId, PriceTagPosition, PriceTagColor } from '@/types';
2
+ import {
3
+ PositionCenterIcon, PositionTopIcon, PositionBottomIcon, PositionLeftIcon, PositionRightIcon,
4
+ SparklesIcon, OutlineWhiteIcon, OutlineBlackIcon, OutlineShadowIcon, OutlineBoxIcon,
5
+ PriceTagCircleIcon, PriceTagRectIcon, PriceTagBurstIcon,
6
+ PositionTopLeftIcon, PositionTopRightIcon, PositionBottomLeftIcon, PositionBottomRightIcon, XCircleIcon
7
+ } from '@/components/icons';
8
+
9
+ export const positionOptions: { id: TextPosition; name: string; icon: React.FC<React.SVGProps<SVGSVGElement>> }[] = [
10
+ { id: 'left', name: 'Esquerda', icon: PositionLeftIcon },
11
+ { id: 'center', name: 'Centro', icon: PositionCenterIcon },
12
+ { id: 'right', name: 'Direita', icon: PositionRightIcon },
13
+ { id: 'top', name: 'Topo', icon: PositionTopIcon },
14
+ { id: 'bottom', name: 'Base', icon: PositionBottomIcon },
15
+ ];
16
+
17
+ export const subtitleOutlineOptions: { id: SubtitleOutlineStyle; name: string; icon: React.FC<React.SVGProps<SVGSVGElement>> }[] = [
18
+ { id: 'auto', name: 'Automático', icon: SparklesIcon },
19
+ { id: 'white', name: 'Contorno Branco', icon: OutlineWhiteIcon },
20
+ { id: 'black', name: 'Contorno Preto', icon: OutlineBlackIcon },
21
+ { id: 'soft_shadow', name: 'Sombra Suave', icon: OutlineShadowIcon },
22
+ { id: 'transparent_box', name: 'Caixa de Fundo', icon: OutlineBoxIcon },
23
+ ];
24
+
25
+ // --- Price Tag Options ---
26
+ export const priceStyleOptions: { id: PriceTagStyleId; name: string; icon: React.FC<React.SVGProps<SVGSVGElement>> }[] = [
27
+ { id: 'circle', name: 'Círculo', icon: PriceTagCircleIcon },
28
+ { id: 'tag', name: 'Tag', icon: PriceTagRectIcon },
29
+ { id: 'burst', name: 'Explosão', icon: PriceTagBurstIcon },
30
+ ];
31
+
32
+ export const pricePositionOptions: { id: PriceTagPosition; name: string; icon: React.FC<React.SVGProps<SVGSVGElement>> }[] = [
33
+ { id: 'none', name: 'Nenhum', icon: XCircleIcon },
34
+ { id: 'top-left', name: 'Sup. Esquerdo', icon: PositionTopLeftIcon },
35
+ { id: 'top-right', name: 'Sup. Direito', icon: PositionTopRightIcon },
36
+ { id: 'bottom-left', name: 'Inf. Esquerdo', icon: PositionBottomLeftIcon },
37
+ { id: 'bottom-right', name: 'Inf. Direito', icon: PositionBottomRightIcon },
38
+ ];
39
+
40
+ export const priceColorOptions: { id: PriceTagColor; name: string; hex: string }[] = [
41
+ { id: 'red', name: 'Vermelho', hex: '#ef4444' }, // red-500
42
+ { id: 'yellow', name: 'Amarelo', hex: '#f59e0b' }, // amber-500
43
+ { id: 'blue', name: 'Azul', hex: '#3b82f6' }, // blue-500
44
+ { id: 'black', name: 'Preto', hex: '#1f2937' }, // gray-800
45
+ ];
lib/styles.ts ADDED
@@ -0,0 +1,220 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Separating styles into visual art styles and professional themes for targeted AI prompting.
2
+
3
+ export const artStyles = [
4
+ '007 Golden Eye', '007 Pierce Brosnan', '007 Roger Moore', '007 Sean Connery', '007 Solace',
5
+ '3d-model', 'Andy Warhol', 'Art Déco (1925)', 'Art Nouveau', 'Arquivo X', 'Bauhaus (1919)',
6
+ 'Barrados no Baile', 'Cine Citá', 'Cinematográfico', 'Claude Monet', 'Colagem', 'Comic-book',
7
+ 'Cyberpunk', 'DC Comics', 'Da Vinci', 'Dancing Script', 'Disney - Pixar', 'Edgar Degas',
8
+ 'Édouard Manet', 'Estilo High Society', 'Estilo Lago di Como', 'Estilo Milano 1950',
9
+ 'Estilo Milano 1980', 'Estilo Napoli 1980', 'Estilo UX Design 2025', 'Fantasy-art',
10
+ 'Festival de San Remo', 'Flat', 'Fotorrealista', 'FraseUrbane', 'Friends', 'Futurismo',
11
+ 'Geométrico', 'Estilização Geométrica', 'Gossip Girl', 'Holográfico', 'Esqueceram de Mim',
12
+ 'Isométrico', 'Jim Davis - Garfield', 'Liga da Justiça', 'Law and Order', 'Lobster',
13
+ 'O Lobo de Wall Street', 'Luke Skywalker', 'Luxuoso', 'Maximalismo', 'Meme', 'Meme Clássico',
14
+ 'Meme Regional Trend', 'Meme Trend', 'Menphys', 'Minimalista', 'Neo Brutalismo',
15
+ 'Nova Trend - Trend em alta no Instagram', 'Old Money Napoli', 'Old Money New York',
16
+ 'Paul Rand (1940)', 'Pierre-Auguste Renoir', 'Pixel Art', 'Playfair Display', 'Poético',
17
+ 'Pop Italiano 1980', 'Pós-modernismo (década de 1970)', 'Retrô', 'Roland Garros',
18
+ 'Roma 1980', 'Sex and the City', 'Stan Lee - Marvel', 'Star Wars', 'Star Wars 1980',
19
+ 'Supernatural', 'Surrealismo', 'O Talentoso Ripley', 'That\'s \'70s Show',
20
+ 'The Big Bang Theory', 'Um Maluco no Pedaço', 'Tintin - Hergé', 'Tom Cruise - Missão Impossível',
21
+ 'Two and a Half Men', 'UX Design 2026', 'Van Gogh', 'Wiener Werkstätte (1903)'
22
+ ];
23
+
24
+ const interiorDesignRooms = [
25
+ 'Banheiro (Suíte)',
26
+ 'Closet',
27
+ 'Cozinha',
28
+ 'Home Theater',
29
+ 'Lavabo',
30
+ 'Mesa de Apoio (Sala)',
31
+ 'Sala de Estar',
32
+ ];
33
+
34
+ const interiorDesignThemesCasa = interiorDesignRooms.map(d => `Design de Interiores (Casa): ${d}`);
35
+ const interiorDesignThemesApto = interiorDesignRooms.map(d => `Design de Interiores (Apartamento): ${d}`);
36
+
37
+ export const professionalThemes = [
38
+ 'Adega',
39
+ 'Advocacia Família',
40
+ 'Advocacia Trabalhista',
41
+ 'Agência de Marketing Digital',
42
+ 'Agência de Viagens',
43
+ 'Aluguel de brinquedos para festa infantil',
44
+ 'Animação de festa infantil',
45
+ 'Aplicativo de Delivery',
46
+ 'Aplicativo tipo Booking',
47
+ 'Arquiteto',
48
+ 'Assessoria de Imagem Feminina',
49
+ 'Assessoria de Imagem Masculina',
50
+ 'Autoescola',
51
+ 'Barbearia',
52
+ 'Beach Tênis (Esporte)',
53
+ 'Beleza e Cosméticos',
54
+ 'Bike',
55
+ 'Cafeteria',
56
+ 'Carpintaria',
57
+ 'Chatbot',
58
+ 'Chef de Cozinha',
59
+ 'Clínica de Fisioterapia',
60
+ 'Clínica Estética',
61
+ 'Clínica Veterinária',
62
+ 'Clube de alta classe',
63
+ 'Clube de empresários de alta classe',
64
+ 'Clube de Tênis (Esporte)',
65
+ 'Coach de Carreira',
66
+ 'Concessionária de Carros',
67
+ 'Conserto de Bike',
68
+ 'Conserto de Celular',
69
+ 'Construtora',
70
+ 'Contabilidade',
71
+ 'Corretor de Imóveis',
72
+ 'Decoração',
73
+ 'Decoração de Festas',
74
+ 'Dentista',
75
+ 'Depilação Feminina',
76
+ 'Desenvolvimento Pessoal',
77
+ 'Desenvolvimento Web',
78
+ ...interiorDesignThemesCasa,
79
+ ...interiorDesignThemesApto,
80
+ 'Design Gráfico',
81
+ 'Design Thinking',
82
+ 'Doces para Festas',
83
+ 'E-commerce de Moda',
84
+ 'Educação',
85
+ 'Eletricista',
86
+ 'Encanador',
87
+ 'Energia Solar',
88
+ 'Engenheiro Civil',
89
+ 'Escola de Idiomas',
90
+ 'Espelhos',
91
+ 'Estúdio de Fotografia',
92
+ 'Estúdio de Tatuagem',
93
+ 'Fabricante de chalets',
94
+ 'Farmácia',
95
+ 'Festa de Casamento',
96
+ 'Festas e Eventos',
97
+ 'Filmaker',
98
+ 'Finanças Pessoais',
99
+ 'Fitness e Saúde',
100
+ 'Floricultura',
101
+ 'Fotógrafo',
102
+ 'Gastronomia',
103
+ 'Gestor de Tráfego',
104
+ 'Hamburgueria',
105
+ 'Hotelaria',
106
+ 'Imobiliária',
107
+ 'Investimentos',
108
+ 'Jardinagem',
109
+ 'Loja de Roupas',
110
+ 'Loja de Tênis (Calçados)',
111
+ 'Manicure e Pedicure',
112
+ 'Marca de Joias',
113
+ 'Marcenaria',
114
+ 'Marketing de Afiliados',
115
+ 'Mecânica de Automóveis',
116
+ 'Mercado',
117
+ 'Música',
118
+ 'Nova Loja de: Presentes (Design Autoral)',
119
+ 'Nova Marca de: Alimentos Fit',
120
+ 'Nova Marca de: Bicicleta Elétrica',
121
+ 'Nova Marca de: Bloco Ecológico para Montagem e Construção de Casas',
122
+ 'Nova Marca de: Brinquedos Educativos Infantis',
123
+ 'Nova Marca de: Brinquedos Infantis',
124
+ 'Nova Marca de: Café',
125
+ 'Nova Marca de: Casas e Construções Modulares e Industrializadas',
126
+ 'Nova Marca de: Casas Modulares (Industrializadas) para Locais Remotos',
127
+ 'Nova Marca de: Chocolate Belga',
128
+ 'Nova Marca de: Chocolates',
129
+ 'Nova Marca de: Chocolates Gianduia',
130
+ 'Nova Marca de: Chocolates Praline',
131
+ 'Nova Marca de: Doces Italianos',
132
+ 'Nova Marca de: Doces para Franquia',
133
+ 'Nova Marca de: Equipamentos para Academia',
134
+ 'Nova Marca de: Fast Fashion Feminina',
135
+ 'Nova Marca de: Fast Fashion Masculina',
136
+ 'Nova Marca de: Gadgets para Camping',
137
+ 'Nova Marca de: Gadgets para o Jardim',
138
+ 'Nova Marca de: Gadgets com Energia Solar',
139
+ 'Nova Marca de: Meio de Transporte Individual Elétrico',
140
+ 'Nova Marca de: Mini Moto Elétrica',
141
+ 'Nova Marca de: Módulo Habitacional Industrializado para Estruturas Existentes',
142
+ 'Nova Marca de: Módulos Habitacionais (Industrializados) para Cidades Densamente Povoadas',
143
+ 'Nova Marca de: Óculos',
144
+ 'Nova Marca de: Produtos Naturais',
145
+ 'Nova Marca de: Produtos Saudáveis',
146
+ 'Nova Marca de: Relógio',
147
+ 'Nova Marca de: Roupa Casual para Homem',
148
+ 'Nova Marca de: Roupa Casual para Mulher',
149
+ 'Nova Marca de: Roupa para Beach Tênis',
150
+ 'Nova Marca de: Roupa para Cross Fit',
151
+ 'Nova Marca de: Salgados para Franquia',
152
+ 'Nova Marca de: Suplementação para +40',
153
+ 'Nova Marca de: Suplementação para +50',
154
+ 'Nova Marca de: Suplementos Aminoácidos',
155
+ 'Nova Marca de: Suplementos Proteína',
156
+ 'Nova Marca de: SUV',
157
+ 'Nova Marca de: Tênis (Calçados)',
158
+ 'Nutricionista',
159
+ 'Ótica',
160
+ 'Padaria',
161
+ 'Paisagismo',
162
+ 'Papelaria',
163
+ 'Personal Organizer',
164
+ 'Personal Trainer',
165
+ 'Pet Shop',
166
+ 'Pilates',
167
+ 'Pizzaria',
168
+ 'Podcast',
169
+ 'Produção de Eventos',
170
+ 'Professor Particular',
171
+ 'Psicologia',
172
+ 'Publicidade',
173
+ 'Restaurante',
174
+ 'Salão de Beleza',
175
+ 'Segurança Eletrônica',
176
+ 'Seguros',
177
+ 'Serviços de Limpeza',
178
+ 'Social Media Manager',
179
+ 'Sorveteria',
180
+ 'Startup de Tecnologia',
181
+ 'Tênis de Quadra (Esporte)',
182
+ 'Terapia Holística',
183
+ 'Turismo',
184
+ 'Yoga'
185
+ ].sort((a, b) => a.localeCompare(b));
186
+
187
+ export const masterPromptText = `Você é um assistente de criação de conteúdo de IA, especializado em gerar posts para redes sociais que são visualmente atraentes e textualmente persuasivos.
188
+
189
+ Sua tarefa é processar a solicitação do usuário e gerar dois artefatos em uma única resposta:
190
+ 1. **Imagem:** Uma imagem que corresponda à "Descrição da Imagem" e aos "Estilos da Imagem" fornecidos.
191
+ 2. **Conteúdo em JSON:** Um objeto JSON que contenha textos de marketing e sugestões criativas baseadas no "Estilo do Conteúdo".
192
+
193
+ **Regras Estritas:**
194
+ - Sempre gere a imagem primeiro, antes do bloco de JSON.
195
+ - O bloco de código JSON deve ser o último elemento da sua resposta.
196
+ - O JSON deve ser válido e seguir estritamente a estrutura definida abaixo.
197
+
198
+ **Estrutura do JSON de Saída:**
199
+ \`\`\`json
200
+ {
201
+ "postText": {
202
+ "title": "Um título curto e impactante para o post.",
203
+ "body": "Um texto principal para o post, com 2 a 3 frases. Use o 'Estilo do Conteúdo' como guia. Pode incluir emojis relevantes.",
204
+ "hashtags": [
205
+ "#hashtag1",
206
+ "#hashtag2",
207
+ "#hashtag3"
208
+ ]
209
+ },
210
+ "strategyTip": "Uma dica de marketing ou estratégia criativa rápida e acionável relacionada ao post gerado."
211
+ }
212
+ \`\`\`
213
+
214
+ **Como Processar a Solicitação do Usuário:**
215
+ O usuário fornecerá as seguintes informações:
216
+ - **Descrição da Imagem:** O que deve estar na imagem.
217
+ - **Estilos da Imagem:** Uma lista de estilos visuais e suas ponderações (ex: "Fotorrealista (70%), Cinematográfico (30%)").
218
+ - **Estilo do Conteúdo:** O tom e a voz para o texto (ex: "Profissional e Confiável", "Divertido e Descontraído").
219
+
220
+ Aja agora. Aguardo a primeira solicitação do usuário.`;
metadata.json ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ {
2
+ "name": "Copy of Copy of Copy of InstaStyle",
3
+ "description": "An application for creating stylish text posts for social media. Features AI image generation with style presets, post previews, and Google authentication.",
4
+ "requestFramePermissions": [],
5
+ "prompt": ""
6
+ }
package.json ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "instastyle-huggingface",
3
+ "version": "1.0.0",
4
+ "description": "An application for creating stylish text posts for social media, ready for Hugging Face Spaces.",
5
+ "private": true,
6
+ "scripts": {
7
+ "start": "serve -s . -l 7860"
8
+ },
9
+ "dependencies": {
10
+ "serve": "^14.2.3"
11
+ }
12
+ }
services/geminiService.ts ADDED
@@ -0,0 +1,511 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { GoogleGenAI, Type } from "@google/genai";
2
+ import type { RegionalityData, AdCopy, AdTrendAnalysis, BrandData, BrandConcept, FeatureDetails } from '@/types';
3
+ import { RateLimitError } from '@/lib/errors';
4
+
5
+ /**
6
+ * Processes a caught API error, classifies it, and throws a new, user-friendly error.
7
+ * This centralizes error handling for all Gemini API calls, making it robust against
8
+ * various error shapes (Error object, JSON string, plain object).
9
+ * @param e The caught error object.
10
+ * @param context A string describing the operation that failed (e.g., 'gerar imagem').
11
+ */
12
+ const processApiError = (e: unknown, context: string): never => {
13
+ console.error(`Erro ao ${context} com a API Gemini:`, e);
14
+
15
+ // Re-throw our specific custom errors if they've already been processed.
16
+ if (e instanceof RateLimitError) {
17
+ throw e;
18
+ }
19
+
20
+ // 1. Get a string representation of the error.
21
+ let errorString: string;
22
+ if (e instanceof Error) {
23
+ errorString = e.message;
24
+ } else if (typeof e === 'string') {
25
+ errorString = e;
26
+ } else {
27
+ try {
28
+ errorString = JSON.stringify(e);
29
+ } catch {
30
+ errorString = 'Ocorreu um erro não-serializável.';
31
+ }
32
+ }
33
+
34
+ // 2. Check for the most critical errors first using robust string matching.
35
+ const lowerCaseError = errorString.toLowerCase();
36
+ if (lowerCaseError.includes('429') || lowerCaseError.includes('quota') || lowerCaseError.includes('resource_exhausted')) {
37
+ throw new RateLimitError("Você excedeu sua cota de uso da API. Por favor, aguarde um momento e tente novamente.");
38
+ }
39
+ if (lowerCaseError.includes('safety') || lowerCaseError.includes('blocked') || lowerCaseError.includes("api não retornou uma imagem")) {
40
+ throw new Error("Seu prompt foi bloqueado pelas políticas de segurança da IA. Por favor, reformule sua solicitação para ser mais neutra e evite termos que possam ser considerados sensíveis.");
41
+ }
42
+ if (lowerCaseError.includes('rpc failed due to xhr error')) {
43
+ throw new Error("Ocorreu um erro de comunicação com a API. Verifique sua conexão e tente novamente.");
44
+ }
45
+
46
+ // 3. Try to parse for a more specific API message.
47
+ try {
48
+ const errorBody = JSON.parse(errorString);
49
+ // Handle nested { error: { message: ... } } or flat { message: ... } structures.
50
+ const apiMsg = errorBody?.error?.message || errorBody?.message;
51
+ if (apiMsg && typeof apiMsg === 'string' && !apiMsg.trim().startsWith('{')) {
52
+ throw new Error(apiMsg);
53
+ }
54
+ } catch {
55
+ // Not a JSON string or parsing failed. The raw string might be the best message if it's not JSON.
56
+ if (!errorString.trim().startsWith('{')) {
57
+ throw new Error(errorString);
58
+ }
59
+ }
60
+
61
+ // 4. Final fallback for unknown errors or JSON we couldn't parse.
62
+ throw new Error("Ocorreu um erro desconhecido ao comunicar com a API.");
63
+ };
64
+
65
+
66
+ export async function generateImage(prompt: string, negativePrompt?: string, numberOfImages: number = 1): Promise<string[]> {
67
+ if (!process.env.API_KEY) {
68
+ throw new Error("A chave da API Gemini não está configurada. Defina a variável de ambiente API_KEY.");
69
+ }
70
+ const ai = new GoogleGenAI({ apiKey: process.env.API_KEY });
71
+
72
+ // Safeguard: The API limit is 4 images per request. Clamp the value to be safe.
73
+ const safeNumberOfImages = Math.max(1, Math.min(numberOfImages, 4));
74
+
75
+ try {
76
+ const params: any = {
77
+ model: 'imagen-3.0-generate-002',
78
+ prompt: prompt,
79
+ config: {
80
+ numberOfImages: safeNumberOfImages, // Use the clamped value
81
+ outputMimeType: 'image/jpeg',
82
+ aspectRatio: '1:1', // Square image for Instagram
83
+ },
84
+ };
85
+
86
+ if (negativePrompt) {
87
+ params.negativePrompt = negativePrompt;
88
+ }
89
+
90
+ const response = await ai.models.generateImages(params);
91
+
92
+ if (response.generatedImages && response.generatedImages.length > 0) {
93
+ return response.generatedImages.map(img => img.image.imageBytes);
94
+ } else {
95
+ // This case is often a safety block. We create a specific error message for it.
96
+ const blockReason = (response as any).promptFeedback?.blockReason;
97
+ let errorMessage = "A API não retornou uma imagem";
98
+ if (blockReason) {
99
+ errorMessage += `, motivo: ${blockReason}.`;
100
+ }
101
+ throw new Error(errorMessage);
102
+ }
103
+ } catch (e) {
104
+ processApiError(e, 'gerar imagem');
105
+ }
106
+ }
107
+
108
+ export async function generateSlogan(brandName: string, theme: string): Promise<{slogan: string}> {
109
+ if (!process.env.API_KEY) {
110
+ throw new Error("API key not configured.");
111
+ }
112
+ const ai = new GoogleGenAI({ apiKey: process.env.API_KEY });
113
+
114
+ const prompt = `
115
+ Aja como um especialista em branding de classe mundial.
116
+ **Tarefa:** Crie um slogan curto, memorável e impactante.
117
+ **Marca:** ${brandName}
118
+ **Nicho/Tema:** ${theme}
119
+ **Requisitos:**
120
+ - O slogan deve ser em português do Brasil.
121
+ - Deve ser conciso (idealmente entre 3 e 7 palavras).
122
+ - Deve refletir o valor ou a personalidade da marca dentro do nicho.
123
+
124
+ **Formato de Saída Obrigatório (JSON):**
125
+ Responda APENAS com um objeto JSON contendo a chave "slogan".
126
+ `;
127
+
128
+ try {
129
+ const response = await ai.models.generateContent({
130
+ model: 'gemini-2.5-flash',
131
+ contents: prompt,
132
+ config: {
133
+ responseMimeType: "application/json",
134
+ responseSchema: {
135
+ type: Type.OBJECT,
136
+ properties: {
137
+ slogan: {
138
+ type: Type.STRING,
139
+ description: "O slogan gerado para a marca."
140
+ }
141
+ },
142
+ required: ["slogan"]
143
+ },
144
+ temperature: 0.9,
145
+ }
146
+ });
147
+
148
+ const jsonResponse = JSON.parse(response.text.trim());
149
+ if (jsonResponse.slogan) {
150
+ return jsonResponse as {slogan: string};
151
+ } else {
152
+ throw new Error("A resposta da API para o slogan está malformada.");
153
+ }
154
+ } catch (e) {
155
+ processApiError(e, 'gerar slogan');
156
+ }
157
+ }
158
+
159
+ export async function generateAdCopy(imageDescription: string, postText: string, theme: string, brandData: BrandData): Promise<AdCopy> {
160
+ if (!process.env.API_KEY) {
161
+ throw new Error("API key not configured.");
162
+ }
163
+ const ai = new GoogleGenAI({ apiKey: process.env.API_KEY });
164
+ const [title, ...subtitleLines] = postText.split('\n');
165
+ const subtitle = subtitleLines.join(' ');
166
+
167
+ const brandClause = brandData.name
168
+ ? `A marca é '${brandData.name}'${brandData.slogan ? ` com o slogan '${brandData.slogan}'` : ''}. A influência da marca na cópia deve ser de ${brandData.weight}%.`
169
+ : 'Nenhuma marca específica definida.';
170
+
171
+ const prompt = `
172
+ Aja como um copywriter de resposta direta de classe mundial, especialista em campanhas pagas para o tema "${theme}".
173
+
174
+ **Contexto do Anúncio:**
175
+ - **Visual:** Uma imagem de "${imageDescription}"
176
+ - **Texto na Imagem:** Título: "${title}", Subtítulo: "${subtitle}"
177
+ - **Branding:** ${brandClause || 'Nenhuma marca específica definida.'}
178
+
179
+ **DIRETRIZ MESTRA E INEGOCIÁVEL:**
180
+ Sua única tarefa é gerar textos para anúncios que sejam **cirurgicamente precisos e persuasivos**.
181
+
182
+ 1. **REVISÃO OBRIGATÓRIA:** Antes de responder, você DEVE revisar cada palavra para garantir:
183
+ - **Coerência Absoluta:** O texto se conecta perfeitamente ao visual e ao texto na imagem.
184
+ - **Zero Nonsense:** Nenhuma palavra aleatória ou sem sentido. Clareza e lógica são inegociáveis.
185
+ - **Gramática Impecável:** A escrita deve ser perfeita, pronta para publicação imediata.
186
+ 2. **FOCO EM CONVERSÃO:** Use a fórmula AIDA (Atenção, Interesse, Desejo, Ação) para maximizar o impacto.
187
+
188
+ **Formato de Saída Obrigatório (JSON):**
189
+ Responda APENAS com um objeto JSON válido para Google e Facebook Ads.
190
+ `;
191
+
192
+ try {
193
+ const response = await ai.models.generateContent({
194
+ model: 'gemini-2.5-flash',
195
+ contents: prompt,
196
+ config: {
197
+ responseMimeType: "application/json",
198
+ responseSchema: {
199
+ type: Type.OBJECT,
200
+ properties: {
201
+ google: {
202
+ type: Type.OBJECT,
203
+ properties: {
204
+ headlines: {
205
+ type: Type.ARRAY,
206
+ items: { type: Type.STRING },
207
+ description: "3 headlines curtos e de alto impacto para Google Ads (máx 30 caracteres)."
208
+ },
209
+ descriptions: {
210
+ type: Type.ARRAY,
211
+ items: { type: Type.STRING },
212
+ description: "2 descrições persuasivas para Google Ads (máx 90 caracteres)."
213
+ }
214
+ }
215
+ },
216
+ facebook: {
217
+ type: Type.OBJECT,
218
+ properties: {
219
+ primaryText: {
220
+ type: Type.STRING,
221
+ description: "Texto principal para o Facebook Ad. Comece com um gancho forte. Use quebras de linha e emojis para legibilidade."
222
+ },
223
+ headline: {
224
+ type: Type.STRING,
225
+ description: "Headline para o Facebook Ad. Focado no benefício principal."
226
+ },
227
+ description: {
228
+ type: Type.STRING,
229
+ description: "Descrição/link para o Facebook Ad. Um CTA claro."
230
+ }
231
+ }
232
+ },
233
+ strategyTip: {
234
+ type: Type.STRING,
235
+ description: "Uma dica de estratégia de marketing acionável e inteligente relacionada a esta campanha específica."
236
+ }
237
+ },
238
+ required: ["google", "facebook", "strategyTip"]
239
+ },
240
+ temperature: 0.8,
241
+ }
242
+ });
243
+
244
+ const jsonResponse = JSON.parse(response.text.trim());
245
+ // Basic validation
246
+ if (jsonResponse.google && jsonResponse.facebook) {
247
+ return jsonResponse as AdCopy;
248
+ } else {
249
+ throw new Error("A resposta da API está malformada.");
250
+ }
251
+
252
+ } catch (e) {
253
+ processApiError(e, 'gerar textos de anúncio');
254
+ }
255
+ }
256
+
257
+
258
+ export async function analyzeAdTrends(theme: string, regionality: RegionalityData, brandData: BrandData): Promise<AdTrendAnalysis> {
259
+ if (!process.env.API_KEY) {
260
+ throw new Error("API key not configured.");
261
+ }
262
+ const ai = new GoogleGenAI({ apiKey: process.env.API_KEY });
263
+
264
+ const locationParts = [regionality.neighborhood, regionality.city, regionality.country].filter(Boolean);
265
+ const locationClause = locationParts.length > 0 && regionality.weight > 10
266
+ ? `com foco na região de ${locationParts.join(', ')} (peso de influência: ${regionality.weight}%)`
267
+ : 'com um foco global';
268
+
269
+ const brandClause = brandData.name
270
+ ? `A marca em questão é '${brandData.name}' e sua influência criativa deve ser considerada em ${brandData.weight}% das sugestões.`
271
+ : 'As sugestões devem ser genéricas para o tema, sem uma marca específica.';
272
+
273
+ const prompt = `
274
+ Aja como um diretor de criação e copywriter sênior, especialista em campanhas virais para o tema "${theme}". Seu foco é ${locationClause}.
275
+
276
+ **Contexto da Marca:**
277
+ ${brandClause}
278
+
279
+ **DIRETRIZ MESTRA E INEGOCIÁVEL:**
280
+ Sua única tarefa é gerar 3 conceitos de anúncio que sejam **IMPECÁVEIS**. Cada palavra deve ser intencional, coerente e poderosa.
281
+
282
+ 1. **REVISÃO OBRIGATÓRIA:** Antes de responder, você DEVE revisar cada palavra para garantir que:
283
+ - O texto é 100% relevante para "${theme}".
284
+ - Não existem palavras ou frases sem sentido, aleatórias, ou inventadas. A escrita é clara e lógica.
285
+ - A gramática e ortografia são PERFEITAS, como se fossem para um cliente de altíssimo padrão.
286
+ 2. **FOCO EM APLICAÇÃO REAL:** As ideias devem ser tão boas que um profissional de marketing as usaria imediatamente em uma campanha real.
287
+
288
+ **Formato de Saída Obrigatório (JSON):**
289
+ Responda APENAS com um objeto JSON válido, contendo uma análise de tendências, 3 ideias de anúncio e hashtags.
290
+ `;
291
+
292
+ try {
293
+ const response = await ai.models.generateContent({
294
+ model: 'gemini-2.5-flash',
295
+ contents: prompt,
296
+ config: {
297
+ responseMimeType: "application/json",
298
+ responseSchema: {
299
+ type: Type.OBJECT,
300
+ properties: {
301
+ trendOverview: {
302
+ type: Type.STRING,
303
+ description: "Análise concisa (2-3 frases) sobre as tendências de anúncios para este tema. Fale sobre formatos (vídeo, carrossel), ganchos e estéticas que estão funcionando AGORA."
304
+ },
305
+ adIdeas: {
306
+ type: Type.ARRAY,
307
+ description: "Uma lista com exatamente 3 ideias de anúncio.",
308
+ items: {
309
+ type: Type.OBJECT,
310
+ properties: {
311
+ conceptName: {
312
+ type: Type.STRING,
313
+ description: "Nome do conceito do anúncio (Ex: 'O Segredo que Ninguém Conta', 'Sua Jornada em 15s')."
314
+ },
315
+ headline: {
316
+ type: Type.STRING,
317
+ description: "Um título de anúncio magnético e curto."
318
+ },
319
+ primaryText: {
320
+ type: Type.STRING,
321
+ description: "O texto principal do anúncio. Use quebras de linha e emojis para legibilidade. Deve ser atraente, claro e direto."
322
+ },
323
+ replicabilityTip: {
324
+ type: Type.STRING,
325
+ description: "Uma dica rápida sobre como replicar o visual deste anúncio (ex: 'Use um vídeo POV mostrando...', 'Crie um carrossel com fotos de clientes...')."
326
+ }
327
+ }
328
+ }
329
+ },
330
+ hashtags: {
331
+ type: Type.ARRAY,
332
+ items: { type: Type.STRING },
333
+ description: "Uma lista de 5-7 hashtags relevantes, misturando hashtags de alto volume com nicho."
334
+ }
335
+ },
336
+ required: ["trendOverview", "adIdeas", "hashtags"]
337
+ },
338
+ temperature: 0.8,
339
+ }
340
+ });
341
+
342
+ const jsonResponse = JSON.parse(response.text.trim());
343
+ if (jsonResponse.adIdeas) {
344
+ return jsonResponse as AdTrendAnalysis;
345
+ } else {
346
+ throw new Error("A resposta da API está malformada.");
347
+ }
348
+
349
+ } catch (e) {
350
+ processApiError(e, 'analisar tendências');
351
+ }
352
+ }
353
+
354
+ export async function generateProductConcepts(basePrompt: string, productType: string): Promise<BrandConcept[]> {
355
+ if (!process.env.API_KEY) throw new Error("API key not configured.");
356
+ const ai = new GoogleGenAI({ apiKey: process.env.API_KEY });
357
+ const prompt = `
358
+ Aja como um estúdio de design e branding de renome mundial (pense em Pentagram, IDEO).
359
+ **Tarefa:** Gerar 3 conceitos de marca distintos e criativos para um novo produto.
360
+ **Ideia Central do Usuário:** "${basePrompt}"
361
+ **Tipo de Produto:** ${productType}
362
+
363
+ **Diretrizes Rígidas:**
364
+ 1. **Originalidade:** Crie nomes e filosofias que se destaquem. Evite o clichê.
365
+ 2. **Clareza:** O 'visualStyle' deve ser descritivo e evocativo, pintando um quadro claro para um designer.
366
+ 3. **Ação:** As 'keywords' devem ser termos de busca poderosos que um IA de imagem pode usar.
367
+
368
+ **Formato de Saída Obrigatório (JSON Array):**
369
+ Responda APENAS com um array JSON contendo exatamente 3 objetos.
370
+ `;
371
+ try {
372
+ const response = await ai.models.generateContent({
373
+ model: 'gemini-2.5-flash',
374
+ contents: prompt,
375
+ config: {
376
+ responseMimeType: "application/json",
377
+ responseSchema: {
378
+ type: Type.ARRAY,
379
+ items: {
380
+ type: Type.OBJECT,
381
+ properties: {
382
+ name: { type: Type.STRING, description: "Nome da marca/produto. Curto, forte e único." },
383
+ philosophy: { type: Type.STRING, description: "O 'porquê' da marca. Um slogan ou frase de missão curta e inspiradora." },
384
+ visualStyle: { type: Type.STRING, description: "Descrição do design do produto, materiais, cores e estética geral." },
385
+ keywords: { type: Type.ARRAY, items: { type: Type.STRING }, description: "5-7 palavras-chave para guiar a geração de imagem (ex: 'couro vegano, costura contrastante, minimalista')." }
386
+ },
387
+ required: ["name", "philosophy", "visualStyle", "keywords"]
388
+ }
389
+ },
390
+ temperature: 0.9
391
+ }
392
+ });
393
+ const jsonResponse = JSON.parse(response.text.trim());
394
+ if (Array.isArray(jsonResponse) && jsonResponse.length > 0) {
395
+ return jsonResponse as BrandConcept[];
396
+ } else {
397
+ throw new Error("A resposta da API para conceitos de produto está malformada.");
398
+ }
399
+ } catch(e) {
400
+ processApiError(e, 'gerar conceitos de produto');
401
+ }
402
+ }
403
+
404
+ export async function generateDesignConcepts(basePrompt: string, designType: string): Promise<BrandConcept[]> {
405
+ if (!process.env.API_KEY) throw new Error("API key not configured.");
406
+ const ai = new GoogleGenAI({ apiKey: process.env.API_KEY });
407
+ const prompt = `
408
+ Aja como um arquiteto e designer de interiores de classe mundial (pense em Kelly Wearstler, Philippe Starck).
409
+ **Tarefa:** Gerar 3 conceitos de design distintos para um espaço ou móvel.
410
+ **Ideia Central do Usuário:** "${basePrompt}"
411
+ **Tipo de Design:** ${designType}
412
+
413
+ **Diretrizes Rígidas:**
414
+ 1. **Conceituação Forte:** O 'name' deve ser evocativo, como o nome de uma coleção ou projeto.
415
+ 2. **Narrativa:** A 'philosophy' deve contar uma história sobre a experiência de estar no espaço.
416
+ 3. **Especificidade:** O 'visualStyle' deve detalhar materiais, paleta de cores, iluminação e mobiliário chave.
417
+
418
+ **Formato de Saída Obrigatório (JSON Array):**
419
+ Responda APENAS com um array JSON contendo exatamente 3 objetos.
420
+ `;
421
+ try {
422
+ const response = await ai.models.generateContent({
423
+ model: 'gemini-2.5-flash',
424
+ contents: prompt,
425
+ config: {
426
+ responseMimeType: "application/json",
427
+ responseSchema: {
428
+ type: Type.ARRAY,
429
+ items: {
430
+ type: Type.OBJECT,
431
+ properties: {
432
+ name: { type: Type.STRING, description: "Nome do conceito de design (ex: 'Refúgio Urbano', 'Oásis Moderno')." },
433
+ philosophy: { type: Type.STRING, description: "A narrativa ou sentimento que o design evoca (ex: 'Um santuário de calma na cidade agitada')." },
434
+ visualStyle: { type: Type.STRING, description: "Descrição detalhada de materiais (madeira, concreto), texturas, cores, iluminação e formas." },
435
+ keywords: { type: Type.ARRAY, items: { type: Type.STRING }, description: "5-7 palavras-chave para guiar uma IA de imagem (ex: 'luz natural, madeira de carvalho, linho, minimalista, brutalismo suave')." }
436
+ },
437
+ required: ["name", "philosophy", "visualStyle", "keywords"]
438
+ }
439
+ },
440
+ temperature: 0.9
441
+ }
442
+ });
443
+ const jsonResponse = JSON.parse(response.text.trim());
444
+ if (Array.isArray(jsonResponse) && jsonResponse.length > 0) {
445
+ return jsonResponse as BrandConcept[];
446
+ } else {
447
+ throw new Error("A resposta da API para conceitos de design está malformada.");
448
+ }
449
+ } catch(e) {
450
+ processApiError(e, 'gerar conceitos de design');
451
+ }
452
+ }
453
+
454
+ export async function generateFeatureDescriptions(basePrompt: string, concept: BrandConcept): Promise<FeatureDetails[]> {
455
+ if (!process.env.API_KEY) throw new Error("API key not configured.");
456
+ const ai = new GoogleGenAI({ apiKey: process.env.API_KEY });
457
+
458
+ const prompt = `
459
+ Aja como um copywriter técnico e de marketing, especialista em traduzir características de produtos em benefícios claros para o consumidor.
460
+ **Produto:** ${concept.name}
461
+ **Filosofia/Conceito:** "${concept.philosophy}"
462
+ **Descrição do Design:** "${concept.visualStyle}"
463
+ **Ideia Original do Usuário:** "${basePrompt}"
464
+
465
+ **Tarefa Principal:**
466
+ Identifique as 4 características mais importantes, inovadoras ou atraentes do produto descrito. Para cada uma, crie um título curto e uma descrição persuasiva.
467
+
468
+ **Diretrizes Rígidas:**
469
+ 1. **Foco no Benefício:** Não liste apenas a característica. Explique por que ela é importante para o cliente. (Ex: Em vez de "Sola de borracha", use "Aderência Inabalável" com a descrição "Nossa sola de composto duplo garante segurança em qualquer superfície, do asfalto molhado à trilha de terra.")
470
+ 2. **Linguagem da Marca:** O tom deve ser consistente com a filosofia da marca.
471
+ 3. **Precisão:** As descrições devem ser plausíveis e baseadas no conceito fornecido.
472
+
473
+ **Formato de Saída Obrigatório (JSON Array):**
474
+ Responda APENAS com um array JSON contendo exatamente 4 objetos.
475
+ `;
476
+ try {
477
+ const response = await ai.models.generateContent({
478
+ model: 'gemini-2.5-flash',
479
+ contents: prompt,
480
+ config: {
481
+ responseMimeType: "application/json",
482
+ responseSchema: {
483
+ type: Type.ARRAY,
484
+ items: {
485
+ type: Type.OBJECT,
486
+ properties: {
487
+ title: {
488
+ type: Type.STRING,
489
+ description: "O título da característica (2-4 palavras). Ex: 'Design Ergonômico'."
490
+ },
491
+ description: {
492
+ type: Type.STRING,
493
+ description: "A descrição do benefício (1-2 frases). Ex: 'Criado para se adaptar perfeitamente, oferecendo conforto o dia todo.'"
494
+ }
495
+ },
496
+ required: ["title", "description"]
497
+ }
498
+ },
499
+ temperature: 0.7
500
+ }
501
+ });
502
+ const jsonResponse = JSON.parse(response.text.trim());
503
+ if (Array.isArray(jsonResponse) && jsonResponse.length > 0) {
504
+ return jsonResponse as FeatureDetails[];
505
+ } else {
506
+ throw new Error("A resposta da API para detalhes de features está malformada.");
507
+ }
508
+ } catch(e) {
509
+ processApiError(e, 'gerar descrições de detalhes');
510
+ }
511
+ }
services/supabaseClient.ts ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { createClient } from '@supabase/supabase-js';
2
+ import type { SupabaseClient } from '@supabase/supabase-js';
3
+
4
+ // These variables are expected to be set in the environment.
5
+ // Do not replace them with hardcoded values.
6
+ const supabaseUrl = process.env.SUPABASE_URL;
7
+ const supabaseAnonKey = process.env.SUPABASE_ANON_KEY;
8
+
9
+ const isSupabaseEnabled = supabaseUrl && supabaseAnonKey;
10
+
11
+ // Initialize the Supabase client, which will be null if the environment variables are not set.
12
+ export const supabase: SupabaseClient | null = isSupabaseEnabled
13
+ ? createClient(supabaseUrl, supabaseAnonKey)
14
+ : null;
15
+
16
+ // This check ensures that the rest of the application knows that supabase might be null,
17
+ // and it provides a helpful warning for developers.
18
+ if (!supabase) {
19
+ console.warn(
20
+ 'Supabase environment variables (SUPABASE_URL, SUPABASE_ANON_KEY) are not set. Authentication features will be disabled.'
21
+ );
22
+ }
tsconfig.json ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "experimentalDecorators": true,
5
+ "useDefineForClassFields": false,
6
+ "module": "ESNext",
7
+ "lib": [
8
+ "ES2022",
9
+ "DOM",
10
+ "DOM.Iterable"
11
+ ],
12
+ "skipLibCheck": true,
13
+ "moduleResolution": "bundler",
14
+ "isolatedModules": true,
15
+ "moduleDetection": "force",
16
+ "allowJs": true,
17
+ "jsx": "react-jsx",
18
+ "paths": {
19
+ "@/*": [
20
+ "./*"
21
+ ]
22
+ },
23
+ "allowImportingTsExtensions": true,
24
+ "noEmit": true
25
+ }
26
+ }
types.ts ADDED
@@ -0,0 +1,117 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // This file is intentionally left blank as the types from the previous version are no longer needed.
2
+ // New types will be defined locally within components where they are used.
3
+ export interface MixedStyle {
4
+ name: string;
5
+ percentage: number;
6
+ }
7
+
8
+ export interface RegionalityData {
9
+ country: string;
10
+
11
+ city: string;
12
+ neighborhood: string;
13
+ weight: number;
14
+ }
15
+
16
+ export interface BrandData {
17
+ name: string;
18
+ slogan: string;
19
+ weight: number; // Percentage of influence
20
+ }
21
+
22
+ export type TextPosition = 'center' | 'top' | 'bottom' | 'left' | 'right' | 'top-right';
23
+ export type SubtitleOutlineStyle = 'auto' | 'white' | 'black' | 'soft_shadow' | 'transparent_box';
24
+
25
+ // Renamed and enhanced for new styling capabilities
26
+ export interface CompositionPreset {
27
+ id: string;
28
+ name: string;
29
+ icon: React.FC<React.SVGProps<SVGSVGElement>>;
30
+ config: {
31
+ style: {
32
+ name: 'fill' | 'stroke' | 'fill-stroke' | 'gradient-on-block' | 'vertical';
33
+ palette: 'light' | 'dark' | 'complementary' | 'analogous';
34
+ background?: {
35
+ color: string; // e.g., 'rgba(0,0,0,0.5)'
36
+ padding: number; // as a factor of font size
37
+ };
38
+ forcedStroke?: string;
39
+ };
40
+ rotation: boolean;
41
+ subtitle: boolean;
42
+ };
43
+ }
44
+
45
+ // --- Price Tag Types ---
46
+ export type PriceTagPosition = 'none' | 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right';
47
+ export type PriceTagStyleId = 'circle' | 'tag' | 'burst';
48
+ export type PriceTagColor = 'red' | 'yellow' | 'blue' | 'black';
49
+
50
+ export interface PriceData {
51
+ text: string;
52
+ modelText: string;
53
+ style: PriceTagStyleId;
54
+ position: PriceTagPosition;
55
+ color: PriceTagColor;
56
+ }
57
+
58
+
59
+ // --- Marketing Suite Types ---
60
+
61
+ export interface GoogleAd {
62
+ headlines: string[];
63
+ descriptions: string[];
64
+ }
65
+ export interface FacebookAd {
66
+ primaryText: string;
67
+ headline: string;
68
+ description: string;
69
+ }
70
+ export interface AdCopy {
71
+ google: GoogleAd;
72
+ facebook: FacebookAd;
73
+ strategyTip: string;
74
+ }
75
+
76
+ export interface AdIdea {
77
+ conceptName: string;
78
+ headline: string;
79
+ primaryText: string;
80
+ replicabilityTip: string;
81
+ }
82
+
83
+ export interface AdTrendAnalysis {
84
+ trendOverview: string;
85
+ adIdeas: AdIdea[];
86
+ hashtags: string[];
87
+ }
88
+
89
+ export interface BrandConcept {
90
+ name: string;
91
+ philosophy: string;
92
+ visualStyle: string;
93
+ keywords: string[];
94
+ }
95
+
96
+ export interface FeatureDetails {
97
+ title: string;
98
+ description: string;
99
+ }
100
+
101
+ // Interface to consolidate all generation parameters into a single object
102
+ export interface GenerateOptions {
103
+ basePrompt: string;
104
+ imagePrompt: string;
105
+ textOverlay: string;
106
+ compositionId: string;
107
+ textPosition: TextPosition;
108
+ subtitleOutline: SubtitleOutlineStyle;
109
+ artStyles: string[];
110
+ theme: string;
111
+ brandData: BrandData;
112
+ priceData: PriceData;
113
+ negativeImagePrompt?: string;
114
+ numberOfImages: number;
115
+ scenario?: 'product' | 'couple' | 'family' | 'isometric_details' | 'poster' | 'carousel_cta' | 'carousel_educational' | 'carousel_trend' | 'executive_project';
116
+ concept?: BrandConcept;
117
+ }
vite.config.ts ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import path from 'path';
2
+ import { defineConfig, loadEnv } from 'vite';
3
+
4
+ export default defineConfig(({ mode }) => {
5
+ const env = loadEnv(mode, '.', '');
6
+ return {
7
+ define: {
8
+ 'process.env.API_KEY': JSON.stringify(env.GEMINI_API_KEY),
9
+ 'process.env.GEMINI_API_KEY': JSON.stringify(env.GEMINI_API_KEY)
10
+ },
11
+ resolve: {
12
+ alias: {
13
+ '@': path.resolve(__dirname, '.'),
14
+ }
15
+ }
16
+ };
17
+ });