2426 lines
166 KiB
JavaScript
2426 lines
166 KiB
JavaScript
import React, { useState, useEffect, useMemo, useRef } from 'react';
|
|
import {
|
|
Plus, Search, LayoutDashboard, Shirt, LogOut,
|
|
Trash2, Heart, Loader2, AlertCircle,
|
|
UserCircle, Settings, Moon, Sun, ShieldAlert,
|
|
Edit2, Image as ImageIcon, Check, RotateCcw, Trash,
|
|
PanelLeftClose, PanelLeftOpen, Sparkles, CloudSun,
|
|
ArrowRight, Droplets, CheckCircle2, PieChart, History,
|
|
X, Download, Bell, Globe, Filter, ShoppingBag, Share2,
|
|
FolderOpen, Tag, Link, Calendar, ChevronLeft, ChevronRight
|
|
} from 'lucide-react';
|
|
|
|
import {
|
|
signInWithEmailAndPassword, createUserWithEmailAndPassword,
|
|
onAuthStateChanged, signOut, signInWithCustomToken, sendPasswordResetEmail
|
|
} from 'firebase/auth';
|
|
import {
|
|
collection, doc, onSnapshot, addDoc, updateDoc,
|
|
deleteDoc, writeBatch, setDoc, getDoc, query, where
|
|
} from 'firebase/firestore';
|
|
|
|
import { auth, db, appId } from './lib/firebase';
|
|
import { Card } from './components/ui/Card';
|
|
import { Badge } from './components/ui/Badge';
|
|
import { Input } from './components/ui/Input';
|
|
import { translations } from './lib/i18n';
|
|
|
|
export default function App() {
|
|
const [view, setView] = useState('auth');
|
|
const [user, setUser] = useState(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const [clothes, setClothes] = useState([]);
|
|
const [looks, setLooks] = useState([]);
|
|
const [editingItem, setEditingItem] = useState(null);
|
|
const [darkMode, setDarkMode] = useState(false);
|
|
const [searchTerm, setSearchTerm] = useState('');
|
|
const [imageUrlDraft, setImageUrlDraft] = useState('');
|
|
const [itemColors, setItemColors] = useState([]);
|
|
const [sidebarOpen, setSidebarOpen] = useState(true);
|
|
const [authMode, setAuthMode] = useState('login');
|
|
const [authError, setAuthError] = useState('');
|
|
const [showForgotPasswordModal, setShowForgotPasswordModal] = useState(false);
|
|
const [forgotPasswordEmail, setForgotPasswordEmail] = useState('');
|
|
const [categoryFilter, setCategoryFilter] = useState('Todos');
|
|
const [colorFilter, setColorFilter] = useState('');
|
|
const [ageFilter, setAgeFilter] = useState('any');
|
|
const [showClosetFilters, setShowClosetFilters] = useState(false);
|
|
|
|
// Estado para criação de Looks
|
|
const [selectedForLook, setSelectedForLook] = useState([]);
|
|
const [editingLook, setEditingLook] = useState(null);
|
|
|
|
// Perfil do Utilizador
|
|
const [userProfile, setUserProfile] = useState({});
|
|
const [savingProfile, setSavingProfile] = useState(false);
|
|
|
|
// Estado para Definições
|
|
const [notificationsEnabled, setNotificationsEnabled] = useState(true);
|
|
const [weatherAlerts, setWeatherAlerts] = useState(true);
|
|
const [language, setLanguage] = useState('PT');
|
|
const [showLangModal, setShowLangModal] = useState(false);
|
|
const [theme, setTheme] = useState('theme-indigo');
|
|
const [cardSize, setCardSize] = useState('large');
|
|
const [weatherData, setWeatherData] = useState(null);
|
|
|
|
// Estado para Partilha de Looks
|
|
const sharedLookRef = useRef('');
|
|
const [sharedLookData, setSharedLookData] = useState(null);
|
|
const [showSharedLookModal, setShowSharedLookModal] = useState(false);
|
|
const [sharedLookCopying, setSharedLookCopying] = useState(false);
|
|
const [copiedLookId, setCopiedLookId] = useState(null);
|
|
|
|
// Estado para Secções
|
|
const [sections, setSections] = useState([]);
|
|
const [activeSectionFilter, setActiveSectionFilter] = useState('all');
|
|
const [showSectionManager, setShowSectionManager] = useState(false);
|
|
const [newSectionName, setNewSectionName] = useState('');
|
|
const [newSectionEmoji, setNewSectionEmoji] = useState('');
|
|
const [itemSections, setItemSections] = useState([]);
|
|
const [lookSections, setLookSections] = useState([]);
|
|
const [editingSectionId, setEditingSectionId] = useState(null);
|
|
const [editSectionName, setEditSectionName] = useState('');
|
|
const [editSectionEmoji, setEditSectionEmoji] = useState('');
|
|
|
|
const [notifications, setNotifications] = useState([]);
|
|
const [showNotificationsModal, setShowNotificationsModal] = useState(false);
|
|
const [toastMessage, setToastMessage] = useState(null);
|
|
|
|
// Estado do Planeador
|
|
const [plannerMode, setPlannerMode] = useState('month');
|
|
const [plannerCurrentDate, setPlannerCurrentDate] = useState(new Date());
|
|
const [outfitPlans, setOutfitPlans] = useState([]);
|
|
const [showPlannerPicker, setShowPlannerPicker] = useState(false);
|
|
const [plannerPickerDate, setPlannerPickerDate] = useState(null);
|
|
|
|
const t = (key) => translations[language]?.[key] || translations['PT'][key] || key;
|
|
|
|
// Mapeamento de nomes de cor (PT) para valores CSS
|
|
const COLOR_MAP = {
|
|
'Vermelho': '#ef4444',
|
|
'Azul': '#3b82f6',
|
|
'Amarelo': '#eab308',
|
|
'Verde': '#22c55e',
|
|
'Laranja': '#f97316',
|
|
'Roxo': '#a855f7',
|
|
'Branco': '#f8fafc',
|
|
'Preto': '#0f172a',
|
|
'Cinzento': '#6b7280',
|
|
'Bege': '#d4b896',
|
|
};
|
|
|
|
const getColorStyle = (colorStr) => {
|
|
if (!colorStr) return { backgroundColor: '#e5e7eb' };
|
|
const parts = colorStr.split(',').map(c => c.trim()).filter(Boolean);
|
|
const cssColors = parts.map(p => COLOR_MAP[p] || p.toLowerCase());
|
|
if (cssColors.length === 1) return { backgroundColor: cssColors[0] };
|
|
return { background: `linear-gradient(135deg, ${cssColors.join(', ')})` };
|
|
};
|
|
|
|
const saveUserSetting = async (key, value) => {
|
|
if (!user) return;
|
|
try {
|
|
const profileDoc = doc(db, 'artifacts', appId, 'users', user.uid, 'profile', 'data');
|
|
await setDoc(profileDoc, {
|
|
settings: { [key]: value }
|
|
}, { merge: true });
|
|
} catch (err) {
|
|
console.error('Error saving setting:', err);
|
|
}
|
|
};
|
|
|
|
const handleDarkModeToggle = (newVal) => {
|
|
setDarkMode(newVal);
|
|
saveUserSetting('darkMode', newVal);
|
|
};
|
|
|
|
const handleThemeChange = (newVal) => {
|
|
setTheme(newVal);
|
|
saveUserSetting('theme', newVal);
|
|
};
|
|
|
|
const handleLanguageChange = (newVal) => {
|
|
setLanguage(newVal);
|
|
saveUserSetting('language', newVal);
|
|
setShowLangModal(false);
|
|
};
|
|
|
|
const handleNotificationsToggle = (newVal) => {
|
|
setNotificationsEnabled(newVal);
|
|
saveUserSetting('notificationsEnabled', newVal);
|
|
};
|
|
|
|
const handleWeatherAlertsToggle = (newVal) => {
|
|
setWeatherAlerts(newVal);
|
|
saveUserSetting('weatherAlerts', newVal);
|
|
};
|
|
|
|
const handleCardSizeChange = (newVal) => {
|
|
setCardSize(newVal);
|
|
saveUserSetting('cardSize', newVal);
|
|
};
|
|
|
|
// Buscar o look partilhado pelo link
|
|
const fetchSharedLook = async (lookId) => {
|
|
if (!lookId) return;
|
|
try {
|
|
const lookDoc = doc(db, 'artifacts', appId, 'sharedLooks', lookId);
|
|
const snap = await getDoc(lookDoc);
|
|
if (snap.exists()) {
|
|
setSharedLookData({ id: snap.id, ...snap.data() });
|
|
setShowSharedLookModal(true);
|
|
// Limpar o parâmetro do URL sem recarregar a página
|
|
window.history.replaceState({}, '', window.location.pathname);
|
|
}
|
|
} catch (err) {
|
|
console.error('Erro ao buscar look partilhado:', err);
|
|
}
|
|
};
|
|
|
|
const handlePasteSharedLink = () => {
|
|
const link = window.prompt(t('pasteSharedLookLink') || 'Cole o link do look partilhado:');
|
|
if (link) {
|
|
try {
|
|
const url = new URL(link);
|
|
const sharedId = url.searchParams.get('shared');
|
|
if (sharedId) {
|
|
fetchSharedLook(sharedId);
|
|
} else {
|
|
alert(t('invalidSharedLink') || 'Link inválido. Certifique-se de copiar o link completo.');
|
|
}
|
|
} catch (e) {
|
|
alert(t('invalidSharedLink') || 'Link inválido. Certifique-se de copiar o link completo.');
|
|
}
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
if (editingItem && editingItem.color) {
|
|
setItemColors(editingItem.color.split(',').map(c => c.trim()).filter(Boolean));
|
|
} else {
|
|
setItemColors([]);
|
|
}
|
|
setItemSections(editingItem?.sections || []);
|
|
}, [editingItem]);
|
|
|
|
useEffect(() => {
|
|
setLookSections(editingLook?.sections || []);
|
|
}, [editingLook]);
|
|
|
|
useEffect(() => {
|
|
document.documentElement.classList.remove('theme-indigo', 'theme-rose', 'theme-emerald', 'theme-amber', 'theme-slate');
|
|
// Ecrã de login/registo: sempre theme-indigo, independentemente das preferências do utilizador
|
|
const activeTheme = view === 'auth' ? 'theme-indigo' : theme;
|
|
document.documentElement.classList.add(activeTheme);
|
|
// Guardar tema por utilizador (não partilhado entre contas)
|
|
if (view !== 'auth' && user?.uid) {
|
|
localStorage.setItem(`app-theme-${user.uid}`, theme);
|
|
}
|
|
}, [theme, view, user?.uid]);
|
|
|
|
// 1. Inicializar Autenticação
|
|
useEffect(() => {
|
|
const initAuth = async () => {
|
|
const token = import.meta.env.VITE_INITIAL_AUTH_TOKEN;
|
|
if (token) {
|
|
try { await signInWithCustomToken(auth, token); } catch (e) { }
|
|
}
|
|
};
|
|
initAuth();
|
|
const unsubscribe = onAuthStateChanged(auth, (currentUser) => {
|
|
if (!currentUser) {
|
|
// Reset de todo o estado ao fazer logout para não contaminar a próxima conta
|
|
setUser(null);
|
|
setClothes([]);
|
|
setLooks([]);
|
|
setSections([]);
|
|
setUserProfile({});
|
|
setDarkMode(false);
|
|
setTheme('theme-indigo');
|
|
setLanguage('PT');
|
|
setNotificationsEnabled(true);
|
|
setWeatherAlerts(true);
|
|
setWeatherData(null);
|
|
setView('auth');
|
|
} else {
|
|
// Carregar tema guardado para este utilizador específico
|
|
const savedTheme = localStorage.getItem(`app-theme-${currentUser.uid}`) || 'theme-indigo';
|
|
setTheme(savedTheme);
|
|
setUser(currentUser);
|
|
setView('dashboard');
|
|
// Verificar se há um look partilhado no URL
|
|
const sharedId = sharedLookRef.current || new URLSearchParams(window.location.search).get('shared');
|
|
sharedLookRef.current = '';
|
|
if (sharedId) fetchSharedLook(sharedId);
|
|
}
|
|
setLoading(false);
|
|
});
|
|
return () => unsubscribe();
|
|
}, []);
|
|
|
|
// 2. Dados em Tempo Real (Roupas e Looks)
|
|
useEffect(() => {
|
|
if (!user) return;
|
|
|
|
// Roupas
|
|
const clothesCol = collection(db, 'artifacts', appId, 'users', user.uid, 'clothes');
|
|
const unsubClothes = onSnapshot(clothesCol, (snap) => {
|
|
setClothes(snap.docs.map(d => ({ id: d.id, ...d.data() })));
|
|
}, (err) => console.error(err));
|
|
|
|
// Looks
|
|
const looksCol = collection(db, 'artifacts', appId, 'users', user.uid, 'looks');
|
|
const unsubLooks = onSnapshot(looksCol, (snap) => {
|
|
setLooks(snap.docs.map(d => ({ id: d.id, ...d.data() })));
|
|
}, (err) => console.error(err));
|
|
|
|
// Secções
|
|
const sectionsCol = collection(db, 'artifacts', appId, 'users', user.uid, 'sections');
|
|
const unsubSections = onSnapshot(sectionsCol, (snap) => {
|
|
setSections(snap.docs.map(d => ({ id: d.id, ...d.data() })).sort((a, b) => a.createdAt - b.createdAt));
|
|
}, (err) => console.error(err));
|
|
|
|
// Planeador de Outfits
|
|
const plansCol = collection(db, 'artifacts', appId, 'users', user.uid, 'outfitPlans');
|
|
const unsubPlans = onSnapshot(plansCol, (snap) => {
|
|
setOutfitPlans(snap.docs.map(d => ({ id: d.id, ...d.data() })));
|
|
}, (err) => console.error(err));
|
|
|
|
// Profile
|
|
const profileDoc = doc(db, 'artifacts', appId, 'users', user.uid, 'profile', 'data');
|
|
const unsubProfile = onSnapshot(profileDoc, (snap) => {
|
|
if (snap.exists()) {
|
|
const data = snap.data();
|
|
setUserProfile(data);
|
|
if (data.settings) {
|
|
if (data.settings.language !== undefined) setLanguage(data.settings.language);
|
|
if (data.settings.darkMode !== undefined) setDarkMode(data.settings.darkMode);
|
|
if (data.settings.theme !== undefined) setTheme(data.settings.theme);
|
|
if (data.settings.notificationsEnabled !== undefined) setNotificationsEnabled(data.settings.notificationsEnabled);
|
|
if (data.settings.weatherAlerts !== undefined) setWeatherAlerts(data.settings.weatherAlerts);
|
|
if (data.settings.cardSize !== undefined) setCardSize(data.settings.cardSize);
|
|
}
|
|
}
|
|
else setUserProfile({});
|
|
}, (err) => console.error(err));
|
|
|
|
// Notificações (coleção pública partilhada, filtrada por recipientUid)
|
|
const notifCol = collection(db, 'artifacts', appId, 'inboxNotifications');
|
|
const notifQuery = query(notifCol, where('recipientUid', '==', user.uid));
|
|
const unsubNotif = onSnapshot(notifQuery, (snap) => {
|
|
setNotifications(snap.docs.map(d => ({ id: d.id, ...d.data() })).sort((a, b) => b.createdAt - a.createdAt));
|
|
}, (err) => console.error('Notif listener error:', err));
|
|
|
|
return () => { unsubClothes(); unsubLooks(); unsubSections(); unsubProfile(); unsubNotif(); unsubPlans(); };
|
|
}, [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]);
|
|
const laundryClothes = useMemo(() => clothes.filter(c => c.status === 'laundry'), [clothes]);
|
|
const trashClothes = useMemo(() => clothes.filter(c => c.status === 'trash'), [clothes]);
|
|
const wishlistClothes = useMemo(() => clothes.filter(c => c.status === 'wishlist'), [clothes]);
|
|
const availableForLooks = useMemo(() => clothes.filter(c => {
|
|
const isAvailable = c.status !== 'trash';
|
|
const matchesSection = activeSectionFilter === 'all' || (c.sections && c.sections.includes(activeSectionFilter));
|
|
return isAvailable && matchesSection;
|
|
}), [clothes, activeSectionFilter]);
|
|
|
|
// CRUD de Secções
|
|
const updateSection = async () => {
|
|
if (!editSectionName.trim() || !user || !editingSectionId) return;
|
|
const docRef = doc(db, 'artifacts', appId, 'users', user.uid, 'sections', editingSectionId);
|
|
await updateDoc(docRef, {
|
|
name: editSectionName.trim(),
|
|
emoji: editSectionEmoji.trim() || '💼'
|
|
});
|
|
setEditingSectionId(null);
|
|
setEditSectionName('');
|
|
setEditSectionEmoji('');
|
|
};
|
|
|
|
const saveSection = async () => {
|
|
if (!newSectionName.trim() || !user) return;
|
|
const sectionsCol = collection(db, 'artifacts', appId, 'users', user.uid, 'sections');
|
|
await addDoc(sectionsCol, {
|
|
name: newSectionName.trim(),
|
|
emoji: newSectionEmoji.trim() || '💼',
|
|
createdAt: new Date().getTime()
|
|
});
|
|
setNewSectionName('');
|
|
setNewSectionEmoji('');
|
|
};
|
|
|
|
const deleteSection = async (id) => {
|
|
if (!window.confirm(t('confirmDeleteSection'))) return;
|
|
const docRef = doc(db, 'artifacts', appId, 'users', user.uid, 'sections', id);
|
|
await deleteDoc(docRef);
|
|
// Remover a secção de todas as peças que a tinham
|
|
const batch = writeBatch(db);
|
|
clothes.forEach(item => {
|
|
if (item.sections && item.sections.includes(id)) {
|
|
const itemRef = doc(db, 'artifacts', appId, 'users', user.uid, 'clothes', item.id);
|
|
batch.update(itemRef, { sections: item.sections.filter(s => s !== id) });
|
|
}
|
|
});
|
|
looks.forEach(look => {
|
|
if (look.sections && look.sections.includes(id)) {
|
|
const lookRef = doc(db, 'artifacts', appId, 'users', user.uid, 'looks', look.id);
|
|
batch.update(lookRef, { sections: look.sections.filter(s => s !== id) });
|
|
}
|
|
});
|
|
await batch.commit();
|
|
if (activeSectionFilter === id) setActiveSectionFilter('all');
|
|
};
|
|
|
|
const assignOutfitToDay = async (dateStr, lookId) => {
|
|
if (!user) return;
|
|
const planRef = doc(db, 'artifacts', appId, 'users', user.uid, 'outfitPlans', dateStr);
|
|
if (lookId) {
|
|
await setDoc(planRef, { date: dateStr, lookId, updatedAt: new Date().getTime() });
|
|
} else {
|
|
await deleteDoc(planRef);
|
|
}
|
|
};
|
|
|
|
const baseClothes = view === 'wishlist' ? wishlistClothes : activeClothes;
|
|
|
|
const availableColors = useMemo(() => {
|
|
const colors = new Set(baseClothes.map(c => c.color).filter(Boolean));
|
|
return Array.from(colors);
|
|
}, [baseClothes]);
|
|
|
|
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 baseClothes.filter(c => {
|
|
const matchesSearch = (c.name || "").toLowerCase().includes(searchTerm.toLowerCase()) ||
|
|
(c.color || "").toLowerCase().includes(searchTerm.toLowerCase());
|
|
const matchesCategory = categoryFilter === 'Todos' || categoryFilter === t('all') || c.category === categoryFilter;
|
|
const matchesColor = !colorFilter || (c.color && c.color.includes(colorFilter));
|
|
const matchesSection = activeSectionFilter === 'all' || (c.sections && c.sections.includes(activeSectionFilter));
|
|
|
|
let matchesAge = true;
|
|
if (ageFilter !== 'any') {
|
|
const ageInMs = new Date().getTime() - (c.createdAt || new Date().getTime());
|
|
const days = ageInMs / (1000 * 60 * 60 * 24);
|
|
if (ageFilter === 'month') matchesAge = days <= 30;
|
|
else if (ageFilter === '6months') matchesAge = days <= 180;
|
|
else if (ageFilter === '1year') matchesAge = days <= 365;
|
|
else if (ageFilter === 'older') matchesAge = days > 365;
|
|
}
|
|
|
|
return matchesSearch && matchesCategory && matchesColor && matchesAge && matchesSection;
|
|
});
|
|
}, [baseClothes, searchTerm, categoryFilter, colorFilter, ageFilter, t, activeSectionFilter]);
|
|
|
|
// Ações de Itens
|
|
const handleItemAction = async (action, item) => {
|
|
if (!user) return;
|
|
const docRef = doc(db, 'artifacts', appId, 'users', user.uid, 'clothes', item.id || item);
|
|
|
|
switch (action) {
|
|
case 'favorite': await updateDoc(docRef, { favorite: !item.favorite }); break;
|
|
case 'trash': await updateDoc(docRef, { status: 'trash', trashedAt: new Date().getTime() }); break;
|
|
case 'restore': await updateDoc(docRef, { status: 'active', trashedAt: null }); break;
|
|
case 'laundry': await updateDoc(docRef, { status: 'laundry' }); break;
|
|
case 'clean': await updateDoc(docRef, { status: 'active' }); break;
|
|
case 'delete': if (window.confirm(t('confirmDeletePerm'))) await deleteDoc(docRef); break;
|
|
}
|
|
};
|
|
|
|
const saveItem = async (e) => {
|
|
e.preventDefault();
|
|
if (!user) return;
|
|
|
|
const formData = new FormData(e.target);
|
|
const colorVal = formData.get('color');
|
|
if (!colorVal || colorVal.trim() === '') {
|
|
alert('Por favor selecione pelo menos uma cor.');
|
|
return;
|
|
}
|
|
|
|
setLoading(true);
|
|
const itemData = {
|
|
name: formData.get('name'),
|
|
category: formData.get('category'),
|
|
color: formData.get('color'),
|
|
imageUrl: formData.get('imageUrl') || 'https://images.unsplash.com/photo-1521572267360-ee0c2909d518?q=80&w=500&auto=format&fit=crop',
|
|
status: formData.get('isWishlist') ? 'wishlist' : (editingItem && editingItem.status === 'wishlist' ? 'active' : (editingItem ? editingItem.status : 'active')),
|
|
favorite: editingItem ? (editingItem.favorite || false) : false,
|
|
sections: itemSections,
|
|
updatedAt: new Date().getTime()
|
|
};
|
|
|
|
try {
|
|
// Guardamos o id se for edição antes de apagar o estado
|
|
const currentEditId = editingItem ? editingItem.id : null;
|
|
|
|
// Navegação instantânea (Optimistic UI Update)
|
|
setEditingItem(null);
|
|
setImageUrlDraft('');
|
|
setCategoryFilter('Todos');
|
|
setColorFilter('');
|
|
setAgeFilter('any');
|
|
setSearchTerm('');
|
|
setView(formData.get('isWishlist') ? 'wishlist' : 'closet');
|
|
|
|
if (currentEditId) {
|
|
const docRef = doc(db, 'artifacts', appId, 'users', user.uid, 'clothes', currentEditId);
|
|
await updateDoc(docRef, itemData);
|
|
} else {
|
|
itemData.createdAt = new Date().getTime();
|
|
const clothesCol = collection(db, 'artifacts', appId, 'users', user.uid, 'clothes');
|
|
await addDoc(clothesCol, itemData);
|
|
}
|
|
} catch (e) {
|
|
console.error(e);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const saveLook = async (e) => {
|
|
e.preventDefault();
|
|
if (selectedForLook.length < 2) return;
|
|
setLoading(true);
|
|
const fd = new FormData(e.target);
|
|
const lookData = {
|
|
name: fd.get('lookName'),
|
|
items: selectedForLook,
|
|
sections: lookSections,
|
|
updatedAt: new Date().getTime()
|
|
};
|
|
try {
|
|
if (editingLook) {
|
|
const docRef = doc(db, 'artifacts', appId, 'users', user.uid, 'looks', editingLook.id);
|
|
await updateDoc(docRef, lookData);
|
|
} else {
|
|
lookData.createdAt = new Date().getTime();
|
|
const looksCol = collection(db, 'artifacts', appId, 'users', user.uid, 'looks');
|
|
await addDoc(looksCol, lookData);
|
|
}
|
|
setSelectedForLook([]);
|
|
setEditingLook(null);
|
|
setView('outfits');
|
|
} catch (e) { console.error(e); }
|
|
finally { setLoading(false); }
|
|
};
|
|
|
|
const deleteLook = async (id) => {
|
|
if (!window.confirm(t('confirmDeleteLook'))) return;
|
|
const docRef = doc(db, 'artifacts', appId, 'users', user.uid, 'looks', id);
|
|
await deleteDoc(docRef);
|
|
};
|
|
|
|
// Gerar link de partilha e copiar para clipboard
|
|
const shareLook = async (look) => {
|
|
if (!user) return;
|
|
try {
|
|
const lookItems = look.items.map(itemId => {
|
|
const item = clothes.find(c => c.id === itemId);
|
|
return item ? {
|
|
name: item.name,
|
|
category: item.category,
|
|
color: item.color,
|
|
imageUrl: item.imageUrl,
|
|
} : null;
|
|
}).filter(Boolean);
|
|
|
|
const sharedCol = collection(db, 'artifacts', appId, 'sharedLooks');
|
|
const docRef = doc(sharedCol); // Gerar ID de forma síncrona para contornar restrições do Safari
|
|
|
|
// Força o uso do domínio oficial se estiver em localhost (para os links de partilha funcionarem)
|
|
const baseUrl = (window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1')
|
|
? 'https://mycloset.epvc.pt'
|
|
: window.location.origin;
|
|
const shareUrl = `${baseUrl}${window.location.pathname}?shared=${docRef.id}`;
|
|
|
|
let copySuccess = false;
|
|
|
|
// Tentar copiar para o clipboard imediatamente no fluxo do clique
|
|
try {
|
|
if (navigator.clipboard && window.isSecureContext) {
|
|
await navigator.clipboard.writeText(shareUrl);
|
|
copySuccess = true;
|
|
} else {
|
|
throw new Error("Clipboard API indisponível");
|
|
}
|
|
} catch (err) {
|
|
// Fallback para Safari antigo ou ambientes sem HTTPS
|
|
try {
|
|
const textArea = document.createElement("textarea");
|
|
textArea.value = shareUrl;
|
|
textArea.style.position = "fixed";
|
|
textArea.style.left = "-9999px";
|
|
document.body.appendChild(textArea);
|
|
textArea.focus();
|
|
textArea.select();
|
|
copySuccess = document.execCommand('copy');
|
|
document.body.removeChild(textArea);
|
|
} catch (fallbackErr) {
|
|
console.error('Erro no fallback de clipboard:', fallbackErr);
|
|
}
|
|
}
|
|
|
|
// Opcional: usar Web Share API se estiver no telemóvel/Mac (comentado caso prefiram só copiar)
|
|
// if (navigator.share) {
|
|
// await navigator.share({ title: 'MyCloset Look', url: shareUrl });
|
|
// }
|
|
|
|
// Agora podemos guardar no Firebase de forma assíncrona
|
|
await setDoc(docRef, {
|
|
lookName: look.name,
|
|
ownerUid: user.uid,
|
|
ownerEmail: user.email || '',
|
|
items: lookItems,
|
|
createdAt: new Date().getTime(),
|
|
});
|
|
|
|
if (copySuccess) {
|
|
setCopiedLookId(look.id);
|
|
setTimeout(() => setCopiedLookId(null), 3000);
|
|
} else {
|
|
alert('Link de partilha: ' + shareUrl);
|
|
}
|
|
} catch (err) {
|
|
console.error('Erro ao partilhar look:', err);
|
|
alert('Erro ao gerar link de partilha.');
|
|
}
|
|
};
|
|
|
|
// Copiar o look partilhado para o armário do utilizador atual
|
|
const copySharedLook = async () => {
|
|
if (!user || !sharedLookData) return;
|
|
setSharedLookCopying(true);
|
|
try {
|
|
const newItemIds = [];
|
|
for (const item of sharedLookData.items) {
|
|
const clothesCol = collection(db, 'artifacts', appId, 'users', user.uid, 'clothes');
|
|
const newItemRef = await addDoc(clothesCol, {
|
|
name: item.name,
|
|
category: item.category,
|
|
color: item.color,
|
|
imageUrl: item.imageUrl,
|
|
status: 'active',
|
|
favorite: false,
|
|
createdAt: new Date().getTime(),
|
|
updatedAt: new Date().getTime(),
|
|
});
|
|
newItemIds.push(newItemRef.id);
|
|
}
|
|
const looksCol = collection(db, 'artifacts', appId, 'users', user.uid, 'looks');
|
|
await addDoc(looksCol, {
|
|
name: sharedLookData.lookName,
|
|
items: newItemIds,
|
|
createdAt: new Date().getTime(),
|
|
updatedAt: new Date().getTime(),
|
|
});
|
|
|
|
// Notify the owner via coleção pública (inboxNotifications)
|
|
if (sharedLookData.ownerUid && sharedLookData.ownerUid !== user.uid) {
|
|
try {
|
|
const inboxCol = collection(db, 'artifacts', appId, 'inboxNotifications');
|
|
await addDoc(inboxCol, {
|
|
type: 'look_copied',
|
|
recipientUid: sharedLookData.ownerUid,
|
|
lookName: sharedLookData.lookName,
|
|
copiedByEmail: userProfile?.username || user.email || 'Alguém',
|
|
createdAt: new Date().getTime(),
|
|
read: false
|
|
});
|
|
} catch (notifErr) {
|
|
console.error('Não foi possível enviar notificação ao dono do look:', notifErr);
|
|
}
|
|
}
|
|
|
|
setShowSharedLookModal(false);
|
|
setSharedLookData(null);
|
|
setView('outfits');
|
|
} catch (err) {
|
|
console.error('Erro ao copiar look:', err);
|
|
alert('Erro ao copiar look.');
|
|
} finally {
|
|
setSharedLookCopying(false);
|
|
}
|
|
};
|
|
|
|
const sendLookToLaundry = async (look) => {
|
|
setLoading(true);
|
|
try {
|
|
const batch = writeBatch(db);
|
|
look.items.forEach(itemId => {
|
|
const docRef = doc(db, 'artifacts', appId, 'users', user.uid, 'clothes', itemId);
|
|
batch.update(docRef, { status: 'laundry' });
|
|
});
|
|
await batch.commit();
|
|
setToastMessage(t('lookSentToLaundry') || 'Peças enviadas para a lavandaria!');
|
|
setTimeout(() => setToastMessage(null), 3000);
|
|
} catch (e) { console.error(e); }
|
|
finally { setLoading(false); }
|
|
};
|
|
|
|
const handleAuth = async (e) => {
|
|
e.preventDefault();
|
|
setAuthError('');
|
|
setLoading(true);
|
|
const fd = new FormData(e.target);
|
|
const email = fd.get('email');
|
|
const password = fd.get('password');
|
|
|
|
try {
|
|
if (authMode === 'login') await signInWithEmailAndPassword(auth, email, password);
|
|
else await createUserWithEmailAndPassword(auth, email, password);
|
|
} catch (err) {
|
|
console.error(err);
|
|
if (err.code === 'auth/operation-not-allowed') {
|
|
setAuthError(t('authErrorDisabled'));
|
|
} else {
|
|
setAuthError(err.message);
|
|
}
|
|
} finally { setLoading(false); }
|
|
};
|
|
|
|
const handleForgotPassword = () => {
|
|
setAuthError('');
|
|
setShowForgotPasswordModal(true);
|
|
};
|
|
|
|
const handleForgotPasswordSubmit = async (e) => {
|
|
e.preventDefault();
|
|
const email = forgotPasswordEmail;
|
|
if (!email) return;
|
|
setLoading(true);
|
|
setAuthError('');
|
|
try {
|
|
await sendPasswordResetEmail(auth, email);
|
|
setShowForgotPasswordModal(false);
|
|
setForgotPasswordEmail('');
|
|
setToastMessage(t('passwordResetSent') || "E-mail de recuperação enviado! Verifique a sua caixa de entrada.");
|
|
} catch (err) {
|
|
console.error(err);
|
|
setAuthError(err.message);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
|
|
|
|
const emptyTrashPermanently = async () => {
|
|
if (!user || !window.confirm(t('confirmEmptyTrash'))) return;
|
|
setLoading(true);
|
|
try {
|
|
const batch = writeBatch(db);
|
|
trashClothes.forEach(item => {
|
|
const docRef = doc(db, 'artifacts', appId, 'users', user.uid, 'clothes', item.id);
|
|
batch.delete(docRef);
|
|
});
|
|
await batch.commit();
|
|
} catch (e) { console.error(e); }
|
|
finally { setLoading(false); }
|
|
};
|
|
|
|
const clearAllToTrash = async () => {
|
|
if (!user || !window.confirm(t('confirmClearAll'))) return;
|
|
setLoading(true);
|
|
try {
|
|
const batch = writeBatch(db);
|
|
activeClothes.forEach(item => {
|
|
const docRef = doc(db, 'artifacts', appId, 'users', user.uid, 'clothes', item.id);
|
|
batch.update(docRef, { status: 'trash', trashedAt: new Date().getTime() });
|
|
});
|
|
await batch.commit();
|
|
} catch (e) { console.error(e); }
|
|
finally { setLoading(false); }
|
|
};
|
|
|
|
const handleProfileImageUpload = (e) => {
|
|
const file = e.target.files[0];
|
|
if (!file || !user) return;
|
|
|
|
const reader = new FileReader();
|
|
reader.onload = (event) => {
|
|
const img = new Image();
|
|
img.onload = async () => {
|
|
const canvas = document.createElement('canvas');
|
|
const MAX_SIZE = 400;
|
|
let width = img.width;
|
|
let height = img.height;
|
|
|
|
if (width > height) {
|
|
if (width > MAX_SIZE) {
|
|
height *= MAX_SIZE / width;
|
|
width = MAX_SIZE;
|
|
}
|
|
} else {
|
|
if (height > MAX_SIZE) {
|
|
width *= MAX_SIZE / height;
|
|
height = MAX_SIZE;
|
|
}
|
|
}
|
|
|
|
canvas.width = width;
|
|
canvas.height = height;
|
|
const ctx = canvas.getContext('2d');
|
|
ctx.drawImage(img, 0, 0, width, height);
|
|
const base64Data = canvas.toDataURL('image/jpeg', 0.8);
|
|
|
|
try {
|
|
const profileDoc = doc(db, 'artifacts', appId, 'users', user.uid, 'profile', 'data');
|
|
await setDoc(profileDoc, { avatar: base64Data }, { merge: true });
|
|
} catch (err) {
|
|
console.error("Error uploading image:", err);
|
|
}
|
|
};
|
|
img.src = event.target.result;
|
|
};
|
|
reader.readAsDataURL(file);
|
|
};
|
|
|
|
const handleItemImageUpload = (e) => {
|
|
const file = e.target.files[0];
|
|
if (!file) return;
|
|
|
|
const reader = new FileReader();
|
|
reader.onload = (event) => {
|
|
const img = new Image();
|
|
img.onload = () => {
|
|
const canvas = document.createElement('canvas');
|
|
const MAX_SIZE = 800; // Tamanho maior para roupas
|
|
let width = img.width;
|
|
let height = img.height;
|
|
|
|
if (width > height) {
|
|
if (width > MAX_SIZE) {
|
|
height *= MAX_SIZE / width;
|
|
width = MAX_SIZE;
|
|
}
|
|
} else {
|
|
if (height > MAX_SIZE) {
|
|
width *= MAX_SIZE / height;
|
|
height = MAX_SIZE;
|
|
}
|
|
}
|
|
|
|
canvas.width = width;
|
|
canvas.height = height;
|
|
const ctx = canvas.getContext('2d');
|
|
ctx.drawImage(img, 0, 0, width, height);
|
|
const base64Data = canvas.toDataURL('image/jpeg', 0.8);
|
|
|
|
setImageUrlDraft(base64Data);
|
|
};
|
|
img.src = event.target.result;
|
|
};
|
|
reader.readAsDataURL(file);
|
|
};
|
|
|
|
const saveProfile = async (e) => {
|
|
e.preventDefault();
|
|
setSavingProfile(true);
|
|
const fd = new FormData(e.target);
|
|
|
|
try {
|
|
const profileDoc = doc(db, 'artifacts', appId, 'users', user.uid, 'profile', 'data');
|
|
const dobDay = fd.get('dobDay');
|
|
const dobMonth = fd.get('dobMonth');
|
|
const dobYear = fd.get('dobYear');
|
|
let dob = fd.get('dob') || '';
|
|
if (dobDay && dobMonth && dobYear) {
|
|
dob = `${dobYear}-${dobMonth}-${dobDay}`;
|
|
}
|
|
|
|
// Perform optimistc setDoc without blocking the UI
|
|
setDoc(profileDoc, {
|
|
username: fd.get('username') || '',
|
|
fullName: fd.get('fullName') || '',
|
|
dob: dob,
|
|
bio: fd.get('bio') || '',
|
|
location: fd.get('location') || ''
|
|
}, { merge: true }).catch(err => {
|
|
console.error(err);
|
|
});
|
|
} catch (err) {
|
|
console.error(err);
|
|
} finally {
|
|
// Re-enable the button shortly after for smooth optimistic UI
|
|
setTimeout(() => {
|
|
setSavingProfile(false);
|
|
}, 600);
|
|
}
|
|
};
|
|
|
|
if (loading && !user) return <div className="h-screen flex items-center justify-center bg-primary-50 dark:bg-gray-950"><Loader2 className="animate-spin text-primary-600" size={40} /></div>;
|
|
|
|
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 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>
|
|
|
|
{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" />
|
|
{authMode === 'login' && (
|
|
<div className="text-right">
|
|
<button type="button" onClick={handleForgotPassword} className="text-[10px] font-black text-primary-500 hover:text-primary-600 uppercase tracking-widest transition-colors">
|
|
{t('forgotPassword') || 'Esqueceu-se da palavra-passe?'}
|
|
</button>
|
|
</div>
|
|
)}
|
|
<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 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>
|
|
|
|
{/* Modal de Forgot Password */}
|
|
{showForgotPasswordModal && (
|
|
<div className="fixed inset-0 z-[200] flex items-center justify-center bg-black/60 backdrop-blur-sm p-6" onClick={() => setShowForgotPasswordModal(false)}>
|
|
<Card className="w-full max-w-md p-8 animate-in zoom-in-95" darkMode={darkMode} onClick={e => e.stopPropagation()}>
|
|
<h3 className="text-2xl font-black mb-4 text-center text-inherit">{t('forgotPassword')}</h3>
|
|
<p className="text-center opacity-70 mb-8 text-sm text-inherit">{t('forgotPasswordPrompt')}</p>
|
|
<form onSubmit={handleForgotPasswordSubmit} className="space-y-4">
|
|
<input name="resetEmail" type="email" placeholder={t('emailPlaceholder')} required value={forgotPasswordEmail} onChange={e => setForgotPasswordEmail(e.target.value)} 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 text-inherit" />
|
|
<button type="submit" disabled={loading} 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 disabled:opacity-50">
|
|
{loading ? t('saving') : t('sendEmailBtn')}
|
|
</button>
|
|
</form>
|
|
<button type="button" onClick={() => setShowForgotPasswordModal(false)} className="w-full mt-6 py-4 uppercase font-black text-[10px] tracking-widest text-gray-500 hover:text-gray-900 dark:hover:text-white transition-colors">
|
|
{t('cancel')}
|
|
</button>
|
|
</Card>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className={`min-h-screen flex transition-all duration-700 ${darkMode ? 'bg-gray-950 text-white dark' : 'bg-[#FDFDFF] text-gray-900'}`}>
|
|
|
|
{/* Sidebar - Design Futurista */}
|
|
<aside className={`
|
|
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'}
|
|
`}>
|
|
<div className="p-10 h-full flex flex-col backdrop-blur-xl">
|
|
<button onClick={() => setView('closet')} className="flex items-center gap-4 mb-16 hover:scale-[1.02] transition-transform text-left cursor-pointer w-full">
|
|
<div className="p-3 bg-primary-600 rounded-2xl shadow-xl shadow-primary-600/30">
|
|
<Shirt className="text-white" size={24} />
|
|
</div>
|
|
<span className="text-3xl font-black tracking-tighter italic">MyCloset</span>
|
|
</button>
|
|
|
|
<nav className="flex-1 space-y-3">
|
|
{[
|
|
{ id: 'dashboard', label: t('dashboard'), icon: LayoutDashboard },
|
|
{ id: 'closet', label: t('closet'), icon: Shirt },
|
|
{ id: 'wishlist', label: t('wishlist') || 'Carrinho', icon: ShoppingBag },
|
|
{ id: 'laundry', label: t('laundry'), icon: Droplets },
|
|
{ id: 'outfits', label: t('outfits'), icon: Sparkles },
|
|
{ id: 'planner', label: t('planning'), icon: Calendar },
|
|
{ id: 'settings', label: t('settings'), icon: Settings },
|
|
].map(item => (
|
|
<button
|
|
key={item.id}
|
|
onClick={() => setView(item.id)}
|
|
className={`w-full flex items-center gap-4 px-6 py-4 rounded-2xl transition-all font-black text-[11px] uppercase tracking-widest ${view === item.id ? 'bg-primary-600 text-white shadow-2xl shadow-primary-600/30 scale-105' : 'opacity-40 hover:opacity-100 hover:bg-primary-500/5'}`}
|
|
>
|
|
<item.icon size={20} />
|
|
<span>{item.label}</span>
|
|
</button>
|
|
))}
|
|
</nav>
|
|
|
|
<div className="mt-auto pt-10 border-t border-inherit">
|
|
<button onClick={() => setView('profile')} className="w-full flex items-center gap-4 mb-8 px-2 text-left hover:bg-gray-100 dark:hover:bg-gray-800 py-3 rounded-2xl transition-all cursor-pointer">
|
|
<div className={`w-12 h-12 rounded-2xl shrink-0 flex items-center justify-center font-black text-white shadow-xl overflow-hidden ${darkMode ? 'bg-primary-500' : 'bg-primary-600'}`}>
|
|
{userProfile?.avatar ? (
|
|
<img src={userProfile.avatar} className="w-full h-full object-cover" alt="Avatar" />
|
|
) : (
|
|
(userProfile?.fullName?.[0] || userProfile?.username?.[0] || user?.email?.[0] || 'U').toUpperCase()
|
|
)}
|
|
</div>
|
|
<div className="flex-1 min-w-0">
|
|
<p className="text-sm font-black truncate">{userProfile?.username || userProfile?.fullName || user?.email?.split('@')[0] || t('userTitle')}</p>
|
|
<Badge variant="success">{t('online')}</Badge>
|
|
</div>
|
|
</button>
|
|
<button onClick={() => {
|
|
// Limpar dados locais antes de fazer logout
|
|
if (user?.uid) localStorage.removeItem(`app-theme-${user.uid}`);
|
|
signOut(auth);
|
|
}} className="w-full py-4 text-red-500 font-black uppercase tracking-widest text-[10px] hover:bg-red-500/10 rounded-2xl transition-all flex items-center justify-center gap-3">
|
|
<LogOut size={16} /> {t('logout')}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</aside>
|
|
|
|
{/* Área Principal */}
|
|
<main className="flex-1 flex flex-col h-screen overflow-hidden">
|
|
|
|
{/* Header Superior */}
|
|
<header className={`h-24 shrink-0 flex items-center justify-between px-8 md:px-12 transition-all border-b border-inherit ${darkMode ? 'bg-gray-950/50' : 'bg-white/50'} backdrop-blur-xl`}>
|
|
<div className="flex items-center gap-6">
|
|
<button onClick={() => setSidebarOpen(!sidebarOpen)} className={`p-3 rounded-2xl transition-all border ${darkMode ? 'bg-gray-800 border-gray-700' : 'bg-gray-50 border-gray-200'}`}>
|
|
{sidebarOpen ? <PanelLeftClose size={24} /> : <PanelLeftOpen size={24} />}
|
|
</button>
|
|
<h2 className="text-3xl font-black tracking-tighter">
|
|
{view === 'dashboard' && t('overview')}
|
|
{view === 'closet' && t('myCloset')}
|
|
{view === 'wishlist' && (t('wishlist') || 'Carrinho')}
|
|
{view === 'laundry' && t('laundry')}
|
|
{view === 'outfits' && t('outfitsAndStyle')}
|
|
{view === 'planner' && t('planning')}
|
|
{view === 'settings' && t('settings')}
|
|
{view === 'profile' && t('profileInfo')}
|
|
</h2>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-4">
|
|
<div className="flex bg-gray-100 dark:bg-gray-800 p-1.5 rounded-2xl">
|
|
<button onClick={() => handleDarkModeToggle(false)} className={`p-2 rounded-xl ${!darkMode ? 'bg-white shadow-md text-primary-600' : 'text-gray-500'}`}><Sun size={18} /></button>
|
|
<button onClick={() => handleDarkModeToggle(true)} className={`p-2 rounded-xl ${darkMode ? 'bg-gray-900 shadow-md text-primary-400' : 'text-gray-500'}`}><Moon size={18} /></button>
|
|
</div>
|
|
<button onClick={() => setShowNotificationsModal(true)} className="relative p-4 bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-200 rounded-2xl hover:scale-105 active:scale-95 transition-all">
|
|
<Bell size={24} />
|
|
{notifications.filter(n => !n.read).length > 0 && (
|
|
<span className="absolute top-2 right-2 w-3 h-3 bg-red-500 rounded-full border-2 border-white dark:border-gray-800"></span>
|
|
)}
|
|
</button>
|
|
<button onClick={() => { setEditingItem(null); setImageUrlDraft(''); setView('add'); setEditingLook(null); setSelectedForLook([]); }} className="p-4 bg-primary-600 text-white rounded-2xl shadow-xl shadow-primary-600/30 hover:scale-105 active:scale-95 transition-all">
|
|
<Plus size={24} />
|
|
</button>
|
|
</div>
|
|
</header>
|
|
|
|
{/* Conteúdo Dinâmico */}
|
|
<div className="flex-1 overflow-y-auto p-8 md:p-12 space-y-12">
|
|
|
|
{/* DASHBOARD */}
|
|
{view === 'dashboard' && (
|
|
<div className="space-y-12 animate-in fade-in duration-700">
|
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-8">
|
|
{[
|
|
{ label: t('readyClothes'), val: activeClothes.length, icon: Shirt, col: 'primary' },
|
|
{ label: t('inLaundry'), val: laundryClothes.length, icon: Droplets, col: 'blue' },
|
|
{ label: t('myLooks'), val: looks.length, icon: Sparkles, col: 'purple' },
|
|
{ label: t('favorites'), val: activeClothes.filter(c => c.favorite).length, icon: Heart, col: 'rose' },
|
|
].map((s, i) => (
|
|
<Card key={i} className="p-8 group hover:-translate-y-2" darkMode={darkMode}>
|
|
<div className={`w-14 h-14 rounded-2xl flex items-center justify-center mb-6 shadow-inner ${darkMode ? 'bg-gray-700 text-primary-400' : 'bg-primary-50 text-primary-600'}`}>
|
|
<s.icon size={28} />
|
|
</div>
|
|
<p className="text-[10px] font-black uppercase tracking-widest opacity-40 mb-1">{s.label}</p>
|
|
<h4 className="text-4xl font-black tracking-tight">{s.val}</h4>
|
|
</Card>
|
|
))}
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
|
<div className="lg:col-span-2 p-10 rounded-[2rem] relative overflow-hidden shadow-2xl" style={{ backgroundColor: 'hsl(var(--primary-600))', color: 'white' }}>
|
|
<div className="relative z-10 flex flex-col justify-between h-full">
|
|
<div>
|
|
<div className="flex items-center gap-3 mb-4">
|
|
<CloudSun size={28} style={{ color: 'rgba(255,255,255,0.7)' }} />
|
|
<Badge variant="warning">{weatherData ? weatherData.name : t('todayIn')}</Badge>
|
|
</div>
|
|
<h3 className="text-5xl font-black tracking-tighter mb-4" style={{ color: 'white' }}>
|
|
{weatherData ? t('weatherCurrentAvg').replace('{current}', weatherData.currentTemp).replace('{avg}', weatherData.avgTemp) : t('weatherUpdate')}
|
|
</h3>
|
|
<p className="text-lg font-medium max-w-lg leading-relaxed" style={{ color: 'rgba(255,255,255,0.8)' }}>
|
|
{weatherData ? `${t('weatherForecastDesc').replace('{max}', weatherData.maxTemp).replace('{min}', weatherData.minTemp)} ${t('weatherMsg')}` : t('weatherMsg')}
|
|
</p>
|
|
</div>
|
|
<div className="mt-10 flex gap-4 items-center">
|
|
{activeClothes.filter(c => c.category === 'Tops').slice(0, 2).map(c => (
|
|
<div key={c.id} className="w-16 h-16 rounded-xl overflow-hidden border-2" style={{ borderColor: 'rgba(255,255,255,0.4)' }}>
|
|
<img src={c.imageUrl} className="w-full h-full object-cover" alt="" />
|
|
</div>
|
|
))}
|
|
<button onClick={() => setView('closet')} className="flex items-center gap-2 font-black uppercase text-xs tracking-widest hover:translate-x-2 transition-transform" style={{ color: 'white' }}>
|
|
{t('exploreSuggestions')} <ArrowRight size={18} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<CloudSun size={350} className="absolute -bottom-20 -right-20" style={{ color: 'rgba(255,255,255,0.1)' }} />
|
|
</div>
|
|
|
|
<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">
|
|
{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>{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: `${stat.percentage}%` }}></div>
|
|
</div>
|
|
</div>
|
|
)) : (
|
|
<p className="text-xs opacity-50 italic">{t('addColorsToItems')}</p>
|
|
)}
|
|
</div>
|
|
</Card>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* ARMÁRIO & WISHLIST */}
|
|
{(view === 'closet' || view === 'wishlist') && (
|
|
<div className="space-y-10 animate-in slide-in-from-bottom-8 duration-700">
|
|
<div className="flex flex-col xl:flex-row gap-8 items-center justify-between">
|
|
<div className="relative w-full max-w-2xl">
|
|
<Search className="absolute left-6 top-1/2 -translate-y-1/2 text-gray-400" size={24} />
|
|
<input
|
|
placeholder={t('searchPlaceholder')}
|
|
className={`w-full pl-16 pr-8 py-6 rounded-[2rem] shadow-inner outline-none border-none focus:ring-4 focus:ring-primary-500/10 font-bold text-lg ${darkMode ? 'bg-gray-800' : 'bg-gray-100'}`}
|
|
onChange={(e) => setSearchTerm(e.target.value)}
|
|
/>
|
|
</div>
|
|
|
|
<div className="flex gap-3 w-full xl:w-auto">
|
|
<button
|
|
onClick={() => setShowClosetFilters(true)}
|
|
className="flex items-center gap-3 px-8 py-4 bg-primary-600 text-white rounded-[2rem] font-black uppercase text-[10px] tracking-widest shadow-xl shadow-primary-600/30 hover:scale-105 transition-all"
|
|
>
|
|
<Filter size={18} /> {t('advancedFilters')}
|
|
{(colorFilter || ageFilter !== 'any' || (categoryFilter !== 'Todos' && categoryFilter !== t('all'))) && (
|
|
<span className="w-2 h-2 rounded-full bg-white animate-pulse"></span>
|
|
)}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Barra de Secções */}
|
|
{(view === 'closet' || view === 'wishlist' || view === 'outfits') && (
|
|
<div className="flex items-center gap-3 overflow-x-auto pb-1 custom-scrollbar">
|
|
<button
|
|
onClick={() => setActiveSectionFilter('all')}
|
|
className={`shrink-0 flex items-center gap-2 px-5 py-2.5 rounded-2xl font-black text-[10px] uppercase tracking-widest transition-all ${activeSectionFilter === 'all' ? 'bg-primary-600 text-white shadow-lg shadow-primary-600/30' : (darkMode ? 'bg-gray-800 text-gray-400 hover:bg-gray-700' : 'bg-gray-100 text-gray-500 hover:bg-gray-200')}`}
|
|
>
|
|
<FolderOpen size={14} /> {t('allSections')}
|
|
</button>
|
|
{sections.map(sec => (
|
|
<button
|
|
key={sec.id}
|
|
onClick={() => setActiveSectionFilter(sec.id)}
|
|
className={`shrink-0 flex items-center gap-2 px-5 py-2.5 rounded-2xl font-black text-[10px] uppercase tracking-widest transition-all ${activeSectionFilter === sec.id ? 'bg-primary-600 text-white shadow-lg shadow-primary-600/30 scale-105' : (darkMode ? 'bg-gray-800 text-gray-400 hover:bg-gray-700' : 'bg-gray-100 text-gray-500 hover:bg-gray-200')}`}
|
|
>
|
|
{sec.name}
|
|
</button>
|
|
))}
|
|
<button
|
|
onClick={() => setShowSectionManager(true)}
|
|
className={`shrink-0 flex items-center gap-2 px-4 py-2.5 rounded-2xl font-black text-[10px] uppercase tracking-widest transition-all border-2 border-dashed ${darkMode ? 'border-gray-700 text-gray-500 hover:border-primary-500 hover:text-primary-400' : 'border-gray-200 text-gray-400 hover:border-primary-400 hover:text-primary-600'}`}
|
|
>
|
|
<Settings size={14} /> {t('manageSections')}
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
<div className={
|
|
cardSize === 'small' ? 'grid grid-cols-2 sm:grid-cols-4 lg:grid-cols-5 2xl:grid-cols-6 gap-6'
|
|
: cardSize === 'medium' ? 'grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 2xl:grid-cols-5 gap-8'
|
|
: 'grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4 gap-10'
|
|
}>
|
|
{filteredClothes.map(item => (
|
|
<div key={item.id} className="group">
|
|
<Card className={`overflow-hidden p-0 relative border-none hover:shadow-2xl transition-all duration-500 ${cardSize === 'small' ? 'h-[180px]' : cardSize === 'medium' ? 'h-[320px]' : 'h-[480px]'}`} darkMode={darkMode}>
|
|
<img src={item.imageUrl} className="w-full h-full object-cover transition-transform duration-1000 group-hover:scale-110" alt={item.name} />
|
|
|
|
<div className="absolute inset-0 bg-gradient-to-t from-black/80 via-transparent opacity-0 group-hover:opacity-100 transition-all duration-300 flex flex-col justify-end p-6 pb-[136px] text-white z-10 pointer-events-none">
|
|
<div className="grid grid-cols-2 gap-2 pointer-events-auto">
|
|
<button onClick={() => { setEditingItem(item); setImageUrlDraft(item.imageUrl || ''); setView('edit'); }} className="py-3 px-2 bg-white text-primary-600 rounded-xl font-black text-[9px] uppercase flex items-center justify-center gap-1.5 hover:bg-primary-50"><Edit2 size={14} /> {t('edit')}</button>
|
|
<button onClick={() => handleItemAction('laundry', item)} className="py-3 px-2 bg-blue-600 text-white rounded-xl font-black text-[9px] uppercase flex items-center justify-center gap-1.5 hover:bg-blue-700"><Droplets size={14} /> {t('makeDirty')}</button>
|
|
<button onClick={() => handleItemAction('trash', item)} className="py-3 px-2 bg-red-600/20 text-red-100 backdrop-blur-md rounded-xl font-black text-[9px] uppercase hover:bg-red-600 transition-colors col-span-2">{t('moveToTrash')}</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="absolute top-6 left-6 z-20"><Badge>{item.category}</Badge></div>
|
|
<div className="absolute top-6 right-6 z-20 pointer-events-auto">
|
|
<button onClick={() => handleItemAction('favorite', item)} className={`p-3 rounded-2xl shadow-xl backdrop-blur-md transition-all ${item.favorite ? 'bg-rose-500 text-white' : 'bg-white/90 text-gray-400'}`}>
|
|
<Heart size={18} fill={item.favorite ? "currentColor" : "none"} />
|
|
</button>
|
|
</div>
|
|
|
|
<div className="absolute bottom-6 left-6 right-6 p-6 bg-white/95 dark:bg-gray-900/95 backdrop-blur-2xl rounded-3xl shadow-2xl transform transition-transform group-hover:-translate-y-2 z-20 pointer-events-auto">
|
|
<h4 className="text-xl font-black tracking-tighter truncate">{item.name}</h4>
|
|
<div className="flex items-center gap-3 mt-2">
|
|
<div className="flex items-center gap-1.5 border-r border-gray-200 dark:border-gray-700 pr-3">
|
|
<div className="w-4 h-4 rounded-full border border-black/10 shrink-0" style={getColorStyle(item.color)}></div>
|
|
<span className="text-[10px] font-black opacity-40 uppercase tracking-widest">{item.color}</span>
|
|
</div>
|
|
{item.sections && item.sections.length > 0 && (
|
|
<div className="flex items-center gap-1 overflow-x-auto custom-scrollbar no-scrollbar">
|
|
{item.sections.map(secId => {
|
|
const sec = sections.find(s => s.id === secId);
|
|
return sec ? (
|
|
<span key={sec.id} className="text-[10px] font-bold px-2 py-0.5 rounded-md bg-gray-100 dark:bg-gray-800 text-gray-500 whitespace-nowrap">
|
|
{sec.name}
|
|
</span>
|
|
) : null;
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* LAVANDARIA */}
|
|
{view === 'laundry' && (
|
|
<div className="space-y-12 animate-in fade-in duration-700">
|
|
<div className="text-center max-w-2xl mx-auto space-y-4 text-inherit">
|
|
<div className="w-20 h-20 bg-blue-100 dark:bg-blue-900/30 rounded-[2rem] flex items-center justify-center mx-auto text-blue-600 shadow-inner">
|
|
<Droplets size={40} />
|
|
</div>
|
|
<h3 className="text-4xl font-black tracking-tight">{t('laundryBasket')}</h3>
|
|
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-8">
|
|
{laundryClothes.map(item => (
|
|
<Card key={item.id} className="p-4 flex items-center gap-4 border-blue-200 dark:border-blue-900/50" darkMode={darkMode}>
|
|
<img src={item.imageUrl} className="w-16 h-16 rounded-2xl object-cover shadow-sm shrink-0" alt="" />
|
|
<div className="flex-1 min-w-0 flex flex-col justify-center items-start">
|
|
<p className="font-black text-sm truncate w-full text-inherit">{item.name}</p>
|
|
<div className="mt-1.5">
|
|
<Badge variant="warning">{t('washing')}</Badge>
|
|
</div>
|
|
</div>
|
|
<button onClick={() => handleItemAction('clean', item)} className="p-3 bg-green-500 text-white rounded-xl shadow-md hover:scale-105 transition-all shrink-0">
|
|
<CheckCircle2 size={20} />
|
|
</button>
|
|
</Card>
|
|
))}
|
|
{laundryClothes.length === 0 && (
|
|
<div className="col-span-full py-20 text-center opacity-20 font-black uppercase tracking-[0.5em] text-sm">{t('emptyBasket')}</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* LOOKS */}
|
|
{view === 'outfits' && (
|
|
<div className="space-y-12 animate-in fade-in duration-700 pb-20">
|
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-12">
|
|
<div className="lg:col-span-1 space-y-8">
|
|
<Card className="p-8 border-primary-200" darkMode={darkMode}>
|
|
<h3 className="text-2xl font-black tracking-tighter mb-6 flex items-center gap-3 text-inherit">
|
|
<Sparkles className="text-primary-600" /> {editingLook ? t('editLook') || 'Editar Outfit' : t('createNewLook')}
|
|
</h3>
|
|
<form key={editingLook ? editingLook.id : 'new'} onSubmit={saveLook} className="space-y-6">
|
|
<input name="lookName" placeholder={t('lookName')} defaultValue={editingLook?.name || ''} required className={`w-full p-4 rounded-xl border-none shadow-inner font-bold ${darkMode ? 'bg-gray-700' : 'bg-gray-100'}`} />
|
|
<div className="space-y-3">
|
|
<p className="text-[10px] font-black uppercase opacity-40 tracking-widest">{t('selectedPieces')} ({selectedForLook.length})</p>
|
|
<div className="flex flex-wrap gap-2">
|
|
{selectedForLook.map(id => {
|
|
const item = clothes.find(c => c.id === id);
|
|
return (
|
|
<div key={id} className="relative group">
|
|
<img src={item?.imageUrl} className="w-12 h-12 rounded-lg object-cover border-2 border-primary-500" alt="" />
|
|
<button type="button" onClick={() => setSelectedForLook(selectedForLook.filter(i => i !== id))} className="absolute -top-1 -right-1 bg-red-500 text-white rounded-full p-0.5 opacity-0 group-hover:opacity-100 transition-opacity"><X size={10} /></button>
|
|
</div>
|
|
);
|
|
})}
|
|
{selectedForLook.length === 0 && <p className="text-xs text-gray-400 italic">{t('selectPieces')}</p>}
|
|
</div>
|
|
</div>
|
|
<div className="space-y-3 pt-3 border-t border-inherit">
|
|
<div className="flex items-center justify-between">
|
|
<label className="text-[10px] font-black uppercase tracking-widest opacity-50 flex items-center gap-2">
|
|
<Tag size={12} /> {t('assignSections')}
|
|
</label>
|
|
<button type="button" onClick={() => setShowSectionManager(true)} className="text-[10px] font-black uppercase tracking-widest text-primary-600 hover:text-primary-700 flex items-center gap-1">
|
|
<Plus size={10} /> {t('createSection')}
|
|
</button>
|
|
</div>
|
|
{sections.length === 0 ? (
|
|
<div className="p-4 border-2 border-dashed border-gray-200 dark:border-gray-800 rounded-2xl text-center">
|
|
<p className="text-[10px] font-black uppercase tracking-widest opacity-40">{t('noSectionsCreated')}</p>
|
|
</div>
|
|
) : (
|
|
<div className="flex flex-wrap gap-2">
|
|
{sections.map(sec => (
|
|
<button
|
|
key={sec.id}
|
|
type="button"
|
|
onClick={() => {
|
|
if (lookSections.includes(sec.id))
|
|
setLookSections(lookSections.filter(s => s !== sec.id));
|
|
else
|
|
setLookSections([...lookSections, sec.id]);
|
|
}}
|
|
className={`flex items-center gap-2 px-3 py-1.5 rounded-xl text-xs font-bold transition-all border-2 ${lookSections.includes(sec.id) ? 'border-primary-600 bg-primary-600 text-white shadow-md shadow-primary-600/30' : 'border-transparent bg-gray-100 dark:bg-gray-800 text-gray-500 hover:bg-gray-200 dark:hover:bg-gray-700'}`}
|
|
>
|
|
{sec.name}
|
|
{lookSections.includes(sec.id) && <Check size={12} />}
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
<div className="flex gap-4">
|
|
{editingLook && (
|
|
<button type="button" onClick={() => { setEditingLook(null); setSelectedForLook([]); }} className="flex-1 py-4 font-black uppercase text-[10px] tracking-widest text-gray-500 hover:text-gray-900 transition-colors">{t('cancel')}</button>
|
|
)}
|
|
<button disabled={selectedForLook.length < 2} className="flex-[2] py-4 bg-primary-600 text-white rounded-2xl font-black uppercase tracking-widest text-xs shadow-xl shadow-primary-600/30 disabled:opacity-30 transition-all">
|
|
{editingLook ? t('saveChanges') || 'Guardar' : t('saveLook')}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</Card>
|
|
|
|
<div className="space-y-4">
|
|
<p className="text-xs font-black uppercase opacity-50 tracking-widest px-2">{t('closetLabel')}</p>
|
|
<div className="grid grid-cols-4 gap-3 max-h-96 overflow-y-auto pr-2 custom-scrollbar">
|
|
{availableForLooks.map(c => (
|
|
<button key={c.id} onClick={() => !selectedForLook.includes(c.id) && setSelectedForLook([...selectedForLook, c.id])} className={`relative rounded-xl overflow-hidden aspect-square border-2 transition-all ${selectedForLook.includes(c.id) ? 'border-primary-600 scale-90' : 'border-transparent hover:border-primary-200'}`}>
|
|
<img src={c.imageUrl} className="w-full h-full object-cover" alt="" />
|
|
{c.status === 'wishlist' && <div className="absolute top-1 left-1 bg-yellow-500 text-white p-1 rounded-md shadow-md"><ShoppingBag size={10} /></div>}
|
|
{selectedForLook.includes(c.id) && <div className="absolute inset-0 bg-primary-600/40 flex items-center justify-center text-white"><Check size={20} /></div>}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="lg:col-span-2 space-y-10">
|
|
{(() => {
|
|
const filteredBySectionLooks = looks.filter(look => {
|
|
const matchesSection = activeSectionFilter === 'all' || (look.sections && look.sections.includes(activeSectionFilter));
|
|
let matchesColor = true;
|
|
if (colorFilter) {
|
|
matchesColor = look.items.some(id => {
|
|
const item = clothes.find(c => c.id === id);
|
|
return item && item.color && item.color.includes(colorFilter);
|
|
});
|
|
}
|
|
return matchesSection && matchesColor;
|
|
});
|
|
|
|
const availableLooks = filteredBySectionLooks.filter(look =>
|
|
look.items.every(id => {
|
|
const item = clothes.find(c => c.id === id);
|
|
return !item || item.status !== 'laundry';
|
|
})
|
|
);
|
|
const laundryLooks = filteredBySectionLooks.filter(look =>
|
|
look.items.some(id => {
|
|
const item = clothes.find(c => c.id === id);
|
|
return item && item.status === 'laundry';
|
|
})
|
|
);
|
|
|
|
const renderLookCard = (look) => {
|
|
const hasLaundryPieces = look.items.some(id => {
|
|
const item = clothes.find(c => c.id === id);
|
|
return item && item.status === 'laundry';
|
|
});
|
|
return (
|
|
<Card key={look.id} className={`p-8 group hover:shadow-2xl transition-all border-none shadow-md ${hasLaundryPieces ? 'opacity-75' : ''}`} darkMode={darkMode}>
|
|
<div className="flex justify-between items-start mb-6">
|
|
<div className="text-inherit">
|
|
<h4 className="text-xl font-black tracking-tight">{look.name}</h4>
|
|
<p className="text-[10px] opacity-40 font-bold uppercase tracking-widest">{look.items.length} {t('pieces')} • {new Date(look.createdAt).toLocaleDateString()}</p>
|
|
</div>
|
|
<div className="flex gap-2">
|
|
<button
|
|
onClick={() => shareLook(look)}
|
|
className={`p-2 transition-colors relative group/share ${copiedLookId === look.id ? 'text-green-500' : 'text-gray-300 hover:text-green-500'}`}
|
|
title="Partilhar outfit"
|
|
>
|
|
{copiedLookId === look.id ? <Check size={18} /> : <Share2 size={18} />}
|
|
<span className="absolute -top-8 left-1/2 -translate-x-1/2 bg-gray-900 text-white text-[9px] font-black uppercase tracking-widest px-2 py-1 rounded-lg whitespace-nowrap opacity-0 group-hover/share:opacity-100 transition-opacity pointer-events-none">
|
|
{copiedLookId === look.id ? t('linkCopied') : t('share')}
|
|
</span>
|
|
</button>
|
|
<button onClick={() => { setEditingLook(look); setSelectedForLook(look.items); }} className="p-2 text-gray-300 hover:text-primary-500 transition-colors"><Edit2 size={18} /></button>
|
|
<button onClick={() => sendLookToLaundry(look)} className="p-2 text-gray-300 hover:text-blue-500 transition-colors" title="Lavar outfit inteiro"><Droplets size={18} /></button>
|
|
<button onClick={() => deleteLook(look.id)} className="p-2 text-gray-300 hover:text-red-500 transition-colors"><Trash size={18} /></button>
|
|
</div>
|
|
</div>
|
|
<div className="flex -space-x-4 mb-4">
|
|
{look.items.map(itemId => {
|
|
const item = clothes.find(c => c.id === itemId);
|
|
const inLaundry = item?.status === 'laundry';
|
|
return (
|
|
<div key={itemId} className={`relative w-20 h-20 rounded-2xl border-4 overflow-hidden shadow-lg transform group-hover:rotate-6 transition-transform ${inLaundry ? 'border-blue-400' : 'border-white dark:border-gray-800'}`}>
|
|
<img src={item?.imageUrl} className={`w-full h-full object-cover ${inLaundry ? 'brightness-75' : ''}`} alt="" />
|
|
{inLaundry && (
|
|
<div className="absolute inset-0 flex items-center justify-center bg-blue-500/30 backdrop-blur-[1px]">
|
|
<Droplets size={18} className="text-white drop-shadow" />
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
{hasLaundryPieces && (
|
|
<div className="flex items-center gap-2 mt-2 px-3 py-2 bg-blue-50 dark:bg-blue-900/20 rounded-xl">
|
|
<Droplets size={14} className="text-blue-500 shrink-0" />
|
|
<p className="text-[10px] font-black uppercase tracking-widest text-blue-500">
|
|
{look.items.filter(id => { const it = clothes.find(c => c.id === id); return it?.status === 'laundry'; }).length} {t('piecesInLaundry')}
|
|
</p>
|
|
</div>
|
|
)}
|
|
{look.sections && look.sections.length > 0 && (
|
|
<div className="flex items-center gap-1 mt-4 overflow-x-auto custom-scrollbar no-scrollbar">
|
|
{look.sections.map(secId => {
|
|
const sec = sections.find(s => s.id === secId);
|
|
return sec ? (
|
|
<span key={sec.id} className="text-[10px] font-bold px-2 py-0.5 rounded-md bg-gray-100 dark:bg-gray-800 text-gray-500 whitespace-nowrap">
|
|
{sec.name}
|
|
</span>
|
|
) : null;
|
|
})}
|
|
</div>
|
|
)}
|
|
</Card>
|
|
);
|
|
};
|
|
|
|
return (
|
|
<>
|
|
{/* Looks disponíveis */}
|
|
<div className="space-y-6">
|
|
<div className="flex items-center justify-between px-2 flex-wrap gap-4">
|
|
<div className="flex items-center gap-3">
|
|
<div className="w-2.5 h-2.5 rounded-full bg-green-500"></div>
|
|
<h3 className="text-2xl font-black tracking-tighter text-inherit">{t('lookHistory')} <span className="text-sm font-bold opacity-40">— {t('availableLooks')} ({availableLooks.length})</span></h3>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<button
|
|
onClick={handlePasteSharedLink}
|
|
className={`flex items-center gap-2 px-4 py-2 rounded-xl font-black text-[10px] uppercase tracking-widest transition-colors ${darkMode ? 'bg-primary-900/30 text-primary-400 hover:bg-primary-900/50' : 'bg-primary-50 text-primary-600 hover:bg-primary-100'}`}
|
|
title={t('pasteSharedLookLink') || 'Colar link de look'}
|
|
>
|
|
<Link size={14} /> <span className="hidden sm:inline">{t('pasteLink') || 'Colar Link'}</span>
|
|
</button>
|
|
<Filter size={16} className="text-gray-400 ml-2" />
|
|
<select
|
|
value={colorFilter}
|
|
onChange={(e) => setColorFilter(e.target.value)}
|
|
className={`p-2 rounded-xl border-none outline-none focus:ring-2 focus:ring-primary-500 font-bold text-xs ${darkMode ? 'bg-gray-800 text-white' : 'bg-gray-100'}`}
|
|
>
|
|
<option value="">{t('all') || 'Todas as cores'}</option>
|
|
{['Vermelho', 'Azul', 'Amarelo', 'Verde', 'Laranja', 'Roxo', 'Branco', 'Preto', 'Cinzento', 'Bege'].map(c => <option key={c} value={c}>{c}</option>)}
|
|
</select>
|
|
</div>
|
|
</div>
|
|
{availableLooks.length > 0 ? (
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
|
|
{availableLooks.map(renderLookCard)}
|
|
</div>
|
|
) : (
|
|
<div className="py-12 text-center opacity-20 font-black uppercase tracking-[0.3em] text-sm">{t('noLooksAvailable')}</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Looks com peças na lavandaria */}
|
|
{laundryLooks.length > 0 && (
|
|
<div className="space-y-6">
|
|
<div className="flex items-center gap-3 px-2">
|
|
<div className="w-2.5 h-2.5 rounded-full bg-blue-400"></div>
|
|
<h3 className="text-2xl font-black tracking-tighter text-inherit">{t('toBeWashed')} <span className="text-sm font-bold opacity-40">— {t('unavailable')} ({laundryLooks.length})</span></h3>
|
|
</div>
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
|
|
{laundryLooks.map(renderLookCard)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</>
|
|
);
|
|
})()}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* PLANEADOR */}
|
|
{view === 'planner' && (() => {
|
|
const today = new Date();
|
|
today.setHours(0, 0, 0, 0);
|
|
const todayStr = `${today.getFullYear()}-${String(today.getMonth()+1).padStart(2,'0')}-${String(today.getDate()).padStart(2,'0')}`;
|
|
const year = plannerCurrentDate.getFullYear();
|
|
const month = plannerCurrentDate.getMonth();
|
|
|
|
const fmtDate = (d) => `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`;
|
|
|
|
const getPlan = (ds) => outfitPlans.find(p => p.date === ds);
|
|
const getLookForDay = (ds) => { const p = getPlan(ds); return p ? looks.find(l => l.id === p.lookId) : null; };
|
|
|
|
const getMonthDays = () => {
|
|
const first = new Date(year, month, 1);
|
|
const last = new Date(year, month + 1, 0);
|
|
const offset = (first.getDay() + 6) % 7;
|
|
const days = [];
|
|
for (let i = 0; i < offset; i++) days.push({ date: new Date(year, month, 1 - offset + i), cur: false });
|
|
for (let d = 1; d <= last.getDate(); d++) days.push({ date: new Date(year, month, d), cur: true });
|
|
const rem = (7 - (days.length % 7)) % 7;
|
|
for (let i = 1; i <= rem; i++) days.push({ date: new Date(year, month + 1, i), cur: false });
|
|
return days;
|
|
};
|
|
|
|
const getWeekDays = () => {
|
|
const d = new Date(plannerCurrentDate);
|
|
const off = (d.getDay() + 6) % 7;
|
|
const mon = new Date(d); mon.setDate(d.getDate() - off);
|
|
return Array.from({ length: 7 }, (_, i) => { const x = new Date(mon); x.setDate(mon.getDate() + i); return x; });
|
|
};
|
|
|
|
const langLocaleMap = { PT: 'pt-PT', EN: 'en-GB', ES: 'es-ES', FR: 'fr-FR', DE: 'de-DE' };
|
|
const locale = langLocaleMap[language] || 'pt-PT';
|
|
const monthNames = Array.from({ length: 12 }, (_, i) => {
|
|
const d = new Date(2000, i, 1);
|
|
const name = d.toLocaleDateString(locale, { month: 'long' });
|
|
return name.charAt(0).toUpperCase() + name.slice(1);
|
|
});
|
|
const dayHeaders = Array.from({ length: 7 }, (_, i) => {
|
|
const d = new Date(2024, 0, i + 1); // 2024-01-01 is Monday
|
|
return d.toLocaleDateString(locale, { weekday: 'short' }).replace('.', '');
|
|
});
|
|
|
|
const prev = () => { const d = new Date(plannerCurrentDate); plannerMode === 'month' ? d.setMonth(month-1) : d.setDate(d.getDate()-7); setPlannerCurrentDate(d); };
|
|
const next = () => { const d = new Date(plannerCurrentDate); plannerMode === 'month' ? d.setMonth(month+1) : d.setDate(d.getDate()+7); setPlannerCurrentDate(d); };
|
|
|
|
const wDays = getWeekDays();
|
|
const weekLabel = `${wDays[0].getDate()} ${monthNames[wDays[0].getMonth()]} — ${wDays[6].getDate()} ${monthNames[wDays[6].getMonth()]} ${wDays[6].getFullYear()}`;
|
|
|
|
const DayCell = ({ date, cur = true }) => {
|
|
const ds = fmtDate(date);
|
|
const look = getLookForDay(ds);
|
|
const isToday = ds === todayStr;
|
|
const isWeek = plannerMode === 'week';
|
|
return (
|
|
<div
|
|
onClick={() => { setPlannerPickerDate(ds); setShowPlannerPicker(true); }}
|
|
className={`relative rounded-2xl overflow-hidden cursor-pointer transition-all group border-2 ${isToday ? 'border-primary-600 shadow-lg shadow-primary-600/20' : !cur ? 'border-transparent opacity-30' : 'border-transparent hover:border-primary-300 dark:hover:border-primary-700'} ${darkMode ? 'bg-gray-800/80' : 'bg-gray-50'}`}
|
|
style={{ minHeight: isWeek ? '180px' : '100px' }}
|
|
>
|
|
<div className={`px-3 py-2 flex items-center justify-between ${isToday ? 'bg-primary-600' : ''}`}>
|
|
<span className={`text-xs font-black ${isToday ? 'text-white' : ''}`}>{date.getDate()}</span>
|
|
{isToday && <span className="text-[8px] font-black text-white/80 uppercase tracking-widest">{t('today')}</span>}
|
|
</div>
|
|
{look ? (
|
|
<div className="px-2 pb-2 space-y-1">
|
|
<div className="flex -space-x-2">
|
|
{look.items.slice(0, isWeek ? 4 : 3).map(itemId => {
|
|
const it = clothes.find(c => c.id === itemId);
|
|
return it ? <div key={itemId} className={`${isWeek ? 'w-10 h-10' : 'w-7 h-7'} rounded-lg overflow-hidden border-2 border-white dark:border-gray-700 shrink-0`}><img src={it.imageUrl} className="w-full h-full object-cover" alt="" /></div> : null;
|
|
})}
|
|
</div>
|
|
<p className="text-[9px] font-black uppercase tracking-widest opacity-50 truncate">{look.name}</p>
|
|
{isWeek && <p className="text-[9px] opacity-40 font-bold">{look.items.length} {t('piecesShort')}</p>}
|
|
</div>
|
|
) : (
|
|
cur && <div className="absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity">
|
|
<div className={`flex items-center gap-1 px-3 py-1.5 rounded-xl text-[9px] font-black uppercase tracking-widest ${darkMode ? 'bg-gray-700 text-primary-400' : 'bg-white text-primary-600 shadow-sm'}`}>
|
|
<Plus size={10} /> Outfit
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
return (
|
|
<div className="space-y-6 animate-in fade-in duration-700 pb-20">
|
|
{/* Controles */}
|
|
<div className="flex items-center justify-between flex-wrap gap-4">
|
|
<div className="flex items-center gap-3">
|
|
<button onClick={prev} className={`p-3 rounded-2xl transition-all border ${darkMode ? 'bg-gray-800 border-gray-700 hover:bg-gray-700' : 'bg-white border-gray-200 hover:bg-gray-50'} shadow-sm`}>
|
|
<ChevronLeft size={20} />
|
|
</button>
|
|
<h3 className="text-lg font-black tracking-tight min-w-[220px] text-center">
|
|
{plannerMode === 'month' ? `${monthNames[month]} ${year}` : weekLabel}
|
|
</h3>
|
|
<button onClick={next} className={`p-3 rounded-2xl transition-all border ${darkMode ? 'bg-gray-800 border-gray-700 hover:bg-gray-700' : 'bg-white border-gray-200 hover:bg-gray-50'} shadow-sm`}>
|
|
<ChevronRight size={20} />
|
|
</button>
|
|
<button onClick={() => setPlannerCurrentDate(new Date())} className="px-4 py-2 text-[10px] font-black uppercase tracking-widest text-primary-600 bg-primary-50 dark:bg-primary-900/20 rounded-xl hover:bg-primary-100 dark:hover:bg-primary-900/40 transition-colors">
|
|
{t('today')}
|
|
</button>
|
|
</div>
|
|
<div className={`flex p-1.5 rounded-2xl gap-1 ${darkMode ? 'bg-gray-800' : 'bg-gray-100'}`}>
|
|
{['month','week'].map(m => (
|
|
<button key={m} onClick={() => setPlannerMode(m)} className={`px-5 py-2 rounded-xl font-black text-[10px] uppercase tracking-widest transition-all ${plannerMode === m ? `${darkMode ? 'bg-gray-700' : 'bg-white'} shadow-md text-primary-600` : 'text-gray-500 hover:text-gray-700'}`}>
|
|
{m === 'month' ? t('monthLabel') : t('weekLabel')}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Cabeçalhos dos dias */}
|
|
<div className="grid grid-cols-7 gap-2">
|
|
{dayHeaders.map(h => (
|
|
<div key={h} className="text-center text-[10px] font-black uppercase tracking-widest opacity-40 py-1">{h}</div>
|
|
))}
|
|
</div>
|
|
|
|
{/* Grelha */}
|
|
{plannerMode === 'month' ? (
|
|
<div className="grid grid-cols-7 gap-2">
|
|
{getMonthDays().map(({ date, cur }) => <DayCell key={fmtDate(date)} date={date} cur={cur} />)}
|
|
</div>
|
|
) : (
|
|
<div className="grid grid-cols-7 gap-3">
|
|
{getWeekDays().map(date => <DayCell key={fmtDate(date)} date={date} cur={true} />)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
})()}
|
|
|
|
{/* ADICIONAR / EDITAR */}
|
|
{(view === 'add' || view === 'edit') && (
|
|
<div className="max-w-4xl mx-auto animate-in zoom-in-95 duration-500">
|
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-12 items-start">
|
|
<div className="space-y-8">
|
|
<h3 className="text-5xl font-black tracking-tighter text-inherit">{editingItem ? t('edit') : t('newItem')}</h3>
|
|
<Card className="aspect-[3/4] overflow-hidden shadow-2xl relative" darkMode={darkMode}>
|
|
{imageUrlDraft ? (
|
|
<img src={imageUrlDraft} className="w-full h-full object-cover" alt="" />
|
|
) : (
|
|
<div className="h-full flex flex-col items-center justify-center opacity-10">
|
|
<ImageIcon size={100} />
|
|
<p className="font-black uppercase tracking-[0.5em] mt-6">{t('preview')}</p>
|
|
</div>
|
|
)}
|
|
</Card>
|
|
</div>
|
|
|
|
<Card className="p-10 shadow-2xl" darkMode={darkMode}>
|
|
<form onSubmit={saveItem} className="space-y-8">
|
|
<Input label={t('name')} name="name" defaultValue={editingItem?.name} required />
|
|
<div className="space-y-2">
|
|
<label className="text-[10px] font-black uppercase opacity-40 tracking-widest ml-1 text-inherit">{t('category')}</label>
|
|
<select name="category" defaultValue={editingItem?.category || 'Tops'} className={`w-full p-5 rounded-2xl border-none outline-none focus:ring-4 focus:ring-primary-500/10 font-bold ${darkMode ? 'bg-gray-700 text-white' : 'bg-gray-100'}`}>
|
|
<option>{t('tops')}</option><option>{t('bottoms')}</option><option>{t('footwear')}</option><option>{t('coats')}</option><option>{t('accessories')}</option>
|
|
</select>
|
|
</div>
|
|
<label className="flex items-center gap-3 p-4 rounded-xl border border-gray-100 dark:border-gray-800 cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors">
|
|
<input type="checkbox" name="isWishlist" defaultChecked={editingItem?.status === 'wishlist'} className="w-5 h-5 text-primary-600 focus:ring-primary-500 rounded-lg" />
|
|
<div>
|
|
<span className="font-bold text-sm text-inherit">{t('wishlist') || t('wishlistDesc')}</span>
|
|
<p className="text-[10px] uppercase tracking-widest opacity-50">{t('addFuturePurchase')}</p>
|
|
</div>
|
|
</label>
|
|
<div className="space-y-2">
|
|
<label className="text-[10px] font-black uppercase opacity-40 tracking-widest ml-1 text-inherit">{t('color')} *</label>
|
|
<div className="flex flex-wrap gap-2">
|
|
{['Vermelho', 'Azul', 'Amarelo', 'Verde', 'Laranja', 'Roxo', 'Branco', 'Preto', 'Cinzento', 'Bege'].map(c => (
|
|
<button
|
|
key={c}
|
|
type="button"
|
|
onClick={() => {
|
|
if (itemColors.includes(c)) setItemColors(itemColors.filter(color => color !== c));
|
|
else setItemColors([...itemColors, c]);
|
|
}}
|
|
className={`px-4 py-2 rounded-xl text-xs font-bold transition-all border-2 ${itemColors.includes(c) ? 'border-primary-600 bg-primary-600 text-white shadow-lg shadow-primary-600/30' : 'border-transparent bg-gray-100 dark:bg-gray-800 text-gray-500 hover:bg-gray-200 dark:hover:bg-gray-700'}`}
|
|
>
|
|
{c}
|
|
</button>
|
|
))}
|
|
</div>
|
|
<input type="hidden" name="color" value={itemColors.join(', ')} />
|
|
{itemColors.length === 0 && <p className="text-[10px] text-red-500 uppercase tracking-widest font-black mt-2">{t('selectOneColor')}</p>}
|
|
</div>
|
|
<div className="space-y-4">
|
|
<label className="text-[10px] font-black uppercase opacity-40 tracking-widest ml-1 text-inherit flex items-center gap-2"><ImageIcon size={12}/> {t('imageUrl')} ou Upload</label>
|
|
<div className="flex flex-col gap-3">
|
|
<input
|
|
type="text"
|
|
name="imageUrl"
|
|
value={imageUrlDraft}
|
|
onChange={(e) => setImageUrlDraft(e.target.value)}
|
|
placeholder="https://..."
|
|
className={`w-full p-5 rounded-2xl border-none outline-none focus:ring-4 focus:ring-primary-500/10 font-bold transition-all ${darkMode ? 'bg-gray-700 text-white' : 'bg-gray-100 text-gray-900'}`}
|
|
/>
|
|
<div className="flex items-center gap-4">
|
|
<div className="h-px bg-gray-200 dark:bg-gray-700 flex-1"></div>
|
|
<span className="text-[10px] font-black uppercase tracking-widest opacity-30">{t('or')}</span>
|
|
<div className="h-px bg-gray-200 dark:bg-gray-700 flex-1"></div>
|
|
</div>
|
|
<label className={`flex items-center justify-center gap-3 p-5 rounded-2xl cursor-pointer transition-all font-black text-[10px] uppercase tracking-widest border-2 border-dashed ${darkMode ? 'bg-gray-800 border-gray-700 hover:border-primary-500 hover:text-primary-400' : 'bg-gray-50 border-gray-200 hover:border-primary-400 hover:text-primary-600'}`}>
|
|
<ImageIcon size={16} />
|
|
<span>{t('uploadGallery')}</span>
|
|
<input type="file" accept="image/*" className="hidden" onChange={handleItemImageUpload} />
|
|
</label>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Campo de Secções */}
|
|
<div className="space-y-3">
|
|
<div className="flex items-center justify-between">
|
|
<label className="text-[10px] font-black uppercase opacity-40 tracking-widest ml-1 text-inherit flex items-center gap-2">
|
|
<Tag size={12} /> {t('assignSections')}
|
|
</label>
|
|
<button type="button" onClick={() => setShowSectionManager(true)} className="text-[10px] font-black uppercase tracking-widest text-primary-600 hover:text-primary-700 flex items-center gap-1">
|
|
<Plus size={10} /> {t('createSection')}
|
|
</button>
|
|
</div>
|
|
{sections.length === 0 ? (
|
|
<div className="p-4 border-2 border-dashed border-gray-200 dark:border-gray-800 rounded-2xl text-center">
|
|
<p className="text-[10px] font-black uppercase tracking-widest opacity-40">{t('noSectionsCreated')}</p>
|
|
</div>
|
|
) : (
|
|
<div className="flex flex-wrap gap-2">
|
|
{sections.map(sec => (
|
|
<button
|
|
key={sec.id}
|
|
type="button"
|
|
onClick={() => {
|
|
if (itemSections.includes(sec.id))
|
|
setItemSections(itemSections.filter(s => s !== sec.id));
|
|
else
|
|
setItemSections([...itemSections, sec.id]);
|
|
}}
|
|
className={`flex items-center gap-2 px-4 py-2 rounded-xl text-xs font-bold transition-all border-2 ${itemSections.includes(sec.id) ? 'border-primary-600 bg-primary-600 text-white shadow-lg shadow-primary-600/30' : 'border-transparent bg-gray-100 dark:bg-gray-800 text-gray-500 hover:bg-gray-200 dark:hover:bg-gray-700'}`}
|
|
>
|
|
{sec.name}
|
|
{itemSections.includes(sec.id) && <Check size={12} />}
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex gap-4 pt-6">
|
|
<button type="button" onClick={() => { setEditingItem(null); setImageUrlDraft(''); setView('closet'); }} className="flex-1 font-black uppercase text-[10px] opacity-40 hover:opacity-100 tracking-widest transition-all text-inherit">{t('cancel')}</button>
|
|
<button type="submit" className="flex-1 py-5 bg-primary-600 text-white rounded-[2rem] font-black uppercase tracking-widest text-[10px] shadow-2xl shadow-primary-600/40 hover:scale-[1.02] active:scale-95 transition-all">
|
|
{editingItem ? t('save') : t('register')}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</Card>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* PERFIL */}
|
|
{view === 'profile' && (
|
|
<div className="max-w-4xl mx-auto space-y-12 animate-in fade-in duration-700 pb-20">
|
|
<Card className="p-10 border-primary-100 relative overflow-hidden" darkMode={darkMode}>
|
|
<div className="flex items-center gap-8 relative z-10 text-inherit">
|
|
<div className="w-24 h-24 rounded-[2.5rem] bg-primary-600 flex items-center justify-center text-white text-4xl font-black shadow-2xl relative overflow-hidden group cursor-pointer">
|
|
{userProfile?.avatar ? (
|
|
<img src={userProfile.avatar} className="w-full h-full object-cover" alt="Profile" />
|
|
) : (
|
|
<span>{(userProfile?.fullName?.[0] || userProfile?.username?.[0] || user?.email?.[0] || 'U').toUpperCase()}</span>
|
|
)}
|
|
<label className="absolute inset-0 bg-black/50 flex flex-col items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity cursor-pointer text-white">
|
|
<Edit2 size={20} />
|
|
<span className="text-[8px] uppercase font-black mt-1 tracking-widest">{t('edit')}</span>
|
|
<input type="file" accept="image/*" className="hidden" onChange={handleProfileImageUpload} />
|
|
</label>
|
|
</div>
|
|
<div>
|
|
<h3 className="text-3xl font-black tracking-tighter">{userProfile?.fullName || t('yourAccount')}</h3>
|
|
<p className="opacity-60 font-bold text-sm">@{userProfile?.username || user?.email?.split('@')[0] || t('papMode')}</p>
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
|
|
<Card className="p-8" darkMode={darkMode}>
|
|
<h3 className="text-xl font-black mb-6 flex items-center gap-3 text-inherit"><UserCircle className="text-primary-600" /> {t('profileInfo')}</h3>
|
|
<form key={`${userProfile?.username}-${userProfile?.fullName}-${userProfile?.dob}-${userProfile?.bio}-${userProfile?.location}`} onSubmit={saveProfile} className="space-y-6">
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
|
<Input label={t('username')} name="username" defaultValue={userProfile?.username || ''} placeholder="Ex: amari" />
|
|
<Input label={t('fullName')} name="fullName" defaultValue={userProfile?.fullName || ''} placeholder="Ex: Amari Rodriguez" />
|
|
<div className="space-y-2">
|
|
<label className="text-[10px] font-black uppercase opacity-40 tracking-widest ml-1 text-inherit">{t('dob')} {t('optional')}</label>
|
|
<div className="flex gap-2">
|
|
<select name="dobDay" defaultValue={userProfile?.dob?.split('-')[2] || ''} className={`flex-1 p-4 rounded-xl border-none outline-none focus:ring-2 focus:ring-primary-500 font-bold ${darkMode ? 'bg-gray-800 text-white' : 'bg-gray-50'}`}>
|
|
<option value="">DD</option>
|
|
{Array.from({ length: 31 }, (_, i) => String(i + 1).padStart(2, '0')).map(d => <option key={d} value={d}>{d}</option>)}
|
|
</select>
|
|
<select name="dobMonth" defaultValue={userProfile?.dob?.split('-')[1] || ''} className={`flex-1 p-4 rounded-xl border-none outline-none focus:ring-2 focus:ring-primary-500 font-bold ${darkMode ? 'bg-gray-800 text-white' : 'bg-gray-50'}`}>
|
|
<option value="">MM</option>
|
|
{Array.from({ length: 12 }, (_, i) => String(i + 1).padStart(2, '0')).map(m => <option key={m} value={m}>{m}</option>)}
|
|
</select>
|
|
<select name="dobYear" defaultValue={userProfile?.dob?.split('-')[0] || ''} className={`flex-[1.5] p-4 rounded-xl border-none outline-none focus:ring-2 focus:ring-primary-500 font-bold ${darkMode ? 'bg-gray-800 text-white' : 'bg-gray-50'}`}>
|
|
<option value="">YYYY</option>
|
|
{Array.from({ length: 100 }, (_, i) => new Date().getFullYear() - i).map(y => <option key={y} value={y}>{y}</option>)}
|
|
</select>
|
|
</div>
|
|
</div>
|
|
<Input label={`${t('bio')} ${t('optional')}`} name="bio" defaultValue={userProfile?.bio || ''} placeholder="..." />
|
|
<Input label={t('location')} name="location" defaultValue={userProfile?.location || ''} placeholder={t('locationEx')} />
|
|
</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')}
|
|
</button>
|
|
</form>
|
|
</Card>
|
|
</div>
|
|
)}
|
|
|
|
{/* DEFINIÇÕES */}
|
|
{view === 'settings' && (
|
|
<div className="max-w-4xl mx-auto space-y-12 animate-in fade-in duration-700 pb-20">
|
|
|
|
{/* Preferências */}
|
|
<div className="flex flex-col gap-8">
|
|
<Card className="p-8" darkMode={darkMode}>
|
|
<h3 className="text-xl font-black mb-6 flex items-center gap-3 text-inherit"><Settings className="text-primary-600" /> {t('preferences')}</h3>
|
|
<div className="space-y-6">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<p className="font-bold text-inherit">{t('darkMode')}</p>
|
|
<p className="text-[10px] uppercase tracking-widest opacity-50 text-inherit">{t('interfaceAppearance')}</p>
|
|
</div>
|
|
<button onClick={() => handleDarkModeToggle(!darkMode)} className={`w-14 h-8 rounded-full transition-colors relative ${darkMode ? 'bg-primary-600' : 'bg-gray-200'}`}>
|
|
<div className={`w-6 h-6 rounded-full bg-white absolute top-1 transition-all ${darkMode ? 'left-7' : 'left-1'}`}></div>
|
|
</button>
|
|
</div>
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<p className="font-bold text-inherit">{t('themeColor') || 'Cor do Tema'}</p>
|
|
<p className="text-[10px] uppercase tracking-widest opacity-50 text-inherit">{t('personalizeColor') || 'Personalize a cor'}</p>
|
|
</div>
|
|
<div className="flex gap-2">
|
|
{[
|
|
{ id: 'theme-indigo', color: '#4f46e5' },
|
|
{ id: 'theme-rose', color: '#e11d48' },
|
|
{ id: 'theme-emerald', color: '#10b981' },
|
|
{ id: 'theme-amber', color: '#f59e0b' },
|
|
{ id: 'theme-slate', color: '#64748b' }
|
|
].map(tObj => (
|
|
<button
|
|
key={tObj.id}
|
|
onClick={() => handleThemeChange(tObj.id)}
|
|
className={`w-6 h-6 rounded-full transition-all flex items-center justify-center ${theme === tObj.id ? 'ring-2 ring-offset-2 ring-offset-white dark:ring-offset-gray-900 ring-primary-500 scale-110' : 'hover:scale-110'}`}
|
|
style={{ backgroundColor: tObj.color }}
|
|
>
|
|
{theme === tObj.id && <Check size={12} className="text-white" />}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<p className="font-bold text-inherit flex items-center gap-2">{t('notifications')}</p>
|
|
<p className="text-[10px] uppercase tracking-widest opacity-50 text-inherit">{t('lookReminders')}</p>
|
|
</div>
|
|
<button onClick={() => handleNotificationsToggle(!notificationsEnabled)} className={`w-14 h-8 rounded-full transition-colors relative ${notificationsEnabled ? 'bg-primary-600' : 'bg-gray-200'}`}>
|
|
<div className={`w-6 h-6 rounded-full bg-white absolute top-1 transition-all ${notificationsEnabled ? 'left-7' : 'left-1'}`}></div>
|
|
</button>
|
|
</div>
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<p className="font-bold text-inherit flex items-center gap-2">{t('weatherAlerts')}</p>
|
|
<p className="text-[10px] uppercase tracking-widest opacity-50 text-inherit">{t('weatherSuggestions')}</p>
|
|
</div>
|
|
<button onClick={() => handleWeatherAlertsToggle(!weatherAlerts)} className={`w-14 h-8 rounded-full transition-colors relative ${weatherAlerts ? 'bg-primary-600' : 'bg-gray-200'}`}>
|
|
<div className={`w-6 h-6 rounded-full bg-white absolute top-1 transition-all ${weatherAlerts ? 'left-7' : 'left-1'}`}></div>
|
|
</button>
|
|
</div>
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<p className="font-bold text-inherit">{t('cardSize') || 'Tamanho do Card'}</p>
|
|
<p className="text-[10px] uppercase tracking-widest opacity-50 text-inherit">{t('cardSizeDesc') || 'Tamanho no armário/carrinho'}</p>
|
|
</div>
|
|
<div className="flex gap-2">
|
|
{['small', 'medium', 'large'].map(s => (
|
|
<button
|
|
key={s}
|
|
onClick={() => handleCardSizeChange(s)}
|
|
className={`px-3 py-1.5 rounded-xl text-[10px] font-black uppercase tracking-widest transition-all border-2 ${cardSize === s ? 'border-primary-600 bg-primary-600 text-white shadow-lg shadow-primary-600/30 scale-105' : 'border-transparent bg-gray-100 dark:bg-gray-800 text-gray-500 hover:bg-gray-200 dark:hover:bg-gray-700'}`}
|
|
>
|
|
{s === 'small' ? t('small') || 'Pequeno' : s === 'medium' ? t('medium') || 'Médio' : t('large') || 'Grande'}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center justify-between pt-4 border-t border-gray-100 dark:border-gray-800">
|
|
<div>
|
|
<p className="font-bold text-inherit">{t('appLanguage')}</p>
|
|
<p className="text-[10px] uppercase tracking-widest opacity-50 text-inherit">
|
|
{language === 'PT' ? '🇵🇹 ' + t('portuguese') :
|
|
language === 'EN' ? '🇬🇧 ' + t('english') :
|
|
language === 'ES' ? '🇪🇸 ' + t('spanish') :
|
|
language === 'FR' ? '🇫🇷 ' + t('french') :
|
|
language === 'DE' ? '🇩🇪 ' + t('german') : language}
|
|
</p>
|
|
</div>
|
|
<button onClick={() => setShowLangModal(true)} className="px-5 py-2 font-black text-[10px] uppercase tracking-widest bg-primary-50 text-primary-600 rounded-xl hover:bg-primary-100 transition-colors dark:bg-primary-900/40 dark:text-primary-400">
|
|
{t('edit')}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
|
|
<Card className="p-8" darkMode={darkMode}>
|
|
<h3 className="text-xl font-black mb-6 flex items-center gap-3 text-inherit"><Bell className="text-primary-600" /> {t('feedbackTitle') || 'Suporte e Feedback'}</h3>
|
|
<p className="opacity-60 text-sm font-medium mb-6">{t('feedbackDesc') || 'Tem alguma ideia, sugestão ou encontrou algum problema? Envie uma mensagem diretamente para nós!'}</p>
|
|
<form onSubmit={async (e) => {
|
|
e.preventDefault();
|
|
const fd = new FormData(e.target);
|
|
const type = fd.get('type');
|
|
const msg = fd.get('message');
|
|
const email = "faiker027@gmail.com";
|
|
|
|
try {
|
|
const response = await fetch(`https://formsubmit.co/ajax/${email}`, {
|
|
method: "POST",
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Accept': 'application/json'
|
|
},
|
|
body: JSON.stringify({
|
|
Tipo: type,
|
|
Mensagem: msg,
|
|
Utilizador: user?.email || 'Desconhecido',
|
|
_subject: `MyCloset Feedback: ${type}`
|
|
})
|
|
});
|
|
|
|
if (response.ok) {
|
|
setToastMessage(t('msgSentSuccess'));
|
|
setTimeout(() => setToastMessage(null), 4000);
|
|
e.target.reset();
|
|
} else {
|
|
throw new Error('Falha no envio');
|
|
}
|
|
} catch (error) {
|
|
console.error("Erro ao enviar feedback:", error);
|
|
setToastMessage(t('msgSendError'));
|
|
setTimeout(() => setToastMessage(null), 4000);
|
|
}
|
|
}} className="space-y-4">
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<label className="flex items-center gap-3 p-4 rounded-xl border border-gray-100 dark:border-gray-800 cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors">
|
|
<input type="radio" name="type" value="Ideia/Sugestão" defaultChecked className="text-primary-600 focus:ring-primary-500" />
|
|
<span className="font-bold text-sm text-inherit">{t('ideaSuggestion')}</span>
|
|
</label>
|
|
<label className="flex items-center gap-3 p-4 rounded-xl border border-gray-100 dark:border-gray-800 cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors">
|
|
<input type="radio" name="type" value="Bug/Erro" className="text-primary-600 focus:ring-primary-500" />
|
|
<span className="font-bold text-sm text-inherit">{t('bugError')}</span>
|
|
</label>
|
|
</div>
|
|
<textarea name="message" required placeholder={t('writeMessage')} rows={4} className={`w-full p-4 rounded-xl border-none outline-none focus:ring-2 focus:ring-primary-500 font-bold resize-none ${darkMode ? 'bg-gray-800 text-white' : 'bg-gray-50'}`}></textarea>
|
|
<button 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 hover:scale-[1.01] transition-all">
|
|
{t('sendMessage')}
|
|
</button>
|
|
</form>
|
|
</Card>
|
|
|
|
</div>
|
|
|
|
<div className="space-y-6">
|
|
<div className="flex items-center justify-between px-2 text-inherit">
|
|
<h3 className="text-xl font-black text-red-500 flex items-center gap-3 tracking-widest uppercase"><Trash2 size={24} /> {t('recycleBin')}</h3>
|
|
{trashClothes.length > 0 && <button onClick={emptyTrashPermanently} className="text-[10px] font-black text-red-500 uppercase tracking-widest hover:underline">{t('empty')}</button>}
|
|
</div>
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
{trashClothes.map(item => (
|
|
<Card key={item.id} className="p-4 flex items-center gap-5 border-red-50" darkMode={darkMode}>
|
|
<img src={item.imageUrl} className="w-16 h-16 rounded-2xl object-cover grayscale opacity-40" alt="" />
|
|
<div className="flex-1 min-w-0 text-inherit">
|
|
<p className="font-black text-sm truncate">{item.name}</p>
|
|
<p className="text-[10px] font-black text-red-400 uppercase tracking-tighter">{t('deleted')}</p>
|
|
</div>
|
|
<div className="flex gap-1">
|
|
<button onClick={() => handleItemAction('restore', item)} className="p-3 text-primary-600 hover:bg-primary-50 rounded-2xl transition-all"><RotateCcw size={18} /></button>
|
|
<button onClick={() => handleItemAction('delete', item.id)} className="p-3 text-red-600 hover:bg-red-50 rounded-2xl transition-all"><Trash size={18} /></button>
|
|
</div>
|
|
</Card>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
<Card className="p-10 border-red-200 bg-red-50/10" darkMode={darkMode}>
|
|
<div className="flex flex-col md:flex-row items-center justify-between gap-8">
|
|
<div className="text-inherit">
|
|
<h4 className="text-xl font-black text-red-700 flex items-center gap-3"><ShieldAlert /> {t('criticalZone')}</h4>
|
|
<p className="opacity-60 font-bold text-sm mt-2">{t('fullCleanActions')}</p>
|
|
</div>
|
|
<button onClick={clearAllToTrash} className="px-8 py-4 bg-red-600 text-white rounded-2xl font-black uppercase text-[10px] tracking-widest hover:bg-red-700 transition-all">{t('clearAll')}</button>
|
|
</div>
|
|
</Card>
|
|
</div>
|
|
)}
|
|
|
|
</div>
|
|
</main>
|
|
|
|
{/* Modal do Planeador - Escolher Outfit */}
|
|
{showPlannerPicker && plannerPickerDate && (
|
|
<div className="fixed inset-0 z-[200] flex items-center justify-center bg-black/60 backdrop-blur-sm p-6" onClick={() => setShowPlannerPicker(false)}>
|
|
<Card className="w-full max-w-lg p-8 animate-in zoom-in-95 flex flex-col max-h-[80vh]" darkMode={darkMode} onClick={e => e.stopPropagation()}>
|
|
<div className="flex items-center justify-between mb-6">
|
|
<div>
|
|
<h3 className="text-xl font-black text-inherit flex items-center gap-3">
|
|
<Calendar size={22} className="text-primary-600" /> {t('chooseOutfit')}
|
|
</h3>
|
|
<p className="text-[10px] font-black uppercase tracking-widest opacity-40 mt-1">
|
|
{(() => { const locMap = { PT: 'pt-PT', EN: 'en-GB', ES: 'es-ES', FR: 'fr-FR', DE: 'de-DE' }; return new Date(plannerPickerDate + 'T12:00:00').toLocaleDateString(locMap[language] || 'pt-PT', { weekday: 'long', day: 'numeric', month: 'long' }); })()}
|
|
</p>
|
|
</div>
|
|
<button onClick={() => setShowPlannerPicker(false)} className="p-2 bg-gray-100 dark:bg-gray-800 rounded-full hover:scale-110 transition-all text-inherit"><X size={20} /></button>
|
|
</div>
|
|
|
|
{outfitPlans.find(p => p.date === plannerPickerDate) && (
|
|
<button
|
|
onClick={async () => { await assignOutfitToDay(plannerPickerDate, null); setShowPlannerPicker(false); }}
|
|
className="mb-4 w-full py-3 border-2 border-dashed border-red-200 dark:border-red-900/50 text-red-400 rounded-2xl font-black text-[10px] uppercase tracking-widest hover:border-red-400 hover:text-red-500 transition-all flex items-center justify-center gap-2"
|
|
>
|
|
<Trash size={14} /> {t('removeOutfitDay')}
|
|
</button>
|
|
)}
|
|
|
|
<div className="flex-1 overflow-y-auto space-y-3 custom-scrollbar">
|
|
{looks.length === 0 ? (
|
|
<div className="py-12 text-center opacity-30 font-black uppercase tracking-[0.3em] text-sm">{t('noOutfitCreated')}</div>
|
|
) : looks.map(look => {
|
|
const isSelected = outfitPlans.find(p => p.date === plannerPickerDate)?.lookId === look.id;
|
|
return (
|
|
<button
|
|
key={look.id}
|
|
onClick={async () => { await assignOutfitToDay(plannerPickerDate, look.id); setShowPlannerPicker(false); }}
|
|
className={`w-full flex items-center gap-4 p-4 rounded-2xl transition-all border-2 text-left ${isSelected ? 'border-primary-600 bg-primary-50 dark:bg-primary-900/20' : `border-transparent ${darkMode ? 'bg-gray-800 hover:bg-gray-700' : 'bg-gray-50 hover:bg-gray-100'}`}`}
|
|
>
|
|
<div className="flex -space-x-2 shrink-0">
|
|
{look.items.slice(0, 3).map(itemId => {
|
|
const item = clothes.find(c => c.id === itemId);
|
|
return item ? (
|
|
<div key={itemId} className="w-12 h-12 rounded-xl overflow-hidden border-2 border-white dark:border-gray-700">
|
|
<img src={item.imageUrl} className="w-full h-full object-cover" alt="" />
|
|
</div>
|
|
) : null;
|
|
})}
|
|
</div>
|
|
<div className="flex-1 min-w-0">
|
|
<p className="font-black text-sm truncate text-inherit">{look.name}</p>
|
|
<p className="text-[10px] uppercase tracking-widest opacity-40 font-bold">{look.items.length} {t('piecesShort')}</p>
|
|
</div>
|
|
{isSelected && <Check size={18} className="text-primary-600 shrink-0" />}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
</Card>
|
|
</div>
|
|
)}
|
|
|
|
{/* Toast Message */}
|
|
{toastMessage && (
|
|
<div className="fixed bottom-8 left-1/2 transform -translate-x-1/2 z-[300] animate-in slide-in-from-bottom-5">
|
|
<div className="bg-gray-900 text-white px-6 py-3 rounded-full shadow-2xl font-bold text-sm tracking-wide flex items-center gap-3">
|
|
<CheckCircle2 size={18} className="text-green-400" />
|
|
{toastMessage}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Modal de Notificações */}
|
|
{showNotificationsModal && (
|
|
<div className="fixed inset-0 z-[200] flex items-center justify-center bg-black/60 backdrop-blur-sm p-6" onClick={() => setShowNotificationsModal(false)}>
|
|
<Card className="w-full max-w-md p-8 animate-in zoom-in-95 flex flex-col max-h-[80vh]" darkMode={darkMode} onClick={e => e.stopPropagation()}>
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between mb-6">
|
|
<div>
|
|
<h3 className="text-2xl font-black text-inherit flex items-center gap-3">
|
|
<Bell size={24} className="text-primary-600" /> {t('notificationsModal')}
|
|
</h3>
|
|
{notifications.filter(n => !n.read).length > 0 && (
|
|
<p className="text-[10px] font-black uppercase tracking-widest text-primary-600 mt-1">
|
|
{notifications.filter(n => !n.read).length} {language === 'PT' ? 'nova(s)' : language === 'EN' ? 'new' : language === 'ES' ? 'nueva(s)' : language === 'FR' ? 'nouvelle(s)' : 'neue'}
|
|
</p>
|
|
)}
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
{notifications.filter(n => !n.read).length > 0 && (
|
|
<button
|
|
onClick={async () => {
|
|
const batch = writeBatch(db);
|
|
notifications.filter(n => !n.read).forEach(n => {
|
|
const ref = doc(db, 'artifacts', appId, 'inboxNotifications', n.id);
|
|
batch.update(ref, { read: true });
|
|
});
|
|
await batch.commit();
|
|
}}
|
|
className="text-[9px] font-black uppercase tracking-widest text-primary-600 hover:underline px-3 py-2 hover:bg-primary-50 dark:hover:bg-primary-900/20 rounded-xl transition-all"
|
|
>
|
|
{t('markAllRead')}
|
|
</button>
|
|
)}
|
|
<button onClick={() => setShowNotificationsModal(false)} className="p-2 bg-gray-100 dark:bg-gray-800 rounded-full hover:scale-110 transition-all text-inherit"><X size={20} /></button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Lista */}
|
|
<div className="flex-1 overflow-y-auto space-y-3 custom-scrollbar">
|
|
{notifications.length === 0 ? (
|
|
<div className="py-16 text-center flex flex-col items-center gap-4 opacity-30">
|
|
<Bell size={40} />
|
|
<span className="font-black uppercase tracking-[0.3em] text-sm">{t('noNotifications')}</span>
|
|
</div>
|
|
) : notifications.map(notif => (
|
|
<div
|
|
key={notif.id}
|
|
className={`p-4 rounded-2xl flex items-start gap-4 transition-all ${
|
|
!notif.read
|
|
? 'bg-primary-50 dark:bg-primary-900/20 border border-primary-100 dark:border-primary-800/40'
|
|
: (darkMode ? 'bg-gray-800/60' : 'bg-gray-50')
|
|
}`}
|
|
>
|
|
{/* Ícone da Notificação */}
|
|
<div className={`shrink-0 w-12 h-12 flex items-center justify-center rounded-2xl text-xl shadow-sm ${
|
|
!notif.read
|
|
? 'bg-primary-100 dark:bg-primary-900/50'
|
|
: (darkMode ? 'bg-gray-700' : 'bg-gray-200')
|
|
}`}>
|
|
{notif.type === 'look_copied' ? '✂️' : <Bell size={20} />}
|
|
</div>
|
|
|
|
{/* Conteúdo */}
|
|
<div className="flex-1 min-w-0">
|
|
<p className="font-bold text-sm leading-snug text-inherit">
|
|
{notif.type === 'look_copied' && (
|
|
<>
|
|
<span className="text-primary-600 font-black">{notif.copiedByEmail}</span>
|
|
{' '}{t('lookCopiedBy')}{' '}
|
|
<span className="italic">"{notif.lookName}"</span>
|
|
</>
|
|
)}
|
|
</p>
|
|
<p className="text-[10px] uppercase font-black tracking-widest opacity-40 mt-1.5">
|
|
{new Date(notif.createdAt).toLocaleDateString()} às {new Date(notif.createdAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
|
</p>
|
|
</div>
|
|
|
|
{/* Botão marcar como lida */}
|
|
{!notif.read && (
|
|
<button
|
|
onClick={async () => {
|
|
const docRef = doc(db, 'artifacts', appId, 'inboxNotifications', notif.id);
|
|
await updateDoc(docRef, { read: true });
|
|
}}
|
|
className="shrink-0 p-2 text-primary-600 hover:bg-primary-100 dark:hover:bg-primary-900/40 rounded-xl transition-all"
|
|
title="Marcar como lida"
|
|
>
|
|
<Check size={16} />
|
|
</button>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</Card>
|
|
</div>
|
|
)}
|
|
|
|
{/* Modal de Gestão de Secções */}
|
|
{showSectionManager && (
|
|
<div className="fixed inset-0 z-[200] flex items-center justify-center bg-black/60 backdrop-blur-sm p-6" onClick={() => setShowSectionManager(false)}>
|
|
<Card className="w-full max-w-lg p-8 animate-in zoom-in-95 flex flex-col max-h-[90vh]" darkMode={darkMode} onClick={e => e.stopPropagation()}>
|
|
<div className="flex items-center justify-between mb-8">
|
|
<h3 className="text-2xl font-black text-inherit flex items-center gap-3">
|
|
<FolderOpen size={24} className="text-primary-600" /> {t('manageSections')}
|
|
</h3>
|
|
<button onClick={() => setShowSectionManager(false)} className="p-2 bg-gray-100 dark:bg-gray-800 rounded-full hover:scale-110 transition-all text-inherit"><X size={20} /></button>
|
|
</div>
|
|
|
|
{/* Criar nova secção */}
|
|
<div className={`flex gap-3 mb-8 p-4 rounded-2xl ${darkMode ? 'bg-gray-800' : 'bg-gray-50'}`}>
|
|
<input
|
|
value={newSectionName}
|
|
onChange={e => setNewSectionName(e.target.value)}
|
|
placeholder={t('sectionPlaceholder')}
|
|
onKeyDown={e => e.key === 'Enter' && saveSection()}
|
|
className={`flex-1 p-3 rounded-xl border-none outline-none font-bold ${darkMode ? 'bg-gray-700 text-white' : 'bg-white'} shadow-sm`}
|
|
/>
|
|
<button
|
|
onClick={saveSection}
|
|
disabled={!newSectionName.trim()}
|
|
className="px-5 py-3 bg-primary-600 text-white rounded-xl font-black text-[10px] uppercase tracking-widest shadow-lg shadow-primary-600/30 hover:scale-105 active:scale-95 transition-all disabled:opacity-30"
|
|
>
|
|
<Plus size={18} />
|
|
</button>
|
|
</div>
|
|
|
|
{/* Lista de secções */}
|
|
<div className="flex-1 overflow-y-auto space-y-3 custom-scrollbar">
|
|
{sections.length === 0 ? (
|
|
<div className="py-12 text-center opacity-30 font-black uppercase tracking-[0.3em] text-sm">{t('noSections')}</div>
|
|
) : sections.map(sec => (
|
|
<div key={sec.id} className={`flex items-center gap-4 p-4 rounded-2xl transition-all ${darkMode ? 'bg-gray-800' : 'bg-gray-50'}`}>
|
|
{editingSectionId === sec.id ? (
|
|
<>
|
|
<input
|
|
value={editSectionName}
|
|
onChange={e => setEditSectionName(e.target.value)}
|
|
onKeyDown={e => e.key === 'Enter' && updateSection()}
|
|
className={`flex-1 p-2 rounded-xl border-none outline-none font-bold text-sm ${darkMode ? 'bg-gray-700 text-white' : 'bg-white'} shadow-sm`}
|
|
/>
|
|
<button onClick={updateSection} disabled={!editSectionName.trim()} className="p-2 bg-green-500 text-white rounded-xl shadow-md hover:scale-105 disabled:opacity-30"><Check size={16} /></button>
|
|
<button onClick={() => setEditingSectionId(null)} className="p-2 bg-gray-200 dark:bg-gray-700 text-gray-500 dark:text-gray-400 rounded-xl hover:scale-105"><X size={16} /></button>
|
|
</>
|
|
) : (
|
|
<>
|
|
<div className="flex-1 min-w-0">
|
|
<p className="font-black text-sm truncate">{sec.name}</p>
|
|
<p className="text-[10px] opacity-40 font-bold uppercase tracking-widest">
|
|
{clothes.filter(c => c.sections && c.sections.includes(sec.id)).length} {t('pieces')} • {looks.filter(l => l.sections && l.sections.includes(sec.id)).length} look(s)
|
|
</p>
|
|
</div>
|
|
<button
|
|
onClick={() => {
|
|
setEditingSectionId(sec.id);
|
|
setEditSectionName(sec.name);
|
|
setEditSectionEmoji(sec.emoji);
|
|
}}
|
|
className="p-2 text-gray-400 hover:text-primary-600 hover:bg-primary-50 dark:hover:bg-primary-900/20 rounded-xl transition-all"
|
|
>
|
|
<Edit2 size={16} />
|
|
</button>
|
|
<button
|
|
onClick={() => deleteSection(sec.id)}
|
|
className="p-2 text-red-400 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-xl transition-all"
|
|
>
|
|
<Trash size={16} />
|
|
</button>
|
|
</>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
<button onClick={() => setShowSectionManager(false)} className="mt-8 w-full py-4 font-black uppercase text-[10px] tracking-widest text-gray-500 hover:text-gray-900 dark:hover:text-white transition-colors">
|
|
{t('cancel')}
|
|
</button>
|
|
</Card>
|
|
</div>
|
|
)}
|
|
|
|
{/* Modal de Filtros Avançados */}
|
|
{showClosetFilters && (
|
|
<div className="fixed inset-0 z-[200] flex items-center justify-center bg-black/60 backdrop-blur-sm p-6" onClick={() => setShowClosetFilters(false)}>
|
|
<Card className="w-full max-w-lg p-8 animate-in zoom-in-95 flex flex-col max-h-[90vh]" darkMode={darkMode} onClick={e => e.stopPropagation()}>
|
|
<div className="flex items-center justify-between mb-8">
|
|
<h3 className="text-2xl font-black text-inherit flex items-center gap-3"><Filter size={24} className="text-primary-600" /> {t('advancedFilters')}</h3>
|
|
<button onClick={() => setShowClosetFilters(false)} className="p-2 bg-gray-100 dark:bg-gray-800 rounded-full hover:scale-110 transition-all text-inherit"><X size={20} /></button>
|
|
</div>
|
|
|
|
<div className="flex-1 overflow-y-auto space-y-8 pr-2 custom-scrollbar">
|
|
<div className="space-y-4">
|
|
<label className="text-[10px] font-black uppercase opacity-40 tracking-widest ml-1 text-inherit">{t('closet')}</label>
|
|
<div className="flex flex-wrap gap-2">
|
|
{[t('all'), t('tops'), t('bottoms'), t('footwear'), t('coats'), t('accessories')].map(cat => (
|
|
<button
|
|
key={cat}
|
|
onClick={() => setCategoryFilter(cat)}
|
|
className={`px-5 py-3 rounded-xl font-black text-[10px] uppercase tracking-widest transition-all ${categoryFilter === cat ? 'bg-primary-600 text-white shadow-xl shadow-primary-600/30' : (darkMode ? 'bg-gray-800 text-gray-400' : 'bg-gray-50 text-gray-500 shadow-sm border border-gray-100')} hover:scale-[1.02]`}
|
|
>
|
|
{cat}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-4">
|
|
<label className="text-[10px] font-black uppercase opacity-40 tracking-widest ml-1 text-inherit">{t('filterByColor')}</label>
|
|
<select
|
|
value={colorFilter}
|
|
onChange={(e) => setColorFilter(e.target.value)}
|
|
className={`w-full p-4 rounded-2xl border-none outline-none focus:ring-2 focus:ring-primary-500 font-bold ${darkMode ? 'bg-gray-800 text-white' : 'bg-gray-50'}`}
|
|
>
|
|
<option value="">{t('all')}</option>
|
|
{availableColors.map(c => <option key={c} value={c}>{c}</option>)}
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="pt-8 flex gap-4 border-t mt-8 border-gray-100 dark:border-gray-800">
|
|
<button onClick={() => { setCategoryFilter('Todos'); setColorFilter(''); setAgeFilter('any'); }} className="flex-1 py-4 font-black uppercase text-[10px] tracking-widest text-gray-500 hover:text-gray-900 dark:hover:text-white transition-colors">{t('clearAll')}</button>
|
|
<button onClick={() => setShowClosetFilters(false)} className="flex-1 py-4 bg-primary-600 text-white rounded-2xl font-black uppercase text-[10px] tracking-widest shadow-xl shadow-primary-600/30 hover:scale-105 transition-all">{t('applyFilters')}</button>
|
|
</div>
|
|
</Card>
|
|
</div>
|
|
)}
|
|
|
|
{/* Modal de Idioma */}
|
|
{showLangModal && (
|
|
<div className="fixed inset-0 z-[200] flex items-center justify-center bg-black/60 backdrop-blur-sm p-6" onClick={() => setShowLangModal(false)}>
|
|
<Card className="w-full max-w-lg p-8 animate-in zoom-in-95" darkMode={darkMode} onClick={e => e.stopPropagation()}>
|
|
<h3 className="text-2xl font-black mb-8 text-center text-inherit">{t('appLanguage')}</h3>
|
|
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
|
|
{[
|
|
{ id: 'PT', flag: '🇵🇹', label: t('portuguese') },
|
|
{ id: 'EN', flag: '🇬🇧', label: t('english') },
|
|
{ id: 'ES', flag: '🇪🇸', label: t('spanish') },
|
|
{ id: 'FR', flag: '🇫🇷', label: t('french') },
|
|
{ id: 'DE', flag: '🇩🇪', label: t('german') }
|
|
].map(lang => (
|
|
<button
|
|
key={lang.id}
|
|
onClick={() => handleLanguageChange(lang.id)}
|
|
className={`p-6 rounded-2xl flex flex-col items-center justify-center gap-4 transition-all ${language === lang.id ? 'bg-primary-600 text-white shadow-xl shadow-primary-600/30 scale-105' : 'bg-gray-50 text-gray-600 hover:bg-gray-100 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700'}`}
|
|
>
|
|
<span className="text-4xl">{lang.flag}</span>
|
|
<span className="font-black text-[10px] uppercase tracking-widest text-center">{lang.label}</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
<button onClick={() => setShowLangModal(false)} className="w-full mt-8 py-4 uppercase font-black text-[10px] tracking-widest text-gray-500 hover:text-gray-900 dark:hover:text-white transition-colors">
|
|
{t('cancel')}
|
|
</button>
|
|
</Card>
|
|
</div>
|
|
)}
|
|
|
|
{/* Modal de {t('sharedLookTitle')} */}
|
|
{showSharedLookModal && sharedLookData && (
|
|
<div className="fixed inset-0 z-[300] flex items-center justify-center bg-black/70 backdrop-blur-md p-6" onClick={() => { setShowSharedLookModal(false); setSharedLookData(null); }}>
|
|
<div
|
|
className={`w-full max-w-lg rounded-[2rem] shadow-2xl overflow-hidden animate-in zoom-in-95 duration-300 ${darkMode ? 'bg-gray-900' : 'bg-white'}`}
|
|
onClick={e => e.stopPropagation()}
|
|
>
|
|
{/* Header com gradiente */}
|
|
<div className="relative p-8 pb-6" style={{ background: 'linear-gradient(135deg, hsl(var(--primary-600)), hsl(var(--primary-400)))' }}>
|
|
<div className="absolute inset-0 opacity-20" style={{ backgroundImage: 'radial-gradient(circle at 80% 20%, white 0%, transparent 60%)' }} />
|
|
<div className="relative z-10">
|
|
<div className="flex items-center gap-3 mb-2">
|
|
<div className="p-2 bg-white/20 rounded-xl backdrop-blur-sm">
|
|
<Share2 size={20} className="text-white" />
|
|
</div>
|
|
<span className="text-white/80 font-black uppercase text-[10px] tracking-widest">{t('sharedLookTitle')}</span>
|
|
</div>
|
|
<h2 className="text-3xl font-black text-white tracking-tight">{sharedLookData.lookName}</h2>
|
|
<p className="text-white/60 text-sm font-bold mt-1">{sharedLookData.items.length} peça{sharedLookData.items.length !== 1 ? 's' : ''} • {t('sharedBy')} {sharedLookData.ownerEmail?.split('@')[0] || t('someone')}</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Peças do look */}
|
|
<div className={`p-8 ${darkMode ? 'bg-gray-900' : 'bg-white'}`}>
|
|
<p className="text-[10px] font-black uppercase tracking-widest opacity-40 mb-4">{t('includedPieces')}</p>
|
|
<div className="flex flex-wrap gap-3 mb-8">
|
|
{sharedLookData.items.map((item, idx) => (
|
|
<div key={idx} className="relative group/item">
|
|
<div className="w-20 h-20 rounded-2xl overflow-hidden border-2 border-gray-100 dark:border-gray-700 shadow-lg">
|
|
<img src={item.imageUrl} alt={item.name} className="w-full h-full object-cover group-hover/item:scale-110 transition-transform duration-500" />
|
|
</div>
|
|
<div className="absolute -bottom-1 left-1/2 -translate-x-1/2 bg-gray-900/90 text-white text-[8px] font-black uppercase tracking-wide px-2 py-0.5 rounded-full whitespace-nowrap opacity-0 group-hover/item:opacity-100 transition-opacity">
|
|
{item.name}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{/* Descrição das peças */}
|
|
<div className={`space-y-2 mb-8 max-h-32 overflow-y-auto custom-scrollbar`}>
|
|
{sharedLookData.items.map((item, idx) => (
|
|
<div key={idx} className={`flex items-center gap-3 px-4 py-2.5 rounded-xl ${darkMode ? 'bg-gray-800' : 'bg-gray-50'}`}>
|
|
<span className="text-xs font-black truncate flex-1">{item.name}</span>
|
|
<span className={`text-[9px] font-black uppercase tracking-widest opacity-40 shrink-0`}>{item.category}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{/* Ações */}
|
|
<div className="flex gap-3">
|
|
<button
|
|
onClick={() => { setShowSharedLookModal(false); setSharedLookData(null); }}
|
|
className={`flex-1 py-4 font-black uppercase text-[10px] tracking-widest rounded-2xl transition-all ${darkMode ? 'bg-gray-800 text-gray-400 hover:bg-gray-700' : 'bg-gray-100 text-gray-500 hover:bg-gray-200'}`}
|
|
>
|
|
Ignorar
|
|
</button>
|
|
<button
|
|
onClick={copySharedLook}
|
|
disabled={sharedLookCopying}
|
|
className="flex-[2] py-4 font-black uppercase text-[10px] tracking-widest rounded-2xl text-white shadow-xl transition-all hover:scale-[1.02] active:scale-95 disabled:opacity-60 flex items-center justify-center gap-2"
|
|
style={{ background: 'linear-gradient(135deg, hsl(var(--primary-600)), hsl(var(--primary-500)))' }}
|
|
>
|
|
{sharedLookCopying ? (
|
|
<><Loader2 size={16} className="animate-spin" /> {t('copying')}</>
|
|
) : (
|
|
<><Check size={16} /> {t('copyToMyCloset')}</>
|
|
)}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|