This commit is contained in:
2026-05-11 14:59:20 +01:00
parent 95bb34b46d
commit 90dec0d963
7 changed files with 3622 additions and 3382 deletions

View File

@@ -7,7 +7,7 @@ import {
PanelLeftClose, PanelLeftOpen, Sparkles, CloudSun,
ArrowRight, Droplets, CheckCircle2, PieChart, History,
X, Download, Bell, Globe, Filter, ShoppingBag, Share2,
FolderOpen, Tag, Link, Calendar, ChevronLeft, ChevronRight
FolderOpen, Tag, Link, Calendar, ChevronLeft, ChevronRight, Users
} from 'lucide-react';
import {
@@ -16,7 +16,7 @@ import {
} from 'firebase/auth';
import {
collection, doc, onSnapshot, addDoc, updateDoc,
deleteDoc, writeBatch, setDoc, getDoc, query, where
deleteDoc, writeBatch, setDoc, getDoc, query, where, getDocs, collectionGroup
} from 'firebase/firestore';
import { auth, db, appId } from './lib/firebase';
@@ -64,6 +64,15 @@ export default function App() {
const [cardSize, setCardSize] = useState('large');
const [defaultPage, setDefaultPage] = useState('dashboard');
const [weatherData, setWeatherData] = useState(null);
const [isPrivate, setIsPrivate] = useState(false);
const [userStatus, setUserStatus] = useState('online');
// Estado da Comunidade
const [communitySearchTerm, setCommunitySearchTerm] = useState('');
const [communityUsers, setCommunityUsers] = useState([]);
const [selectedCommunityUser, setSelectedCommunityUser] = useState(null);
const [selectedUserClothes, setSelectedUserClothes] = useState([]);
const [selectedUserLooks, setSelectedUserLooks] = useState([]);
// Estado para Partilha de Looks
const sharedLookRef = useRef('');
@@ -127,6 +136,11 @@ export default function App() {
await setDoc(profileDoc, {
settings: { [key]: value }
}, { merge: true });
if (key === 'isPrivate') {
const publicProfileDoc = doc(db, 'artifacts', appId, 'publicProfiles', user.uid);
await setDoc(publicProfileDoc, { isPrivate: value, uid: user.uid }, { merge: true });
}
} catch (err) {
console.error('Error saving setting:', err);
}
@@ -168,6 +182,21 @@ export default function App() {
saveUserSetting('defaultPage', newVal);
};
const handlePrivacyToggle = (newVal) => {
setIsPrivate(newVal);
saveUserSetting('isPrivate', newVal);
};
const toggleStatus = (e) => {
e.stopPropagation();
e.preventDefault();
const statuses = ['online', 'away', 'offline'];
const currentIndex = statuses.indexOf(userStatus);
const nextStatus = statuses[(currentIndex + 1) % statuses.length];
setUserStatus(nextStatus);
saveUserSetting('status', nextStatus);
};
// Buscar o look partilhado pelo link
const fetchSharedLook = async (lookId) => {
if (!lookId) return;
@@ -328,6 +357,8 @@ export default function App() {
if (data.settings.defaultPage !== undefined) {
setDefaultPage(data.settings.defaultPage === 'planning' ? 'planner' : data.settings.defaultPage);
}
if (data.settings.isPrivate !== undefined) setIsPrivate(data.settings.isPrivate);
if (data.settings.status !== undefined) setUserStatus(data.settings.status);
}
}
else setUserProfile({});
@@ -393,6 +424,68 @@ export default function App() {
fetchWeather();
}, [userProfile?.location, user]);
// Sync do perfil público para a Comunidade
useEffect(() => {
if (user && userProfile) {
const publicProfileDoc = doc(db, 'artifacts', appId, 'publicProfiles', user.uid);
setDoc(publicProfileDoc, {
uid: user.uid,
username: userProfile.username || '',
fullName: userProfile.fullName || '',
avatar: userProfile.avatar || null,
isPrivate: userProfile.settings?.isPrivate || false
}, { merge: true }).catch(console.error);
}
}, [user, userProfile?.username, userProfile?.fullName, userProfile?.avatar, userProfile?.settings?.isPrivate]);
// Fetch utilizadores da comunidade
useEffect(() => {
if (view !== 'community') return;
const fetchUsers = async () => {
try {
const profilesRef = collection(db, 'artifacts', appId, 'publicProfiles');
const snap = await getDocs(profilesRef);
const users = snap.docs.map(d => d.data()).filter(u => u.uid !== user?.uid);
if (communitySearchTerm.trim()) {
let term = communitySearchTerm.toLowerCase();
if (term.startsWith('@')) term = term.substring(1);
setCommunityUsers(users.filter(u =>
u.username && u.username.toLowerCase().includes(term)
));
} else {
setCommunityUsers(users);
}
} catch (err) {
console.error("Erro ao buscar comunidade", err);
}
};
fetchUsers();
}, [view, communitySearchTerm, user?.uid]);
const viewCommunityUser = async (targetUser) => {
setSelectedCommunityUser(targetUser);
if (targetUser.isPrivate) {
setSelectedUserClothes([]);
setSelectedUserLooks([]);
return;
}
try {
// Roupas
const clothesCol = collection(db, 'artifacts', appId, 'users', targetUser.uid, 'clothes');
const snapClothes = await getDocs(clothesCol);
setSelectedUserClothes(snapClothes.docs.map(d => ({id: d.id, ...d.data()})).filter(c => c.status !== 'trash'));
// Looks
const looksCol = collection(db, 'artifacts', appId, 'users', targetUser.uid, 'looks');
const snapLooks = await getDocs(looksCol);
setSelectedUserLooks(snapLooks.docs.map(d => ({id: d.id, ...d.data()})));
} catch (err) {
console.error("Erro ao carregar perfil do utilizador", err);
}
};
// --- Lógicas de Negócio ---
const activeClothes = useMemo(() => clothes.filter(c => c.status === 'active'), [clothes]);
@@ -961,6 +1054,23 @@ export default function App() {
const fd = new FormData(e.target);
try {
let usernameInput = (fd.get('username') || '').trim();
if (usernameInput.startsWith('@')) usernameInput = usernameInput.substring(1);
if (usernameInput) {
// Verificar se o nome de utilizador já existe
const publicProfilesRef = collection(db, 'artifacts', appId, 'publicProfiles');
const q = query(publicProfilesRef, where('username', '==', usernameInput));
const snap = await getDocs(q);
const isTaken = snap.docs.some(doc => doc.data().uid !== user.uid);
if (isTaken) {
alert(t('usernameTaken') || 'Este nome de utilizador já está em uso.');
setSavingProfile(false);
return;
}
}
const profileDoc = doc(db, 'artifacts', appId, 'users', user.uid, 'profile', 'data');
const dobDay = fd.get('dobDay');
const dobMonth = fd.get('dobMonth');
@@ -972,7 +1082,7 @@ export default function App() {
// Perform optimistc setDoc without blocking the UI
setDoc(profileDoc, {
username: fd.get('username') || '',
username: usernameInput,
fullName: fd.get('fullName') || '',
dob: dob,
bio: fd.get('bio') || '',
@@ -1098,6 +1208,7 @@ export default function App() {
{ id: 'laundry', label: t('laundry'), icon: Droplets },
{ id: 'outfits', label: t('outfits'), icon: Sparkles },
{ id: 'planner', label: t('planning'), icon: Calendar },
{ id: 'community', label: t('community'), icon: Users },
{ id: 'settings', label: t('settings'), icon: Settings },
].map(item => (
<button
@@ -1112,7 +1223,7 @@ export default function App() {
</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 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" />
@@ -1120,11 +1231,15 @@ export default function App() {
(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 className="flex-1 min-w-0 text-left">
<p className="text-sm font-black truncate text-inherit">@{userProfile?.username || user?.email?.split('@')[0] || t('userTitle')}</p>
<div onClick={toggleStatus} className="inline-block mt-1 cursor-pointer hover:opacity-80 transition-opacity" title="Mudar estado">
<Badge variant={userStatus === 'online' ? 'success' : (userStatus === 'away' ? 'warning' : 'default')}>
{t(userStatus)}
</Badge>
</div>
</div>
</button>
</div>
<button onClick={() => {
// Limpar dados locais antes de fazer logout
if (user?.uid) localStorage.removeItem(`app-theme-${user.uid}`);
@@ -1152,6 +1267,7 @@ export default function App() {
{view === 'laundry' && t('laundry')}
{view === 'outfits' && t('outfitsAndStyle')}
{view === 'planner' && t('planning')}
{view === 'community' && t('community')}
{view === 'settings' && t('settings')}
{view === 'profile' && t('profileInfo')}
</h2>
@@ -2011,7 +2127,13 @@ export default function App() {
<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" />
<div className="space-y-2 relative">
<label className="text-[10px] font-black uppercase opacity-40 tracking-widest ml-1 text-inherit">{t('username')}</label>
<div className="relative">
<span className="absolute left-4 top-1/2 -translate-y-1/2 opacity-40 font-black">@</span>
<input name="username" defaultValue={userProfile?.username || ''} placeholder="amari" className={`w-full p-4 pl-10 rounded-xl border-none outline-none focus:ring-2 focus:ring-primary-500 font-bold ${darkMode ? 'bg-gray-800 text-white' : 'bg-gray-50'}`} />
</div>
</div>
<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>
@@ -2041,6 +2163,101 @@ export default function App() {
</div>
)}
{/* COMUNIDADE */}
{view === 'community' && (
<div className="max-w-7xl mx-auto space-y-12 animate-in fade-in duration-700 pb-20">
{!selectedCommunityUser ? (
<>
<div className="relative mb-8">
<Search className="absolute left-6 top-1/2 -translate-y-1/2 opacity-40 text-inherit" size={24} />
<input
type="text"
placeholder={t('searchUsers')}
value={communitySearchTerm}
onChange={(e) => setCommunitySearchTerm(e.target.value)}
className={`w-full p-6 pl-16 rounded-3xl font-black text-lg outline-none focus:ring-4 focus:ring-primary-500/20 transition-all shadow-xl shadow-black/5 text-inherit ${darkMode ? 'bg-gray-800' : 'bg-white'}`}
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{communityUsers.length === 0 ? (
<div className="col-span-full text-center py-12 opacity-50 text-inherit font-black text-xl">
{t('noUsersFound')}
</div>
) : (
communityUsers.map(u => (
<Card key={u.uid} className="p-6 cursor-pointer hover:scale-105 transition-transform" darkMode={darkMode} onClick={() => viewCommunityUser(u)}>
<div className="flex items-center gap-4 text-inherit">
<div className="w-16 h-16 rounded-2xl bg-primary-600 text-white flex items-center justify-center font-black text-2xl overflow-hidden">
{u.avatar ? <img src={u.avatar} className="w-full h-full object-cover" alt="Avatar"/> : <span>{(u.fullName?.[0] || u.username?.[0] || 'U').toUpperCase()}</span>}
</div>
<div>
<h3 className="font-black text-lg">{u.fullName || t('userTitle')}</h3>
<p className="text-sm opacity-60 font-bold">@{u.username || 'user'}</p>
</div>
</div>
</Card>
))
)}
</div>
</>
) : (
<div className="space-y-8">
<button onClick={() => setSelectedCommunityUser(null)} className="flex items-center gap-2 opacity-60 hover:opacity-100 transition-opacity font-black text-inherit uppercase text-xs tracking-widest">
<ChevronLeft size={16} /> Voltar
</button>
<Card className="p-8 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 text-white flex items-center justify-center font-black text-4xl overflow-hidden">
{selectedCommunityUser.avatar ? <img src={selectedCommunityUser.avatar} className="w-full h-full object-cover" alt="Avatar"/> : <span>{(selectedCommunityUser.fullName?.[0] || selectedCommunityUser.username?.[0] || 'U').toUpperCase()}</span>}
</div>
<div>
<h3 className="text-3xl font-black tracking-tighter">{selectedCommunityUser.fullName || t('userTitle')}</h3>
<p className="opacity-60 font-bold text-sm">@{selectedCommunityUser.username || 'user'}</p>
</div>
</div>
</Card>
{selectedCommunityUser.isPrivate ? (
<div className="text-center py-20 opacity-50 font-black text-2xl text-inherit">
<ShieldAlert className="w-16 h-16 mx-auto mb-4 opacity-50" />
{t('isPrivateUser')}
</div>
) : (
<div className="space-y-12 text-inherit">
<div>
<h3 className="text-xl font-black mb-6 uppercase tracking-widest text-[11px] opacity-50">{t('userOutfits')} ({selectedUserLooks.length})</h3>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-6">
{selectedUserLooks.map(look => (
<div key={look.id} className="group relative aspect-[3/4] rounded-[2rem] overflow-hidden bg-gray-100 dark:bg-gray-800 cursor-pointer shadow-lg">
{look.items && look.items[0] && selectedUserClothes.find(c => c.id === look.items[0]) && (
<img src={selectedUserClothes.find(c => c.id === look.items[0]).imageUrl} className="w-full h-full object-cover group-hover:scale-110 transition-transform duration-700" alt="Look" />
)}
<div className="absolute inset-0 bg-gradient-to-t from-black/80 via-black/20 to-transparent flex items-end p-6">
<span className="text-white font-black text-sm">{look.name}</span>
</div>
</div>
))}
</div>
</div>
<div>
<h3 className="text-xl font-black mb-6 uppercase tracking-widest text-[11px] opacity-50">{t('userCloset')} ({selectedUserClothes.length})</h3>
<div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 gap-4">
{selectedUserClothes.map(item => (
<div key={item.id} className="aspect-square rounded-2xl overflow-hidden bg-gray-100 dark:bg-gray-800 shadow-md">
<img src={item.imageUrl} className="w-full h-full object-cover" alt="Item" />
</div>
))}
</div>
</div>
</div>
)}
</div>
)}
</div>
)}
{/* DEFINIÇÕES */}
{view === 'settings' && (
<div className="max-w-4xl mx-auto space-y-12 animate-in fade-in duration-700 pb-20">
@@ -2101,6 +2318,15 @@ export default function App() {
<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 flex items-center gap-2">{t('privateProfile')}</p>
<p className="text-[10px] uppercase tracking-widest opacity-50 text-inherit">{t('privateProfileDesc')}</p>
</div>
<button onClick={() => handlePrivacyToggle(!isPrivate)} className={`w-14 h-8 rounded-full transition-colors relative ${isPrivate ? 'bg-primary-600' : 'bg-gray-200'}`}>
<div className={`w-6 h-6 rounded-full bg-white absolute top-1 transition-all ${isPrivate ? '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>

View File

@@ -18,6 +18,8 @@ export const translations = {
outfits: "Outfits",
settings: "Definições",
online: "Online",
away: "Ausente",
offline: "Offline",
dailyOutfit: "Outfit Diário",
noOutfitPlanned: "Nenhum Outfit Planeado",
goToPlanning: "Vá ao planeamento para adicionar",
@@ -203,6 +205,16 @@ export const translations = {
large: "Grande",
defaultPage: "Página Inicial",
defaultPageDesc: "Página que aparece após o login",
community: "Comunidade",
searchUsers: "Procurar por @username...",
privateProfile: "Perfil Privado",
privateProfileDesc: "Ocultar armário de outros utilizadores",
viewProfile: "Ver Perfil",
noUsersFound: "Nenhum utilizador encontrado",
isPrivateUser: "Este perfil é privado.",
userOutfits: "Outfits do Utilizador",
userCloset: "Armário",
usernameTaken: "Este nome de utilizador já está em uso.",
},
EN: {
loginModeIntro: "The Future of Your Style",
@@ -223,6 +235,8 @@ export const translations = {
outfits: "Outfits",
settings: "Settings",
online: "Online",
away: "Away",
offline: "Offline",
dailyOutfit: "Daily Outfit",
noOutfitPlanned: "No Outfit Planned",
goToPlanning: "Go to planning to add one",
@@ -408,6 +422,16 @@ export const translations = {
large: "Large",
defaultPage: "Home Page",
defaultPageDesc: "Page that appears after login",
community: "Community",
searchUsers: "Search by @username...",
privateProfile: "Private Profile",
privateProfileDesc: "Hide closet from other users",
viewProfile: "View Profile",
noUsersFound: "No users found",
isPrivateUser: "This profile is private.",
userOutfits: "User's Outfits",
userCloset: "Closet",
usernameTaken: "This username is already taken.",
},
ES: {
loginModeIntro: "El Futuro de Tu Estilo",
@@ -428,6 +452,8 @@ export const translations = {
outfits: "Outfits",
settings: "Ajustes",
online: "En línea",
away: "Ausente",
offline: "Desconectado",
dailyOutfit: "Outfit Diario",
noOutfitPlanned: "Sin Outfit Planeado",
goToPlanning: "Ve a planificación para añadir",
@@ -613,6 +639,16 @@ export const translations = {
large: "Grande",
defaultPage: "Página de Inicio",
defaultPageDesc: "Página que aparece después de iniciar sesión",
community: "Comunidad",
searchUsers: "Buscar por @username...",
privateProfile: "Perfil Privado",
privateProfileDesc: "Ocultar armario de otros usuarios",
viewProfile: "Ver Perfil",
noUsersFound: "Ningún usuario encontrado",
isPrivateUser: "Este perfil es privado.",
userOutfits: "Outfits del Usuario",
userCloset: "Armario",
usernameTaken: "Este nombre de usuario ya está en uso.",
},
FR: {
loginModeIntro: "Le Futur de Ton Style",
@@ -633,6 +669,8 @@ export const translations = {
outfits: "Tenues",
settings: "Paramètres",
online: "En ligne",
away: "Absent",
offline: "Hors ligne",
dailyOutfit: "Tenue du Jour",
noOutfitPlanned: "Aucune Tenue Prévue",
goToPlanning: "Allez dans planification pour ajouter",
@@ -818,6 +856,16 @@ export const translations = {
large: "Grand",
defaultPage: "Page d'Accueil",
defaultPageDesc: "Page qui apparaît après la connexion",
community: "Communauté",
searchUsers: "Rechercher par @username...",
privateProfile: "Profil Privé",
privateProfileDesc: "Cacher le placard aux autres utilisateurs",
viewProfile: "Voir le Profil",
noUsersFound: "Aucun utilisateur trouvé",
isPrivateUser: "Ce profil est privé.",
userOutfits: "Outfits de l'Utilisateur",
userCloset: "Placard",
usernameTaken: "Ce nom d'utilisateur est déjà utilisé.",
},
DE: {
loginModeIntro: "Die Zukunft deines Stils",
@@ -838,6 +886,8 @@ export const translations = {
outfits: "Outfits",
settings: "Einstellungen",
online: "Online",
away: "Abwesend",
offline: "Offline",
dailyOutfit: "Tägliches Outfit",
noOutfitPlanned: "Kein Outfit Geplant",
goToPlanning: "Gehen Sie zur Planung, um eins hinzuzufügen",
@@ -1023,5 +1073,15 @@ export const translations = {
large: "Groß",
defaultPage: "Startseite",
defaultPageDesc: "Seite, die nach der Anmeldung angezeigt wird",
community: "Gemeinschaft",
searchUsers: "Nach @username suchen...",
privateProfile: "Privates Profil",
privateProfileDesc: "Kleiderschrank vor anderen Benutzern verbergen",
viewProfile: "Profil anzeigen",
noUsersFound: "Keine Benutzer gefunden",
isPrivateUser: "Dieses Profil ist privat.",
userOutfits: "Outfits des Benutzers",
userCloset: "Kleiderschrank",
usernameTaken: "Dieser Benutzername ist bereits vergeben.",
}
};