armario, login/registro, definicoes, tempo, top cores

This commit is contained in:
2026-04-21 17:10:03 +01:00
parent 6c418d9c1f
commit 12171ca3f2
3 changed files with 115 additions and 30 deletions

View File

@@ -55,6 +55,7 @@ export default function App() {
const [language, setLanguage] = useState('PT'); const [language, setLanguage] = useState('PT');
const [showLangModal, setShowLangModal] = useState(false); const [showLangModal, setShowLangModal] = useState(false);
const [theme, setTheme] = useState(() => localStorage.getItem('app-theme') || 'theme-indigo'); const [theme, setTheme] = useState(() => localStorage.getItem('app-theme') || 'theme-indigo');
const [weatherData, setWeatherData] = useState(null);
const t = (key) => translations[language]?.[key] || translations['PT'][key] || key; const t = (key) => translations[language]?.[key] || translations['PT'][key] || key;
@@ -107,6 +108,37 @@ export default function App() {
return () => { unsubClothes(); unsubLooks(); unsubProfile(); }; return () => { unsubClothes(); unsubLooks(); unsubProfile(); };
}, [user]); }, [user]);
// Fetch Weather Data
useEffect(() => {
if (view !== 'dashboard') return;
const fetchWeather = async () => {
try {
const locName = userProfile?.location || 'Lisboa, Portugal';
const geoRes = await fetch(`https://geocoding-api.open-meteo.com/v1/search?name=${encodeURIComponent(locName)}&count=1&language=pt&format=json`);
const geoData = await geoRes.json();
if (geoData.results && geoData.results.length > 0) {
const { latitude, longitude, name, country } = geoData.results[0];
const weatherRes = await fetch(`https://api.open-meteo.com/v1/forecast?latitude=${latitude}&longitude=${longitude}&current_weather=true&daily=temperature_2m_max,temperature_2m_min&timezone=auto`);
const weatherRaw = await weatherRes.json();
if (weatherRaw.current_weather && weatherRaw.daily) {
setWeatherData({
name: `${name}, ${country || ''}`.replace(/,\s*$/, ''),
currentTemp: Math.round(weatherRaw.current_weather.temperature),
minTemp: Math.round(weatherRaw.daily.temperature_2m_min[0]),
maxTemp: Math.round(weatherRaw.daily.temperature_2m_max[0]),
avgTemp: Math.round((weatherRaw.daily.temperature_2m_min[0] + weatherRaw.daily.temperature_2m_max[0]) / 2)
});
}
}
} catch (err) {
console.error("Error fetching weather", err);
}
};
fetchWeather();
}, [userProfile?.location, view]);
// --- Lógicas de Negócio --- // --- Lógicas de Negócio ---
const activeClothes = useMemo(() => clothes.filter(c => c.status === 'active'), [clothes]); const activeClothes = useMemo(() => clothes.filter(c => c.status === 'active'), [clothes]);
@@ -118,6 +150,28 @@ export default function App() {
return Array.from(colors); return Array.from(colors);
}, [activeClothes]); }, [activeClothes]);
const colorStats = useMemo(() => {
if (!activeClothes.length) return [];
const colorCounts = {};
let totalWithColor = 0;
activeClothes.forEach(c => {
if (c.color) {
colorCounts[c.color] = (colorCounts[c.color] || 0) + 1;
totalWithColor++;
}
});
if (totalWithColor === 0) return [];
return Object.entries(colorCounts)
.sort((a, b) => b[1] - a[1])
.slice(0, 3)
.map(([color, count]) => ({
color,
count,
percentage: Math.round((count / totalWithColor) * 100)
}));
}, [activeClothes]);
const filteredClothes = useMemo(() => { const filteredClothes = useMemo(() => {
return activeClothes.filter(c => { return activeClothes.filter(c => {
const matchesSearch = (c.name || "").toLowerCase().includes(searchTerm.toLowerCase()) || const matchesSearch = (c.name || "").toLowerCase().includes(searchTerm.toLowerCase()) ||
@@ -288,7 +342,8 @@ export default function App() {
username: fd.get('username') || '', username: fd.get('username') || '',
fullName: fd.get('fullName') || '', fullName: fd.get('fullName') || '',
dob: dob, dob: dob,
bio: fd.get('bio') || '' bio: fd.get('bio') || '',
location: fd.get('location') || ''
}, { merge: true }).catch(err => { }, { merge: true }).catch(err => {
console.error(err); console.error(err);
}); });
@@ -307,29 +362,30 @@ export default function App() {
if (view === 'auth') { if (view === 'auth') {
return ( return (
<div className={`min-h-screen bg-gradient-to-br from-primary-100 via-white to-purple-50 dark:from-gray-950 dark:to-gray-900 flex items-center justify-center p-6 text-gray-900 ${darkMode ? 'dark' : ''}`}> <div className={`min-h-screen bg-gradient-to-br from-primary-100 via-white to-purple-50 dark:from-gray-950 dark:to-gray-900 flex items-center justify-center p-6 text-gray-900 ${darkMode ? 'dark' : ''}`}>
<Card className="max-w-md w-full p-12 border-none shadow-2xl" darkMode={darkMode}> <Card className="max-w-md w-full p-12 border-none shadow-2xl overflow-hidden" darkMode={darkMode}>
<div className="text-center mb-10"> <div key={authMode} className="animate-custom-zoom">
<div className="inline-flex p-5 bg-primary-600 rounded-[2rem] shadow-2xl shadow-primary-600/40 mb-6"> <div className="text-center mb-10">
<Shirt className="text-white w-12 h-12" /> <div className="inline-flex p-5 bg-primary-600 rounded-[2rem] shadow-2xl shadow-primary-600/40 mb-6 transition-all duration-300">
{authMode === 'login' ? <Shirt className="text-white w-12 h-12" /> : <UserCircle className="text-white w-12 h-12" />}
</div>
<h1 className="text-5xl font-black tracking-tighter italic">MyCloset</h1>
</div> </div>
<h1 className="text-5xl font-black tracking-tighter italic">MyCloset</h1>
<p className="text-gray-400 mt-3 font-bold uppercase tracking-widest text-[10px]">{t('loginModeIntro')}</p>
</div>
{authError && <div className="mb-6 p-4 bg-red-50 text-red-600 text-[10px] rounded-2xl flex items-center gap-2 font-black uppercase tracking-widest border border-red-100"><AlertCircle size={16} /> {authError}</div>} {authError && <div className="mb-6 p-4 bg-red-50 text-red-600 text-[10px] rounded-2xl flex items-center gap-2 font-black uppercase tracking-widest border border-red-100"><AlertCircle size={16} /> {authError}</div>}
<form onSubmit={handleAuth} className="space-y-4"> <form onSubmit={handleAuth} className="space-y-4">
<input name="email" type="email" placeholder={t('emailPlaceholder')} required className="w-full p-5 rounded-2xl bg-gray-50 dark:bg-gray-800 border-none focus:ring-2 focus:ring-primary-500 outline-none font-bold" /> <input name="email" type="email" placeholder={t('emailPlaceholder')} required className="w-full p-5 rounded-2xl bg-gray-50 dark:bg-gray-800 border-none focus:ring-2 focus:ring-primary-500 outline-none font-bold" />
<input name="password" type="password" placeholder={t('passwordPlaceholder')} required className="w-full p-5 rounded-2xl bg-gray-50 dark:bg-gray-800 border-none focus:ring-2 focus:ring-primary-500 outline-none font-bold" /> <input name="password" type="password" placeholder={t('passwordPlaceholder')} required className="w-full p-5 rounded-2xl bg-gray-50 dark:bg-gray-800 border-none focus:ring-2 focus:ring-primary-500 outline-none font-bold" />
<button className="w-full py-5 bg-primary-600 text-white rounded-[2rem] font-black text-lg shadow-2xl hover:scale-[1.02] active:scale-95 transition-all"> <button className="w-full py-5 bg-primary-600 text-white rounded-[2rem] font-black text-lg shadow-2xl hover:scale-[1.02] active:scale-95 transition-all">
{authMode === 'login' ? t('loginBtn') : t('registerBtn')} {authMode === 'login' ? t('loginBtn') : t('registerBtn')}
</button> </button>
</form> </form>
<div className="mt-10 text-center"> <div className="mt-10 text-center">
<button onClick={() => setAuthMode(authMode === 'login' ? 'register' : 'login')} className="text-gray-400 font-black text-[10px] uppercase tracking-[0.3em] hover:text-primary-600 transition-colors text-inherit"> <button type="button" onClick={() => setAuthMode(authMode === 'login' ? 'register' : 'login')} className="text-gray-400 font-black text-[10px] uppercase tracking-[0.3em] hover:text-primary-600 transition-colors text-inherit">
{authMode === 'login' ? t('createAccount') : t('haveAccount')} {authMode === 'login' ? t('createAccount') : t('haveAccount')}
</button> </button>
</div>
</div> </div>
</Card> </Card>
</div> </div>
@@ -341,7 +397,7 @@ export default function App() {
{/* Sidebar - Design Futurista */} {/* Sidebar - Design Futurista */}
<aside className={` <aside className={`
fixed md:relative inset-y-0 left-0 z-[100] transition-all duration-500 ease-in-out border-r fixed md:relative inset-y-0 left-0 z-[100] transition-all duration-500 ease-in-out border-r overflow-hidden
${darkMode ? 'bg-gray-900/80 border-gray-800' : 'bg-white border-gray-100'} ${darkMode ? 'bg-gray-900/80 border-gray-800' : 'bg-white border-gray-100'}
${sidebarOpen ? 'w-80 translate-x-0' : 'w-0 -translate-x-full md:w-0 md:opacity-0'} ${sidebarOpen ? 'w-80 translate-x-0' : 'w-0 -translate-x-full md:w-0 md:opacity-0'}
`}> `}>
@@ -448,11 +504,13 @@ export default function App() {
<div> <div>
<div className="flex items-center gap-3 mb-4"> <div className="flex items-center gap-3 mb-4">
<CloudSun size={28} className="text-primary-200" /> <CloudSun size={28} className="text-primary-200" />
<Badge variant="warning">{t('todayIn')}</Badge> <Badge variant="warning">{weatherData ? weatherData.name : t('todayIn')}</Badge>
</div> </div>
<h3 className="text-5xl font-black tracking-tighter mb-4">{t('weatherUpdate')}</h3> <h3 className="text-5xl font-black tracking-tighter mb-4">
{weatherData ? `${weatherData.currentTemp}°C Atual • Média ${weatherData.avgTemp}°C` : t('weatherUpdate')}
</h3>
<p className="text-primary-100 text-lg font-medium max-w-lg leading-relaxed"> <p className="text-primary-100 text-lg font-medium max-w-lg leading-relaxed">
{t('weatherMsg')} {weatherData ? `O dia de hoje tem máximas de ${weatherData.maxTemp}°C e mínimas de ${weatherData.minTemp}°C. ${t('weatherMsg')}` : t('weatherMsg')}
</p> </p>
</div> </div>
<div className="mt-10 flex gap-4"> <div className="mt-10 flex gap-4">
@@ -472,17 +530,19 @@ export default function App() {
<Card className="p-8" darkMode={darkMode}> <Card className="p-8" darkMode={darkMode}>
<h3 className="text-lg font-black tracking-tight mb-8 flex items-center gap-2 text-inherit"><PieChart size={20} /> {t('topColors')}</h3> <h3 className="text-lg font-black tracking-tight mb-8 flex items-center gap-2 text-inherit"><PieChart size={20} /> {t('topColors')}</h3>
<div className="space-y-6"> <div className="space-y-6">
{[t('colorBlack'), t('colorWhite'), t('colorBlue')].map(color => ( {colorStats.length > 0 ? colorStats.map(stat => (
<div key={color} className="space-y-2"> <div key={stat.color} className="space-y-2">
<div className="flex justify-between text-[10px] font-black uppercase tracking-widest opacity-50"> <div className="flex justify-between text-[10px] font-black uppercase tracking-widest opacity-50">
<span>{color}</span> <span>{stat.color}</span>
<span>{Math.floor(Math.random() * 40 + 20)}%</span> <span>{stat.percentage}% ({stat.count})</span>
</div> </div>
<div className="h-2 w-full bg-gray-100 dark:bg-gray-700 rounded-full overflow-hidden"> <div className="h-2 w-full bg-gray-100 dark:bg-gray-700 rounded-full overflow-hidden">
<div className="h-full bg-primary-600" style={{ width: `${Math.floor(Math.random() * 40 + 20)}%` }}></div> <div className="h-full bg-primary-600" style={{ width: `${stat.percentage}%` }}></div>
</div> </div>
</div> </div>
))} )) : (
<p className="text-xs opacity-50 italic">Adicione cores aos seus itens.</p>
)}
</div> </div>
</Card> </Card>
</div> </div>
@@ -737,6 +797,7 @@ export default function App() {
</div> </div>
</div> </div>
<Input label={`${t('bio')} ${t('optional')}`} name="bio" defaultValue={userProfile?.bio || ''} placeholder="..." /> <Input label={`${t('bio')} ${t('optional')}`} name="bio" defaultValue={userProfile?.bio || ''} placeholder="..." />
<Input label="Localidade" name="location" defaultValue={userProfile?.location || ''} placeholder="Ex: Lisboa, Portugal" />
</div> </div>
<button disabled={savingProfile} type="submit" className="w-full py-4 bg-primary-600 text-white rounded-xl font-black uppercase text-[10px] tracking-widest shadow-xl shadow-primary-600/30 disabled:opacity-50 hover:scale-[1.01] transition-all"> <button disabled={savingProfile} type="submit" className="w-full py-4 bg-primary-600 text-white rounded-xl font-black uppercase text-[10px] tracking-widest shadow-xl shadow-primary-600/30 disabled:opacity-50 hover:scale-[1.01] transition-all">
{savingProfile ? t('saving') : t('save')} {savingProfile ? t('saving') : t('save')}

View File

@@ -89,3 +89,18 @@
.custom-scrollbar::-webkit-scrollbar-thumb:hover { .custom-scrollbar::-webkit-scrollbar-thumb:hover {
background: rgba(156, 163, 175, 0.5); background: rgba(156, 163, 175, 0.5);
} }
@keyframes customZoomIn {
from {
opacity: 0;
transform: scale(0.95);
}
to {
opacity: 1;
transform: scale(1);
}
}
.animate-custom-zoom {
animation: customZoomIn 0.3s ease-out forwards;
}

View File

@@ -7,6 +7,15 @@ export default {
darkMode: 'class', darkMode: 'class',
theme: { theme: {
extend: { extend: {
keyframes: {
'zoom-in': {
'0%': { opacity: '0', transform: 'scale(0.95)' },
'100%': { opacity: '1', transform: 'scale(1)' },
}
},
animation: {
'zoom-in': 'zoom-in 0.3s ease-out forwards',
},
colors: { colors: {
primary: { primary: {
50: 'hsl(var(--primary-50) / <alpha-value>)', 50: 'hsl(var(--primary-50) / <alpha-value>)',