notificacoes, seccoes feito completo e botao lavandaria feito
This commit is contained in:
274
src/App.jsx
274
src/App.jsx
@@ -74,6 +74,14 @@ export default function App() {
|
|||||||
const [newSectionName, setNewSectionName] = useState('');
|
const [newSectionName, setNewSectionName] = useState('');
|
||||||
const [newSectionEmoji, setNewSectionEmoji] = useState('');
|
const [newSectionEmoji, setNewSectionEmoji] = useState('');
|
||||||
const [itemSections, setItemSections] = 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);
|
||||||
|
|
||||||
const t = (key) => translations[language]?.[key] || translations['PT'][key] || key;
|
const t = (key) => translations[language]?.[key] || translations['PT'][key] || key;
|
||||||
|
|
||||||
@@ -163,6 +171,10 @@ export default function App() {
|
|||||||
setItemSections(editingItem?.sections || []);
|
setItemSections(editingItem?.sections || []);
|
||||||
}, [editingItem]);
|
}, [editingItem]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setLookSections(editingLook?.sections || []);
|
||||||
|
}, [editingLook]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
document.documentElement.classList.remove('theme-indigo', 'theme-rose', 'theme-emerald', 'theme-amber', 'theme-slate');
|
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
|
// Ecrã de login/registo: sempre theme-indigo, independentemente das preferências do utilizador
|
||||||
@@ -253,7 +265,13 @@ export default function App() {
|
|||||||
else setUserProfile({});
|
else setUserProfile({});
|
||||||
}, (err) => console.error(err));
|
}, (err) => console.error(err));
|
||||||
|
|
||||||
return () => { unsubClothes(); unsubLooks(); unsubSections(); unsubProfile(); };
|
// Notificações
|
||||||
|
const notifCol = collection(db, 'artifacts', appId, 'users', user.uid, 'notifications');
|
||||||
|
const unsubNotif = onSnapshot(notifCol, (snap) => {
|
||||||
|
setNotifications(snap.docs.map(d => ({ id: d.id, ...d.data() })).sort((a, b) => b.createdAt - a.createdAt));
|
||||||
|
}, (err) => console.error(err));
|
||||||
|
|
||||||
|
return () => { unsubClothes(); unsubLooks(); unsubSections(); unsubProfile(); unsubNotif(); };
|
||||||
}, [user]);
|
}, [user]);
|
||||||
|
|
||||||
// Fetch Weather Data
|
// Fetch Weather Data
|
||||||
@@ -293,9 +311,25 @@ export default function App() {
|
|||||||
const laundryClothes = useMemo(() => clothes.filter(c => c.status === 'laundry'), [clothes]);
|
const laundryClothes = useMemo(() => clothes.filter(c => c.status === 'laundry'), [clothes]);
|
||||||
const trashClothes = useMemo(() => clothes.filter(c => c.status === 'trash'), [clothes]);
|
const trashClothes = useMemo(() => clothes.filter(c => c.status === 'trash'), [clothes]);
|
||||||
const wishlistClothes = useMemo(() => clothes.filter(c => c.status === 'wishlist'), [clothes]);
|
const wishlistClothes = useMemo(() => clothes.filter(c => c.status === 'wishlist'), [clothes]);
|
||||||
const availableForLooks = useMemo(() => clothes.filter(c => c.status === 'active' || 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
|
// 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 () => {
|
const saveSection = async () => {
|
||||||
if (!newSectionName.trim() || !user) return;
|
if (!newSectionName.trim() || !user) return;
|
||||||
const sectionsCol = collection(db, 'artifacts', appId, 'users', user.uid, 'sections');
|
const sectionsCol = collection(db, 'artifacts', appId, 'users', user.uid, 'sections');
|
||||||
@@ -320,6 +354,12 @@ export default function App() {
|
|||||||
batch.update(itemRef, { sections: item.sections.filter(s => s !== 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();
|
await batch.commit();
|
||||||
if (activeSectionFilter === id) setActiveSectionFilter('all');
|
if (activeSectionFilter === id) setActiveSectionFilter('all');
|
||||||
};
|
};
|
||||||
@@ -449,6 +489,7 @@ export default function App() {
|
|||||||
const lookData = {
|
const lookData = {
|
||||||
name: fd.get('lookName'),
|
name: fd.get('lookName'),
|
||||||
items: selectedForLook,
|
items: selectedForLook,
|
||||||
|
sections: lookSections,
|
||||||
updatedAt: new Date().getTime()
|
updatedAt: new Date().getTime()
|
||||||
};
|
};
|
||||||
try {
|
try {
|
||||||
@@ -533,6 +574,19 @@ export default function App() {
|
|||||||
createdAt: new Date().getTime(),
|
createdAt: new Date().getTime(),
|
||||||
updatedAt: new Date().getTime(),
|
updatedAt: new Date().getTime(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Notify the owner
|
||||||
|
if (sharedLookData.ownerUid && sharedLookData.ownerUid !== user.uid) {
|
||||||
|
const notificationsCol = collection(db, 'artifacts', appId, 'users', sharedLookData.ownerUid, 'notifications');
|
||||||
|
await addDoc(notificationsCol, {
|
||||||
|
type: 'look_copied',
|
||||||
|
lookName: sharedLookData.lookName,
|
||||||
|
copiedByEmail: userProfile?.username || user.email || 'Alguém',
|
||||||
|
createdAt: new Date().getTime(),
|
||||||
|
read: false
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
setShowSharedLookModal(false);
|
setShowSharedLookModal(false);
|
||||||
setSharedLookData(null);
|
setSharedLookData(null);
|
||||||
setView('outfits');
|
setView('outfits');
|
||||||
@@ -545,7 +599,6 @@ export default function App() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const sendLookToLaundry = async (look) => {
|
const sendLookToLaundry = async (look) => {
|
||||||
if (!window.confirm(t('confirmSendLookToLaundry') || 'Enviar todas as peças deste look para a lavandaria?')) return;
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const batch = writeBatch(db);
|
const batch = writeBatch(db);
|
||||||
@@ -554,7 +607,8 @@ export default function App() {
|
|||||||
batch.update(docRef, { status: 'laundry' });
|
batch.update(docRef, { status: 'laundry' });
|
||||||
});
|
});
|
||||||
await batch.commit();
|
await batch.commit();
|
||||||
alert(t('lookSentToLaundry') || 'Peças enviadas para a lavandaria!');
|
setToastMessage(t('lookSentToLaundry') || 'Peças enviadas para a lavandaria!');
|
||||||
|
setTimeout(() => setToastMessage(null), 3000);
|
||||||
} catch (e) { console.error(e); }
|
} catch (e) { console.error(e); }
|
||||||
finally { setLoading(false); }
|
finally { setLoading(false); }
|
||||||
};
|
};
|
||||||
@@ -810,6 +864,12 @@ export default function App() {
|
|||||||
<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(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>
|
<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>
|
</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">
|
<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} />
|
<Plus size={24} />
|
||||||
</button>
|
</button>
|
||||||
@@ -917,7 +977,7 @@ export default function App() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Barra de Secções */}
|
{/* Barra de Secções */}
|
||||||
{view === 'closet' && (
|
{(view === 'closet' || view === 'outfits') && (
|
||||||
<div className="flex items-center gap-3 overflow-x-auto pb-1 custom-scrollbar">
|
<div className="flex items-center gap-3 overflow-x-auto pb-1 custom-scrollbar">
|
||||||
<button
|
<button
|
||||||
onClick={() => setActiveSectionFilter('all')}
|
onClick={() => setActiveSectionFilter('all')}
|
||||||
@@ -967,8 +1027,22 @@ export default function App() {
|
|||||||
<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">
|
<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>
|
<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-3 mt-2">
|
||||||
<div className="w-4 h-4 rounded-full border border-black/10 shrink-0" style={getColorStyle(item.color)}></div>
|
<div className="flex items-center gap-1.5 border-r border-gray-200 dark:border-gray-700 pr-3">
|
||||||
<span className="text-[10px] font-black opacity-40 uppercase tracking-widest">{item.color}</span>
|
<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.emoji} {sec.name}
|
||||||
|
</span>
|
||||||
|
) : null;
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -991,14 +1065,16 @@ export default function App() {
|
|||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-8">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-8">
|
||||||
{laundryClothes.map(item => (
|
{laundryClothes.map(item => (
|
||||||
<Card key={item.id} className="p-6 flex items-center gap-6 border-blue-100" darkMode={darkMode}>
|
<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-20 h-20 rounded-2xl object-cover shadow-lg" alt="" />
|
<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">
|
<div className="flex-1 min-w-0 flex flex-col justify-center items-start">
|
||||||
<p className="font-black truncate">{item.name}</p>
|
<p className="font-black text-sm truncate w-full text-inherit">{item.name}</p>
|
||||||
<Badge variant="warning">{t('washing')}</Badge>
|
<div className="mt-1.5">
|
||||||
|
<Badge variant="warning">{t('washing')}</Badge>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button onClick={() => handleItemAction('clean', item)} className="p-4 bg-green-500 text-white rounded-2xl shadow-lg shadow-green-500/30 hover:scale-110 transition-all">
|
<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={24} />
|
<CheckCircle2 size={20} />
|
||||||
</button>
|
</button>
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
))}
|
||||||
@@ -1035,6 +1111,40 @@ export default function App() {
|
|||||||
{selectedForLook.length === 0 && <p className="text-xs text-gray-400 italic">{t('selectPieces')}</p>}
|
{selectedForLook.length === 0 && <p className="text-xs text-gray-400 italic">{t('selectPieces')}</p>}
|
||||||
</div>
|
</div>
|
||||||
</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} /> Criar Secção
|
||||||
|
</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">Ainda não tem secções criadas</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'}`}
|
||||||
|
>
|
||||||
|
<span>{sec.emoji}</span> {sec.name}
|
||||||
|
{lookSections.includes(sec.id) && <Check size={12} />}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<div className="flex gap-4">
|
<div className="flex gap-4">
|
||||||
{editingLook && (
|
{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 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>
|
||||||
@@ -1062,13 +1172,17 @@ export default function App() {
|
|||||||
|
|
||||||
<div className="lg:col-span-2 space-y-10">
|
<div className="lg:col-span-2 space-y-10">
|
||||||
{(() => {
|
{(() => {
|
||||||
const availableLooks = looks.filter(look =>
|
const filteredBySectionLooks = looks.filter(look =>
|
||||||
|
activeSectionFilter === 'all' || (look.sections && look.sections.includes(activeSectionFilter))
|
||||||
|
);
|
||||||
|
|
||||||
|
const availableLooks = filteredBySectionLooks.filter(look =>
|
||||||
look.items.every(id => {
|
look.items.every(id => {
|
||||||
const item = clothes.find(c => c.id === id);
|
const item = clothes.find(c => c.id === id);
|
||||||
return !item || item.status !== 'laundry';
|
return !item || item.status !== 'laundry';
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
const laundryLooks = looks.filter(look =>
|
const laundryLooks = filteredBySectionLooks.filter(look =>
|
||||||
look.items.some(id => {
|
look.items.some(id => {
|
||||||
const item = clothes.find(c => c.id === id);
|
const item = clothes.find(c => c.id === id);
|
||||||
return item && item.status === 'laundry';
|
return item && item.status === 'laundry';
|
||||||
@@ -1225,11 +1339,20 @@ export default function App() {
|
|||||||
<Input label={t('imageUrl')} name="imageUrl" defaultValue={editingItem?.imageUrl} onChange={(v) => setImageUrlDraft(v)} />
|
<Input label={t('imageUrl')} name="imageUrl" defaultValue={editingItem?.imageUrl} onChange={(v) => setImageUrlDraft(v)} />
|
||||||
|
|
||||||
{/* Campo de Secções */}
|
{/* Campo de Secções */}
|
||||||
{sections.length > 0 && (
|
<div className="space-y-3">
|
||||||
<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">
|
<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')}
|
<Tag size={12} /> {t('assignSections')}
|
||||||
</label>
|
</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} /> Criar Secção
|
||||||
|
</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">Ainda não tem secções criadas</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{sections.map(sec => (
|
{sections.map(sec => (
|
||||||
<button
|
<button
|
||||||
@@ -1248,8 +1371,8 @@ export default function App() {
|
|||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
)}
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-4 pt-6">
|
<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="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>
|
||||||
@@ -1468,6 +1591,60 @@ export default function App() {
|
|||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
{/* 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()}>
|
||||||
|
<div className="flex items-center justify-between mb-8">
|
||||||
|
<h3 className="text-2xl font-black text-inherit flex items-center gap-3"><Bell size={24} className="text-primary-600" /> Notificações</h3>
|
||||||
|
<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 className="flex-1 overflow-y-auto space-y-4 custom-scrollbar">
|
||||||
|
{notifications.length === 0 ? (
|
||||||
|
<div className="py-12 text-center opacity-30 font-black uppercase tracking-[0.3em] text-sm">Sem Notificações</div>
|
||||||
|
) : notifications.map(notif => (
|
||||||
|
<div key={notif.id} className={`p-4 rounded-2xl flex items-start gap-4 ${!notif.read ? 'bg-primary-50 dark:bg-primary-900/20' : 'bg-gray-50 dark:bg-gray-800'}`}>
|
||||||
|
<div className={`p-3 rounded-full ${!notif.read ? 'bg-primary-100 text-primary-600 dark:bg-primary-900/40 dark:text-primary-400' : 'bg-gray-200 text-gray-500 dark:bg-gray-700'}`}>
|
||||||
|
<Bell size={20} />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="font-bold text-sm text-inherit">
|
||||||
|
{notif.type === 'look_copied' && (
|
||||||
|
<>O utilizador <span className="text-primary-600">{notif.copiedByEmail}</span> guardou o seu look "{notif.lookName}" no armário dele!</>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
<p className="text-[10px] uppercase font-black tracking-widest opacity-40 mt-2">
|
||||||
|
{new Date(notif.createdAt).toLocaleDateString()} às {new Date(notif.createdAt).toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'})}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{!notif.read && (
|
||||||
|
<button
|
||||||
|
onClick={async () => {
|
||||||
|
const docRef = doc(db, 'artifacts', appId, 'users', user.uid, 'notifications', notif.id);
|
||||||
|
await updateDoc(docRef, { read: true });
|
||||||
|
}}
|
||||||
|
className="p-2 text-primary-600 hover:bg-primary-100 dark:hover:bg-primary-900/40 rounded-xl"
|
||||||
|
>
|
||||||
|
<Check size={16} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Modal de Gestão de Secções */}
|
{/* Modal de Gestão de Secções */}
|
||||||
{showSectionManager && (
|
{showSectionManager && (
|
||||||
<div className="fixed inset-0 z-[200] flex items-center justify-center bg-black/60 backdrop-blur-sm p-6" onClick={() => setShowSectionManager(false)}>
|
<div className="fixed inset-0 z-[200] flex items-center justify-center bg-black/60 backdrop-blur-sm p-6" onClick={() => setShowSectionManager(false)}>
|
||||||
@@ -1510,19 +1687,50 @@ export default function App() {
|
|||||||
<div className="py-12 text-center opacity-30 font-black uppercase tracking-[0.3em] text-sm">{t('noSections')}</div>
|
<div className="py-12 text-center opacity-30 font-black uppercase tracking-[0.3em] text-sm">{t('noSections')}</div>
|
||||||
) : sections.map(sec => (
|
) : 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'}`}>
|
<div key={sec.id} className={`flex items-center gap-4 p-4 rounded-2xl transition-all ${darkMode ? 'bg-gray-800' : 'bg-gray-50'}`}>
|
||||||
<span className="text-2xl w-10 text-center">{sec.emoji}</span>
|
{editingSectionId === sec.id ? (
|
||||||
<div className="flex-1">
|
<>
|
||||||
<p className="font-black text-sm">{sec.name}</p>
|
<input
|
||||||
<p className="text-[10px] opacity-40 font-bold uppercase tracking-widest">
|
value={editSectionEmoji}
|
||||||
{clothes.filter(c => c.sections && c.sections.includes(sec.id)).length} peça(s)
|
onChange={e => setEditSectionEmoji(e.target.value)}
|
||||||
</p>
|
maxLength={2}
|
||||||
</div>
|
className={`w-12 p-2 rounded-xl border-none outline-none font-bold text-center text-lg ${darkMode ? 'bg-gray-700 text-white' : 'bg-white'} shadow-sm`}
|
||||||
<button
|
/>
|
||||||
onClick={() => deleteSection(sec.id)}
|
<input
|
||||||
className="p-2 text-red-400 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-xl transition-all"
|
value={editSectionName}
|
||||||
>
|
onChange={e => setEditSectionName(e.target.value)}
|
||||||
<Trash size={16} />
|
onKeyDown={e => e.key === 'Enter' && updateSection()}
|
||||||
</button>
|
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>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<span className="text-2xl w-10 text-center">{sec.emoji}</span>
|
||||||
|
<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>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ export const Badge = ({ children, variant = "default" }) => {
|
|||||||
warning: "bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300"
|
warning: "bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300"
|
||||||
};
|
};
|
||||||
return (
|
return (
|
||||||
<span className={`px-3 py-1 rounded-full text-[10px] font-black uppercase tracking-widest ${styles[variant]}`}>
|
<span className={`px-3 py-1 rounded-full text-[10px] font-black uppercase tracking-widest whitespace-nowrap ${styles[variant]}`}>
|
||||||
{children}
|
{children}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
export const Card = ({ children, className = "", darkMode }) => (
|
export const Card = ({ children, className = "", darkMode, ...props }) => (
|
||||||
<div className={`rounded-[2rem] border transition-all duration-300 ${darkMode
|
<div
|
||||||
|
className={`rounded-[2rem] border transition-all duration-300 ${darkMode
|
||||||
? 'bg-gray-800/40 border-gray-700/50 backdrop-blur-md'
|
? 'bg-gray-800/40 border-gray-700/50 backdrop-blur-md'
|
||||||
: 'bg-white/80 border-gray-200/50 backdrop-blur-md shadow-sm'
|
: 'bg-white/80 border-gray-200/50 backdrop-blur-md shadow-sm'
|
||||||
} ${className}`}>
|
} ${className}`}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ export const translations = {
|
|||||||
makeDirty: "Sujar",
|
makeDirty: "Sujar",
|
||||||
moveToTrash: "Mover para Lixo",
|
moveToTrash: "Mover para Lixo",
|
||||||
laundryBasket: "Cesto da Roupa",
|
laundryBasket: "Cesto da Roupa",
|
||||||
|
lookSentToLaundry: "Peças enviadas para a lavandaria!",
|
||||||
laundryMsg: "Aqui encontras as peças que marcaste como sujas. Lava-as para que voltem ao armário principal.",
|
laundryMsg: "Aqui encontras as peças que marcaste como sujas. Lava-as para que voltem ao armário principal.",
|
||||||
washing: "A lavar",
|
washing: "A lavar",
|
||||||
emptyBasket: "Cesto Vazio",
|
emptyBasket: "Cesto Vazio",
|
||||||
@@ -171,6 +172,7 @@ export const translations = {
|
|||||||
makeDirty: "Make Dirty",
|
makeDirty: "Make Dirty",
|
||||||
moveToTrash: "Move to Trash",
|
moveToTrash: "Move to Trash",
|
||||||
laundryBasket: "Laundry Basket",
|
laundryBasket: "Laundry Basket",
|
||||||
|
lookSentToLaundry: "Pieces sent to the laundry!",
|
||||||
laundryMsg: "Here you find the pieces you marked as dirty. Wash them to return them to the main closet.",
|
laundryMsg: "Here you find the pieces you marked as dirty. Wash them to return them to the main closet.",
|
||||||
washing: "Washing",
|
washing: "Washing",
|
||||||
emptyBasket: "Empty Basket",
|
emptyBasket: "Empty Basket",
|
||||||
@@ -304,6 +306,7 @@ export const translations = {
|
|||||||
makeDirty: "Ensuciar",
|
makeDirty: "Ensuciar",
|
||||||
moveToTrash: "Mover a la Papelera",
|
moveToTrash: "Mover a la Papelera",
|
||||||
laundryBasket: "Cesto de Ropa",
|
laundryBasket: "Cesto de Ropa",
|
||||||
|
lookSentToLaundry: "¡Piezas enviadas a la lavandería!",
|
||||||
laundryMsg: "Aquí encuentras las piezas que marcaste como sucias. Lávalas para que vuelvan al armario principal.",
|
laundryMsg: "Aquí encuentras las piezas que marcaste como sucias. Lávalas para que vuelvan al armario principal.",
|
||||||
washing: "Lavando",
|
washing: "Lavando",
|
||||||
emptyBasket: "Cesto Vacío",
|
emptyBasket: "Cesto Vacío",
|
||||||
@@ -437,6 +440,7 @@ export const translations = {
|
|||||||
makeDirty: "Salir",
|
makeDirty: "Salir",
|
||||||
moveToTrash: "Mettre à la corbeille",
|
moveToTrash: "Mettre à la corbeille",
|
||||||
laundryBasket: "Panier à linge",
|
laundryBasket: "Panier à linge",
|
||||||
|
lookSentToLaundry: "Pièces envoyées à la blanchisserie !",
|
||||||
laundryMsg: "Ici vous trouvez les pièces que vous avez marquées comme sales. Lavez-les pour les remettre dans le placard principal.",
|
laundryMsg: "Ici vous trouvez les pièces que vous avez marquées comme sales. Lavez-les pour les remettre dans le placard principal.",
|
||||||
washing: "En lavage",
|
washing: "En lavage",
|
||||||
emptyBasket: "Panier Vide",
|
emptyBasket: "Panier Vide",
|
||||||
@@ -570,6 +574,7 @@ export const translations = {
|
|||||||
makeDirty: "Schmutzig machen",
|
makeDirty: "Schmutzig machen",
|
||||||
moveToTrash: "In den Papierkorb verschieben",
|
moveToTrash: "In den Papierkorb verschieben",
|
||||||
laundryBasket: "Wäschekorb",
|
laundryBasket: "Wäschekorb",
|
||||||
|
lookSentToLaundry: "Stücke in die Wäsche geschickt!",
|
||||||
laundryMsg: "Hier findest du die Stücke, die du als schmutzig markiert hast. Wasche sie, um sie in den Hauptschrank zurückzulegen.",
|
laundryMsg: "Hier findest du die Stücke, die du als schmutzig markiert hast. Wasche sie, um sie in den Hauptschrank zurückzulegen.",
|
||||||
washing: "Waschen",
|
washing: "Waschen",
|
||||||
emptyBasket: "Leerer Korb",
|
emptyBasket: "Leerer Korb",
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ export default defineConfig({
|
|||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
server: {
|
server: {
|
||||||
host: true, // Permite acesso externo
|
host: true, // Permite acesso externo
|
||||||
port: 5173, // Opcional, para garantir a porta
|
port: 5173, // Garante que apenas esta porta será usada
|
||||||
|
strictPort: true, // Se a porta estiver em uso, falha em vez de abrir outra
|
||||||
allowedHosts: ['mycloset.epvc.pt', 'localhost'],
|
allowedHosts: ['mycloset.epvc.pt', 'localhost'],
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user