Upload 25 files
Browse files- .env.local +1 -0
- .gitignore +24 -0
- App.tsx +230 -0
- README.md +14 -10
- components/GuideModal.tsx +102 -0
- components/Header.tsx +89 -0
- components/HistorySidebar.tsx +8 -0
- components/PromptForm.tsx +610 -0
- components/SqlViewer.tsx +1273 -0
- components/icons.tsx +432 -0
- entrypoint.sh +0 -0
- index.html +50 -0
- index.tsx +16 -0
- lib/compositions.ts +129 -0
- lib/ctas.ts +3 -0
- lib/errors.ts +9 -0
- lib/options.ts +45 -0
- lib/styles.ts +220 -0
- metadata.json +6 -0
- package.json +12 -0
- services/geminiService.ts +511 -0
- services/supabaseClient.ts +22 -0
- tsconfig.json +26 -0
- types.ts +117 -0
- vite.config.ts +17 -0
.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 |
-
|
3 |
-
|
4 |
-
|
5 |
-
|
6 |
-
|
7 |
-
|
8 |
-
|
9 |
-
|
10 |
-
|
|
|
|
|
|
|
|
|
|
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) 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) 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 |
+
});
|