armario, login/registro, definicoes, tempo, top cores
This commit is contained in:
121
src/App.jsx
121
src/App.jsx
@@ -55,6 +55,7 @@ export default function App() {
|
||||
const [language, setLanguage] = useState('PT');
|
||||
const [showLangModal, setShowLangModal] = useState(false);
|
||||
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;
|
||||
|
||||
@@ -107,6 +108,37 @@ export default function App() {
|
||||
return () => { unsubClothes(); unsubLooks(); unsubProfile(); };
|
||||
}, [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}¤t_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 ---
|
||||
|
||||
const activeClothes = useMemo(() => clothes.filter(c => c.status === 'active'), [clothes]);
|
||||
@@ -118,6 +150,28 @@ export default function App() {
|
||||
return Array.from(colors);
|
||||
}, [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(() => {
|
||||
return activeClothes.filter(c => {
|
||||
const matchesSearch = (c.name || "").toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
@@ -288,7 +342,8 @@ export default function App() {
|
||||
username: fd.get('username') || '',
|
||||
fullName: fd.get('fullName') || '',
|
||||
dob: dob,
|
||||
bio: fd.get('bio') || ''
|
||||
bio: fd.get('bio') || '',
|
||||
location: fd.get('location') || ''
|
||||
}, { merge: true }).catch(err => {
|
||||
console.error(err);
|
||||
});
|
||||
@@ -307,29 +362,30 @@ export default function App() {
|
||||
if (view === 'auth') {
|
||||
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' : ''}`}>
|
||||
<Card className="max-w-md w-full p-12 border-none shadow-2xl" darkMode={darkMode}>
|
||||
<div className="text-center mb-10">
|
||||
<div className="inline-flex p-5 bg-primary-600 rounded-[2rem] shadow-2xl shadow-primary-600/40 mb-6">
|
||||
<Shirt className="text-white w-12 h-12" />
|
||||
<Card className="max-w-md w-full p-12 border-none shadow-2xl overflow-hidden" darkMode={darkMode}>
|
||||
<div key={authMode} className="animate-custom-zoom">
|
||||
<div className="text-center mb-10">
|
||||
<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>
|
||||
<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">
|
||||
<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" />
|
||||
<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')}
|
||||
</button>
|
||||
</form>
|
||||
<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="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">
|
||||
{authMode === 'login' ? t('loginBtn') : t('registerBtn')}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<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">
|
||||
{authMode === 'login' ? t('createAccount') : t('haveAccount')}
|
||||
</button>
|
||||
<div className="mt-10 text-center">
|
||||
<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')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
@@ -341,7 +397,7 @@ export default function App() {
|
||||
|
||||
{/* Sidebar - Design Futurista */}
|
||||
<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'}
|
||||
${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 className="flex items-center gap-3 mb-4">
|
||||
<CloudSun size={28} className="text-primary-200" />
|
||||
<Badge variant="warning">{t('todayIn')}</Badge>
|
||||
<Badge variant="warning">{weatherData ? weatherData.name : t('todayIn')}</Badge>
|
||||
</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">
|
||||
{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>
|
||||
</div>
|
||||
<div className="mt-10 flex gap-4">
|
||||
@@ -472,17 +530,19 @@ export default function App() {
|
||||
<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>
|
||||
<div className="space-y-6">
|
||||
{[t('colorBlack'), t('colorWhite'), t('colorBlue')].map(color => (
|
||||
<div key={color} className="space-y-2">
|
||||
{colorStats.length > 0 ? colorStats.map(stat => (
|
||||
<div key={stat.color} className="space-y-2">
|
||||
<div className="flex justify-between text-[10px] font-black uppercase tracking-widest opacity-50">
|
||||
<span>{color}</span>
|
||||
<span>{Math.floor(Math.random() * 40 + 20)}%</span>
|
||||
<span>{stat.color}</span>
|
||||
<span>{stat.percentage}% ({stat.count})</span>
|
||||
</div>
|
||||
<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>
|
||||
))}
|
||||
)) : (
|
||||
<p className="text-xs opacity-50 italic">Adicione cores aos seus itens.</p>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
@@ -737,6 +797,7 @@ export default function App() {
|
||||
</div>
|
||||
</div>
|
||||
<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>
|
||||
<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')}
|
||||
|
||||
@@ -89,3 +89,18 @@
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -7,6 +7,15 @@ export default {
|
||||
darkMode: 'class',
|
||||
theme: {
|
||||
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: {
|
||||
primary: {
|
||||
50: 'hsl(var(--primary-50) / <alpha-value>)',
|
||||
|
||||
Reference in New Issue
Block a user