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 |
+
});
|