firebase-os 1.1.4 → 1.1.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/FirebaseOS.d.ts +15 -0
- package/dist/firebase-os.cjs.js +5 -20
- package/dist/firebase-os.es.js +95 -90
- package/dist/index.d.ts +3 -0
- package/dist/lib/ConfigContext.d.ts +12 -0
- package/package.json +3 -2
- package/scripts/postinstall.js +86 -15
- package/src/App.css +184 -0
- package/src/App.tsx +214 -0
- package/src/FirebaseOS.tsx +81 -0
- package/src/assets/hero.png +0 -0
- package/src/assets/react.svg +1 -0
- package/src/assets/vite.svg +1 -0
- package/src/components/AdminNotifications.test.tsx +98 -0
- package/src/components/AdminNotifications.tsx +194 -0
- package/src/components/Button.test.tsx +22 -0
- package/src/components/Button.tsx +53 -0
- package/src/components/ConfirmModal.test.tsx +98 -0
- package/src/components/ConfirmModal.tsx +73 -0
- package/src/components/ContactPopup.test.tsx +98 -0
- package/src/components/ContactPopup.tsx +437 -0
- package/src/components/CustomSelect.test.tsx +47 -0
- package/src/components/CustomSelect.tsx +89 -0
- package/src/components/DashboardNav.test.tsx +98 -0
- package/src/components/DashboardNav.tsx +281 -0
- package/src/components/Input.test.tsx +33 -0
- package/src/components/Input.tsx +61 -0
- package/src/components/JsonEditor.tsx +579 -0
- package/src/components/Navbar.test.tsx +98 -0
- package/src/components/Navbar.tsx +563 -0
- package/src/configs/forms/contactForm.config.ts +15 -0
- package/src/configs/forms/index.ts +29 -0
- package/src/configs/forms/pubForm.config.ts +11 -0
- package/src/configs/forms/supportForm.config.ts +14 -0
- package/src/configs/forms/userForm.config.ts +11 -0
- package/src/configs/pages/admin.config.ts +29 -0
- package/src/configs/pages/contact.config.ts +6 -0
- package/src/configs/pages/home.config.ts +18 -0
- package/src/configs/pages/mem.config.ts +2 -0
- package/src/configs/pages/menuOrders.config.ts +11 -0
- package/src/configs/pages/pub.config.ts +11 -0
- package/src/configs/pages/shared.config.ts +29 -0
- package/src/configs/pages/support.config.ts +7 -0
- package/src/configs/pages/tabOrders.config.ts +33 -0
- package/src/configs/pages/user.config.ts +29 -0
- package/src/configs/theme.config.ts +93 -0
- package/src/index.css +403 -0
- package/src/index.ts +22 -0
- package/src/lib/AuthContext.test.tsx +88 -0
- package/src/lib/AuthContext.tsx +191 -0
- package/src/lib/ConfigContext.tsx +45 -0
- package/src/lib/ThemeContext.tsx +233 -0
- package/src/lib/firebase.ts +91 -0
- package/src/main.tsx +22 -0
- package/src/microcomponents/AdminExampleContent.tsx +44 -0
- package/src/microcomponents/PrivateExampleContent.tsx +39 -0
- package/src/microcomponents/Public.tsx +126 -0
- package/src/microcomponents/SharedExampleContent.tsx +53 -0
- package/src/pages/Dashboard.test.tsx +98 -0
- package/src/pages/Dashboard.tsx +60 -0
- package/src/pages/DynamicPage.tsx +237 -0
- package/src/pages/FormsAdmin.test.tsx +98 -0
- package/src/pages/FormsAdmin.tsx +459 -0
- package/src/pages/Home.test.tsx +98 -0
- package/src/pages/Home.tsx +144 -0
- package/src/pages/Login.test.tsx +98 -0
- package/src/pages/Login.tsx +108 -0
- package/src/pages/PagesAdmin.test.tsx +98 -0
- package/src/pages/PagesAdmin.tsx +1022 -0
- package/src/pages/Profile.test.tsx +98 -0
- package/src/pages/Profile.tsx +319 -0
- package/src/pages/Register.test.tsx +98 -0
- package/src/pages/Register.tsx +116 -0
- package/src/pages/Requests.test.tsx +95 -0
- package/src/pages/Requests.tsx +422 -0
- package/src/pages/ResetPassword.test.tsx +98 -0
- package/src/pages/ResetPassword.tsx +92 -0
- package/src/pages/Settings.test.tsx +98 -0
- package/src/pages/Settings.tsx +393 -0
- package/src/pages/Setup.tsx +407 -0
- package/src/pages/StorageAdmin.test.tsx +150 -0
- package/src/pages/StorageAdmin.tsx +769 -0
- package/src/pages/Submissions.test.tsx +95 -0
- package/src/pages/Submissions.tsx +378 -0
- package/src/pages/Templates.test.tsx +98 -0
- package/src/pages/Templates.tsx +103 -0
- package/src/pages/ThemeAdmin.test.tsx +144 -0
- package/src/pages/ThemeAdmin.tsx +1000 -0
- package/src/pages/Users.test.tsx +95 -0
- package/src/pages/Users.tsx +334 -0
- package/src/pages/Verify.test.tsx +98 -0
- package/src/pages/Verify.tsx +95 -0
- package/src/prompts/index.ts +13 -0
- package/src/prompts/pages/publicPage.ts +44 -0
- package/src/prompts/sharedConstants.ts +12 -0
- package/src/prompts/tabs/board/adminboard.ts +32 -0
- package/src/prompts/tabs/board/privateboard.ts +36 -0
- package/src/prompts/tabs/board/publicboard.ts +36 -0
- package/src/prompts/tabs/calendar/admincalendar.ts +32 -0
- package/src/prompts/tabs/calendar/privatecalendar.ts +36 -0
- package/src/prompts/tabs/calendar/publiccalendar.ts +36 -0
- package/src/prompts/tabs/crud/admin.ts +54 -0
- package/src/prompts/tabs/crud/private.ts +55 -0
- package/src/prompts/tabs/crud/shared.ts +53 -0
- package/src/prompts/tabs/table/admintable.ts +32 -0
- package/src/prompts/tabs/table/privatetable.ts +36 -0
- package/src/prompts/tabs/table/publictable.ts +36 -0
- package/src/setupTests.ts +1 -0
- package/src/templates/AdminPageTemplate.tsx +678 -0
- package/src/templates/PrivatePageTemplate.tsx +594 -0
- package/src/templates/PublicPageTemplate.tsx +92 -0
- package/src/templates/SharedPageTemplate.tsx +551 -0
- package/src/templates/TemplateBoard.test.tsx +106 -0
- package/src/templates/TemplateBoard.tsx +642 -0
- package/src/templates/TemplateCalendar.test.tsx +106 -0
- package/src/templates/TemplateCalendar.tsx +848 -0
- package/src/templates/TemplateConfirmation.test.tsx +106 -0
- package/src/templates/TemplateConfirmation.tsx +145 -0
- package/src/templates/TemplateInlineForm.test.tsx +106 -0
- package/src/templates/TemplateInlineForm.tsx +129 -0
- package/src/templates/TemplatePopupForm.test.tsx +106 -0
- package/src/templates/TemplatePopupForm.tsx +174 -0
- package/src/templates/TemplateTable.test.tsx +106 -0
- package/src/templates/TemplateTable.tsx +675 -0
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// @generated-test
|
|
2
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
3
|
+
import { render, screen, act } from '@testing-library/react';
|
|
4
|
+
import { BrowserRouter } from 'react-router-dom';
|
|
5
|
+
import { AuthProvider } from '../lib/AuthContext';
|
|
6
|
+
import { ThemeProvider } from '../lib/ThemeContext';
|
|
7
|
+
import { Requests } from './Requests';
|
|
8
|
+
|
|
9
|
+
// Global mocks
|
|
10
|
+
Object.defineProperty(window, 'matchMedia', {
|
|
11
|
+
writable: true,
|
|
12
|
+
value: vi.fn().mockImplementation(query => ({
|
|
13
|
+
matches: false,
|
|
14
|
+
media: query,
|
|
15
|
+
onchange: null,
|
|
16
|
+
addListener: vi.fn(),
|
|
17
|
+
removeListener: vi.fn(),
|
|
18
|
+
addEventListener: vi.fn(),
|
|
19
|
+
removeEventListener: vi.fn(),
|
|
20
|
+
dispatchEvent: vi.fn(),
|
|
21
|
+
})),
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
vi.mock('../lib/AuthContext', () => ({
|
|
25
|
+
AuthProvider: ({ children }: any) => <>{children}</>,
|
|
26
|
+
useAuth: () => ({
|
|
27
|
+
user: { uid: 'mock-user-123', email: 'test@example.com' },
|
|
28
|
+
userWorkspaces: [],
|
|
29
|
+
activeWorkspace: null,
|
|
30
|
+
activeOrg: null,
|
|
31
|
+
loading: false
|
|
32
|
+
})
|
|
33
|
+
}));
|
|
34
|
+
|
|
35
|
+
vi.mock('../lib/ThemeContext', () => ({
|
|
36
|
+
ThemeProvider: ({ children }: any) => <>{children}</>,
|
|
37
|
+
useTheme: () => ({ themeMode: 'light', setThemeMode: vi.fn(), activeConfig: {} })
|
|
38
|
+
}));
|
|
39
|
+
|
|
40
|
+
vi.mock('firebase/auth', () => ({
|
|
41
|
+
getAuth: vi.fn(() => ({})),
|
|
42
|
+
onAuthStateChanged: vi.fn((auth, cb) => { cb({ uid: 'mock-user-123', email: 'test@example.com', getIdToken: vi.fn(() => Promise.resolve('mock-token')) }); return () => {}; })
|
|
43
|
+
}));
|
|
44
|
+
vi.mock('firebase/firestore', () => ({
|
|
45
|
+
getFirestore: vi.fn(() => ({})),
|
|
46
|
+
collection: vi.fn(),
|
|
47
|
+
doc: vi.fn(),
|
|
48
|
+
setDoc: vi.fn(() => Promise.resolve()),
|
|
49
|
+
addDoc: vi.fn(() => Promise.resolve()),
|
|
50
|
+
updateDoc: vi.fn(() => Promise.resolve()),
|
|
51
|
+
deleteDoc: vi.fn(() => Promise.resolve()),
|
|
52
|
+
query: vi.fn(),
|
|
53
|
+
where: vi.fn(),
|
|
54
|
+
orderBy: vi.fn(),
|
|
55
|
+
limit: vi.fn(),
|
|
56
|
+
getDoc: vi.fn(() => Promise.resolve({ exists: () => true, data: () => ({ role: 'super_admin' }) })),
|
|
57
|
+
getDocs: vi.fn(() => Promise.resolve({ docs: [], forEach: vi.fn() })),
|
|
58
|
+
onSnapshot: vi.fn((...args: any[]) => {
|
|
59
|
+
let cb = args[1];
|
|
60
|
+
if (typeof cb === 'function') {
|
|
61
|
+
cb({docs: [], forEach: vi.fn(), data: () => ({}), exists: () => true});
|
|
62
|
+
}
|
|
63
|
+
return () => {};
|
|
64
|
+
})
|
|
65
|
+
}));
|
|
66
|
+
vi.mock('firebase/storage', () => ({
|
|
67
|
+
getStorage: vi.fn(() => ({})),
|
|
68
|
+
ref: vi.fn(),
|
|
69
|
+
listAll: vi.fn(() => Promise.resolve({ items: [], prefixes: [] })),
|
|
70
|
+
getDownloadURL: vi.fn(() => Promise.resolve('mock-url')),
|
|
71
|
+
getMetadata: vi.fn(() => Promise.resolve({ size: 1024, timeCreated: new Date().toISOString() }))
|
|
72
|
+
}));
|
|
73
|
+
|
|
74
|
+
describe('Requests Component', () => {
|
|
75
|
+
it('renders without crashing', async () => {
|
|
76
|
+
// Wrap in standard application providers inside act to process async side effects and prevent warnings
|
|
77
|
+
await act(async () => {
|
|
78
|
+
render(
|
|
79
|
+
<BrowserRouter>
|
|
80
|
+
<AuthProvider>
|
|
81
|
+
<ThemeProvider>
|
|
82
|
+
{/* @ts-ignore */}
|
|
83
|
+
<Requests />
|
|
84
|
+
</ThemeProvider>
|
|
85
|
+
</AuthProvider>
|
|
86
|
+
</BrowserRouter>
|
|
87
|
+
);
|
|
88
|
+
// Wait a tick to flush background state updates
|
|
89
|
+
await new Promise(resolve => setTimeout(resolve, 0));
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
// Check if the document has anything rendered without throwing
|
|
93
|
+
expect(document.body).toBeDefined();
|
|
94
|
+
});
|
|
95
|
+
});
|
|
@@ -0,0 +1,422 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react';
|
|
2
|
+
import { motion, AnimatePresence } from 'framer-motion';
|
|
3
|
+
import { db } from '../lib/firebase';
|
|
4
|
+
import { collection, query, orderBy, onSnapshot, doc, getDoc, updateDoc } from 'firebase/firestore';
|
|
5
|
+
import { DashboardNav } from '../components/DashboardNav';
|
|
6
|
+
import { HelpCircle, Calendar, Mail, AlertCircle, ChevronDown, ChevronUp, User, Archive, ArchiveRestore, Loader2, FolderArchive } from 'lucide-react';
|
|
7
|
+
import { Link } from 'react-router-dom';
|
|
8
|
+
|
|
9
|
+
interface UserRequest {
|
|
10
|
+
id: string;
|
|
11
|
+
name: string;
|
|
12
|
+
email?: string;
|
|
13
|
+
contactEmail?: string;
|
|
14
|
+
topic?: string;
|
|
15
|
+
description: string;
|
|
16
|
+
uid: string;
|
|
17
|
+
submittedAt: any;
|
|
18
|
+
avatarUrl?: string;
|
|
19
|
+
isArchived?: boolean;
|
|
20
|
+
[key: string]: any;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function Requests() {
|
|
24
|
+
const [requests, setRequests] = useState<UserRequest[]>([]);
|
|
25
|
+
const [loading, setLoading] = useState(true);
|
|
26
|
+
const [expandedId, setExpandedId] = useState<string | null>(null);
|
|
27
|
+
const [showArchived, setShowArchived] = useState(false);
|
|
28
|
+
const [isArchiving, setIsArchiving] = useState<string | null>(null);
|
|
29
|
+
|
|
30
|
+
useEffect(() => {
|
|
31
|
+
const q = query(
|
|
32
|
+
collection(db, 'user_requests'),
|
|
33
|
+
orderBy('submittedAt', 'desc')
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
const unsubscribe = onSnapshot(q, async (snapshot) => {
|
|
37
|
+
const data: UserRequest[] = [];
|
|
38
|
+
for (const docSnap of snapshot.docs) {
|
|
39
|
+
const reqData = docSnap.data() as UserRequest;
|
|
40
|
+
let avatarUrl = '';
|
|
41
|
+
|
|
42
|
+
// Attempt to fetch profile avatar if available
|
|
43
|
+
if (reqData.uid && reqData.uid !== 'guest') {
|
|
44
|
+
try {
|
|
45
|
+
const profileSnap = await getDoc(doc(db, 'user_profiles', reqData.uid));
|
|
46
|
+
if (profileSnap.exists()) {
|
|
47
|
+
const pData = profileSnap.data();
|
|
48
|
+
if (pData.avatar || pData.photoURL) avatarUrl = pData.avatar || pData.photoURL;
|
|
49
|
+
if (pData.displayName && !reqData.name) reqData.name = pData.displayName;
|
|
50
|
+
if (pData.email && !reqData.email) reqData.email = pData.email;
|
|
51
|
+
if (pData.email && !reqData.contactEmail) reqData.contactEmail = pData.email;
|
|
52
|
+
}
|
|
53
|
+
} catch (e) {
|
|
54
|
+
console.error('Failed to fetch avatar', e);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
data.push({ ...reqData, id: docSnap.id, avatarUrl });
|
|
59
|
+
}
|
|
60
|
+
setRequests(data);
|
|
61
|
+
setLoading(false);
|
|
62
|
+
}, (error) => {
|
|
63
|
+
console.error("Error fetching requests:", error);
|
|
64
|
+
setLoading(false);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
return () => unsubscribe();
|
|
68
|
+
}, []);
|
|
69
|
+
|
|
70
|
+
const formatDate = (timestamp: any) => {
|
|
71
|
+
if (!timestamp) return 'Just now';
|
|
72
|
+
const date = timestamp.toDate ? timestamp.toDate() : new Date(timestamp);
|
|
73
|
+
return new Intl.DateTimeFormat('en-US', {
|
|
74
|
+
month: 'short',
|
|
75
|
+
day: 'numeric',
|
|
76
|
+
hour: 'numeric',
|
|
77
|
+
minute: 'numeric',
|
|
78
|
+
}).format(date);
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
const toggleExpand = (id: string) => {
|
|
82
|
+
setExpandedId(prev => (prev === id ? null : id));
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const handleArchive = async (id: string, currentStatus: boolean = false) => {
|
|
86
|
+
setIsArchiving(id);
|
|
87
|
+
try {
|
|
88
|
+
await updateDoc(doc(db, 'user_requests', id), {
|
|
89
|
+
isArchived: !currentStatus
|
|
90
|
+
});
|
|
91
|
+
if (!currentStatus && expandedId === id) {
|
|
92
|
+
setExpandedId(null);
|
|
93
|
+
}
|
|
94
|
+
} catch (e) {
|
|
95
|
+
console.error("Failed to archive/unarchive", e);
|
|
96
|
+
}
|
|
97
|
+
setIsArchiving(null);
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
const activeRequests = requests.filter(r => !r.isArchived);
|
|
101
|
+
const archivedRequests = requests.filter(r => r.isArchived);
|
|
102
|
+
|
|
103
|
+
return (
|
|
104
|
+
<main className="flex-1 w-full max-w-7xl mx-auto p-4 md:p-8 z-10 flex flex-col">
|
|
105
|
+
<motion.div
|
|
106
|
+
initial={{ opacity: 0, y: 20 }}
|
|
107
|
+
animate={{ opacity: 1, y: 0 }}
|
|
108
|
+
className="mb-8"
|
|
109
|
+
>
|
|
110
|
+
<h1 className="w-fit inline-block text-3xl md:text-4xl lg:text-5xl font-extrabold tracking-tight mb-3 text-gradient mt-1">
|
|
111
|
+
User Requests
|
|
112
|
+
</h1>
|
|
113
|
+
<div className="flex items-center gap-2 text-foreground/40 font-bold uppercase tracking-[0.2em] text-[11px]">
|
|
114
|
+
<span className="w-8 h-[1px] bg-foreground/10" />
|
|
115
|
+
/requests
|
|
116
|
+
</div>
|
|
117
|
+
</motion.div>
|
|
118
|
+
|
|
119
|
+
<div className="flex flex-col gap-4 flex-1 min-h-[500px]">
|
|
120
|
+
<div className="flex justify-start">
|
|
121
|
+
<DashboardNav />
|
|
122
|
+
</div>
|
|
123
|
+
|
|
124
|
+
<div className="w-full flex-1 glass-panel rounded-3xl p-6 md:p-10 relative overflow-hidden border border-[var(--panel-border)] shadow-2xl bg-[var(--panel-bg)]">
|
|
125
|
+
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-64 h-64 bg-accent/5 blur-[100px] rounded-full pointer-events-none" />
|
|
126
|
+
|
|
127
|
+
<div className="relative z-10 h-full flex flex-col">
|
|
128
|
+
{loading ? (
|
|
129
|
+
<div className="flex-1 flex items-center justify-center min-h-[500px]">
|
|
130
|
+
<div className="w-8 h-8 md:w-10 md:h-10 border-4 border-accent/20 border-t-accent rounded-full animate-spin" />
|
|
131
|
+
</div>
|
|
132
|
+
) : requests.length === 0 ? (
|
|
133
|
+
<div className="flex-1 flex flex-col items-center justify-center text-center p-8">
|
|
134
|
+
<div className="w-16 h-16 rounded-3xl bg-accent/10 border border-accent/20 flex items-center justify-center text-accent mx-auto mb-6 shadow-inner">
|
|
135
|
+
<HelpCircle className="w-6 h-6 outline-none" />
|
|
136
|
+
</div>
|
|
137
|
+
<h2 className="text-2xl font-extrabold text-foreground mb-2">No User Requests</h2>
|
|
138
|
+
<p className="text-[14px] text-foreground/50 font-medium max-w-md">User support tickets will appear here once submitted.</p>
|
|
139
|
+
</div>
|
|
140
|
+
) : (
|
|
141
|
+
<div className="flex flex-col gap-3 pb-8">
|
|
142
|
+
{activeRequests.length === 0 && (
|
|
143
|
+
<div className="bg-foreground/[0.02] border border-dashed border-[var(--panel-border)] rounded-2xl flex flex-col items-center justify-center p-8 text-center mb-4">
|
|
144
|
+
<p className="text-[14px] font-bold text-foreground/50">All requests have been archived.</p>
|
|
145
|
+
</div>
|
|
146
|
+
)}
|
|
147
|
+
<AnimatePresence>
|
|
148
|
+
{activeRequests.map((request, index) => {
|
|
149
|
+
const isExpanded = expandedId === request.id;
|
|
150
|
+
|
|
151
|
+
return (
|
|
152
|
+
<motion.div
|
|
153
|
+
key={request.id}
|
|
154
|
+
initial={{ opacity: 0, y: 10 }}
|
|
155
|
+
animate={{ opacity: 1, y: 0 }}
|
|
156
|
+
transition={{ delay: index * 0.05 }}
|
|
157
|
+
className="glass-panel border border-[var(--panel-border)] rounded-2xl flex flex-col overflow-hidden hover:border-accent/40 transition-all duration-300"
|
|
158
|
+
>
|
|
159
|
+
<button
|
|
160
|
+
onClick={() => toggleExpand(request.id)}
|
|
161
|
+
className="w-full flex items-center justify-between p-4 md:px-6 hover:bg-foreground/[0.02] transition-colors text-left"
|
|
162
|
+
>
|
|
163
|
+
<div className="flex items-center gap-4 flex-1 min-w-0">
|
|
164
|
+
<div className="relative shrink-0">
|
|
165
|
+
{(request.avatarUrl || request.submitterAvatar) ? (
|
|
166
|
+
<img src={request.avatarUrl || request.submitterAvatar} alt={request.submitterName || request.name} className="w-10 h-10 rounded-full object-cover border border-[var(--panel-border)] shadow-sm" />
|
|
167
|
+
) : (
|
|
168
|
+
<div className="w-10 h-10 rounded-full bg-accent/10 text-accent flex items-center justify-center font-bold border border-accent/20 shadow-sm uppercase">
|
|
169
|
+
{(request.submitterName || request.name || request.contactEmail || request.email || 'U')[0]}
|
|
170
|
+
</div>
|
|
171
|
+
)}
|
|
172
|
+
</div>
|
|
173
|
+
|
|
174
|
+
<div className="flex flex-col min-w-0 flex-1">
|
|
175
|
+
{request.uid !== 'guest' ? (
|
|
176
|
+
<Link to={`/users/${request.uid}`} onClick={(e: any) => e.stopPropagation()} className="w-fit font-bold text-[14px] md:text-[15px] text-foreground truncate hover:text-accent flex items-center gap-2">
|
|
177
|
+
{request.submitterName || request.name || 'Anonymous User'}
|
|
178
|
+
{request.formId && (
|
|
179
|
+
<span className="text-[10px] font-bold uppercase tracking-widest text-accent bg-accent/10 px-2 py-0.5 rounded-md border border-accent/20 shrink-0">
|
|
180
|
+
{request.formId.replace(/_/g, ' ')}
|
|
181
|
+
</span>
|
|
182
|
+
)}
|
|
183
|
+
</Link>
|
|
184
|
+
) : (
|
|
185
|
+
<div className="flex items-center gap-2">
|
|
186
|
+
<span className="font-bold text-[14px] md:text-[15px] text-foreground truncate">{request.name || 'Anonymous User'}</span>
|
|
187
|
+
{request.formId && (
|
|
188
|
+
<span className="text-[10px] font-bold uppercase tracking-widest text-accent bg-accent/10 px-2 py-0.5 rounded-md border border-accent/20 shrink-0">
|
|
189
|
+
{request.formId.replace(/_/g, ' ')}
|
|
190
|
+
</span>
|
|
191
|
+
)}
|
|
192
|
+
</div>
|
|
193
|
+
)}
|
|
194
|
+
<span className="text-[13px] text-foreground/50 truncate flex items-center gap-1.5 font-medium mt-0.5">
|
|
195
|
+
<Mail className="w-3.5 h-3.5" /> {request.contactEmail || request.email || 'No email provided'}
|
|
196
|
+
</span>
|
|
197
|
+
</div>
|
|
198
|
+
</div>
|
|
199
|
+
|
|
200
|
+
<div className="hidden md:flex items-center gap-4 ml-6 shrink-0">
|
|
201
|
+
<div className="text-[12px] font-medium text-foreground/40">
|
|
202
|
+
{formatDate(request.submittedAt)}
|
|
203
|
+
</div>
|
|
204
|
+
<button
|
|
205
|
+
onClick={(e) => { e.stopPropagation(); handleArchive(request.id, false); }}
|
|
206
|
+
disabled={isArchiving === request.id}
|
|
207
|
+
className="p-1.5 rounded-lg text-foreground/30 hover:text-orange-500 hover:bg-orange-500/10 transition-colors"
|
|
208
|
+
title="Archive Request"
|
|
209
|
+
>
|
|
210
|
+
{isArchiving === request.id ? <Loader2 className="w-4 h-4 animate-spin" /> : <Archive className="w-4 h-4" />}
|
|
211
|
+
</button>
|
|
212
|
+
<div className={`p-1.5 rounded-xl glass-panel transition-transform ${isExpanded ? 'rotate-180 bg-foreground/5' : ''}`}>
|
|
213
|
+
<ChevronDown className="w-4 h-4 text-foreground/50" />
|
|
214
|
+
</div>
|
|
215
|
+
</div>
|
|
216
|
+
</button>
|
|
217
|
+
|
|
218
|
+
<AnimatePresence>
|
|
219
|
+
{isExpanded && (
|
|
220
|
+
<motion.div
|
|
221
|
+
initial={{ height: 0, opacity: 0 }}
|
|
222
|
+
animate={{ height: 'auto', opacity: 1 }}
|
|
223
|
+
exit={{ height: 0, opacity: 0 }}
|
|
224
|
+
className="overflow-hidden"
|
|
225
|
+
>
|
|
226
|
+
<div className="p-4 md:p-6 pt-0 mt-2 border-t border-[var(--panel-border)]/30">
|
|
227
|
+
<div className="md:hidden text-[12px] font-medium text-foreground/40 w-fit mb-4">
|
|
228
|
+
{formatDate(request.submittedAt)}
|
|
229
|
+
</div>
|
|
230
|
+
{request.topic && (
|
|
231
|
+
<div className="flex items-center gap-2 mb-4 text-[13px] text-foreground/70 bg-foreground/5 px-4 py-2.5 rounded-xl w-fit border border-[var(--panel-border)]">
|
|
232
|
+
<AlertCircle className="w-4 h-4 text-accent" />
|
|
233
|
+
<span className="font-medium text-accent">Topic:</span>
|
|
234
|
+
<span className="font-bold">{request.topic}</span>
|
|
235
|
+
</div>
|
|
236
|
+
)}
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
{Object.entries(request)
|
|
240
|
+
.filter(([key]) => !['id', 'uid', 'submittedAt', 'avatarUrl', 'name', 'full_name', 'email', 'contactEmail', 'topic'].includes(key) && !key.toLowerCase().startsWith('submitter') && key.toLowerCase() !== 'formid')
|
|
241
|
+
.map(([key, val]) => {
|
|
242
|
+
const formattedKey = key.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase());
|
|
243
|
+
return (
|
|
244
|
+
<div key={key} className="bg-foreground/[0.02] rounded-2xl p-5 border border-[var(--panel-border)] relative mb-3">
|
|
245
|
+
<div className="flex items-center gap-2 mb-3 text-foreground/60 w-full justify-between">
|
|
246
|
+
<div className="flex items-center gap-2 text-accent">
|
|
247
|
+
<span className="text-[12px] font-bold uppercase tracking-widest text-accent">{formattedKey}</span>
|
|
248
|
+
</div>
|
|
249
|
+
</div>
|
|
250
|
+
<p className="text-[14px] md:text-[15px] text-foreground/80 leading-relaxed whitespace-pre-wrap font-medium">
|
|
251
|
+
{typeof val === 'boolean' ? (val ? 'Yes' : 'No') : String(val || "No data provided")}
|
|
252
|
+
</p>
|
|
253
|
+
</div>
|
|
254
|
+
);
|
|
255
|
+
})}
|
|
256
|
+
|
|
257
|
+
</div>
|
|
258
|
+
</motion.div>
|
|
259
|
+
)}
|
|
260
|
+
</AnimatePresence>
|
|
261
|
+
</motion.div>
|
|
262
|
+
);
|
|
263
|
+
})}
|
|
264
|
+
</AnimatePresence>
|
|
265
|
+
|
|
266
|
+
{/* Archived Folders Toggle */}
|
|
267
|
+
{archivedRequests.length > 0 && (
|
|
268
|
+
<div className="mt-6 pt-4 border-t border-[var(--panel-border)]/30">
|
|
269
|
+
<div className="flex justify-center">
|
|
270
|
+
<button
|
|
271
|
+
onClick={() => setShowArchived(!showArchived)}
|
|
272
|
+
className="flex items-center gap-2 text-[11px] font-bold text-foreground/40 hover:text-foreground/70 transition-colors py-2 uppercase tracking-widest outline-none"
|
|
273
|
+
>
|
|
274
|
+
{showArchived ? 'Hide' : 'View'} {archivedRequests.length} Archived Request{archivedRequests.length !== 1 ? 's' : ''}
|
|
275
|
+
<ChevronDown className={`w-3.5 h-3.5 transition-transform duration-300 ${showArchived ? 'rotate-180' : ''}`} />
|
|
276
|
+
</button>
|
|
277
|
+
</div>
|
|
278
|
+
|
|
279
|
+
<AnimatePresence>
|
|
280
|
+
{showArchived && (
|
|
281
|
+
<motion.div
|
|
282
|
+
initial={{ height: 0, opacity: 0 }}
|
|
283
|
+
animate={{ height: 'auto', opacity: 1 }}
|
|
284
|
+
exit={{ height: 0, opacity: 0 }}
|
|
285
|
+
className="overflow-hidden mt-4"
|
|
286
|
+
>
|
|
287
|
+
<div className="flex flex-col gap-3">
|
|
288
|
+
{archivedRequests.map((request, index) => {
|
|
289
|
+
const isExpanded = expandedId === request.id;
|
|
290
|
+
return (
|
|
291
|
+
<motion.div
|
|
292
|
+
key={request.id}
|
|
293
|
+
initial={{ opacity: 0, y: 10 }}
|
|
294
|
+
animate={{ opacity: 1, y: 0 }}
|
|
295
|
+
transition={{ delay: index * 0.05 }}
|
|
296
|
+
className="glass-panel border border-[var(--panel-border)] rounded-2xl flex flex-col overflow-hidden opacity-50 grayscale hover:opacity-100 hover:grayscale-0 hover:border-accent/40 transition-all duration-300"
|
|
297
|
+
>
|
|
298
|
+
<button
|
|
299
|
+
onClick={() => toggleExpand(request.id)}
|
|
300
|
+
className="w-full flex items-center justify-between p-4 md:px-6 hover:bg-foreground/[0.02] transition-colors text-left"
|
|
301
|
+
>
|
|
302
|
+
<div className="flex items-center gap-4 flex-1 min-w-0">
|
|
303
|
+
<div className="relative shrink-0 grayscale">
|
|
304
|
+
{(request.avatarUrl || request.submitterAvatar) ? (
|
|
305
|
+
<img src={request.avatarUrl || request.submitterAvatar} alt={request.submitterName || request.name} className="w-10 h-10 rounded-full object-cover border border-[var(--panel-border)] shadow-sm" />
|
|
306
|
+
) : (
|
|
307
|
+
<div className="w-10 h-10 rounded-full bg-accent/10 text-accent flex items-center justify-center font-bold border border-accent/20 shadow-sm uppercase">
|
|
308
|
+
{(request.submitterName || request.name || request.contactEmail || request.email || 'U')[0]}
|
|
309
|
+
</div>
|
|
310
|
+
)}
|
|
311
|
+
</div>
|
|
312
|
+
|
|
313
|
+
<div className="flex flex-col min-w-0 flex-1">
|
|
314
|
+
{request.uid !== 'guest' ? (
|
|
315
|
+
<div className="w-fit font-bold text-[14px] md:text-[15px] text-foreground truncate flex items-center gap-2">
|
|
316
|
+
{request.submitterName || request.name || 'Anonymous User'}
|
|
317
|
+
{request.formId && (
|
|
318
|
+
<span className="text-[10px] font-bold uppercase tracking-widest text-accent bg-accent/10 px-2 py-0.5 rounded-md border border-accent/20 shrink-0">
|
|
319
|
+
{request.formId.replace(/_/g, ' ')}
|
|
320
|
+
</span>
|
|
321
|
+
)}
|
|
322
|
+
</div>
|
|
323
|
+
) : (
|
|
324
|
+
<div className="flex items-center gap-2">
|
|
325
|
+
<span className="font-bold text-[14px] md:text-[15px] text-foreground truncate">{request.name || 'Anonymous User'}</span>
|
|
326
|
+
{request.formId && (
|
|
327
|
+
<span className="text-[10px] font-bold uppercase tracking-widest text-accent bg-accent/10 px-2 py-0.5 rounded-md border border-accent/20 shrink-0">
|
|
328
|
+
{request.formId.replace(/_/g, ' ')}
|
|
329
|
+
</span>
|
|
330
|
+
)}
|
|
331
|
+
</div>
|
|
332
|
+
)}
|
|
333
|
+
<span className="text-[13px] text-foreground/50 truncate flex items-center gap-1.5 font-medium mt-0.5">
|
|
334
|
+
<Mail className="w-3.5 h-3.5" /> {request.contactEmail || request.email || 'No email provided'}
|
|
335
|
+
</span>
|
|
336
|
+
</div>
|
|
337
|
+
</div>
|
|
338
|
+
|
|
339
|
+
<div className="hidden md:flex items-center gap-6 ml-6 shrink-0">
|
|
340
|
+
<div className="text-[12px] font-medium text-foreground/40">
|
|
341
|
+
{formatDate(request.submittedAt)}
|
|
342
|
+
</div>
|
|
343
|
+
<div className={`p-2 rounded-xl glass-panel transition-transform ${isExpanded ? 'rotate-180 bg-foreground/5' : ''}`}>
|
|
344
|
+
<ChevronDown className="w-4 h-4 text-foreground/50" />
|
|
345
|
+
</div>
|
|
346
|
+
</div>
|
|
347
|
+
</button>
|
|
348
|
+
|
|
349
|
+
<AnimatePresence>
|
|
350
|
+
{isExpanded && (
|
|
351
|
+
<motion.div
|
|
352
|
+
initial={{ height: 0, opacity: 0 }}
|
|
353
|
+
animate={{ height: 'auto', opacity: 1 }}
|
|
354
|
+
exit={{ height: 0, opacity: 0 }}
|
|
355
|
+
className="overflow-hidden"
|
|
356
|
+
>
|
|
357
|
+
<div className="p-4 md:p-6 pt-0 mt-2 border-t border-[var(--panel-border)]/30">
|
|
358
|
+
<div className="md:hidden text-[12px] font-medium text-foreground/40 w-fit mb-4">
|
|
359
|
+
{formatDate(request.submittedAt)}
|
|
360
|
+
</div>
|
|
361
|
+
{request.topic && (
|
|
362
|
+
<div className="flex items-center gap-2 mb-4 text-[13px] text-foreground/70 bg-foreground/5 px-4 py-2.5 rounded-xl w-fit border border-[var(--panel-border)]">
|
|
363
|
+
<AlertCircle className="w-4 h-4 text-accent" />
|
|
364
|
+
<span className="font-medium text-accent">Topic:</span>
|
|
365
|
+
<span className="font-bold">{request.topic}</span>
|
|
366
|
+
</div>
|
|
367
|
+
)}
|
|
368
|
+
|
|
369
|
+
{Object.entries(request)
|
|
370
|
+
.filter(([key]) => !['id', 'uid', 'submittedAt', 'avatarUrl', 'name', 'full_name', 'email', 'contactEmail', 'topic', 'isArchived'].includes(key) && !key.toLowerCase().startsWith('submitter') && key.toLowerCase() !== 'formid')
|
|
371
|
+
.map(([key, val]) => {
|
|
372
|
+
const formattedKey = key.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase());
|
|
373
|
+
return (
|
|
374
|
+
<div key={key} className="bg-foreground/[0.02] rounded-2xl p-5 border border-[var(--panel-border)] relative mb-3">
|
|
375
|
+
<div className="flex items-center gap-2 mb-3 text-foreground/60 w-full justify-between">
|
|
376
|
+
<div className="flex items-center gap-2 text-accent">
|
|
377
|
+
<span className="text-[12px] font-bold uppercase tracking-widest text-accent">{formattedKey}</span>
|
|
378
|
+
</div>
|
|
379
|
+
</div>
|
|
380
|
+
<p className="text-[14px] md:text-[15px] text-foreground/80 leading-relaxed whitespace-pre-wrap font-medium">
|
|
381
|
+
{typeof val === 'boolean' ? (val ? 'Yes' : 'No') : String(val || "No data provided")}
|
|
382
|
+
</p>
|
|
383
|
+
</div>
|
|
384
|
+
);
|
|
385
|
+
})}
|
|
386
|
+
|
|
387
|
+
<div className="mt-6 flex justify-end">
|
|
388
|
+
<button
|
|
389
|
+
onClick={() => handleArchive(request.id, request.isArchived)}
|
|
390
|
+
disabled={isArchiving === request.id}
|
|
391
|
+
className="flex items-center gap-2 px-4 py-2.5 rounded-xl text-[13px] font-bold text-foreground/70 hover:text-emerald-500 bg-foreground/5 hover:bg-emerald-500/10 transition-colors border border-transparent shadow-sm"
|
|
392
|
+
>
|
|
393
|
+
{isArchiving === request.id ? (
|
|
394
|
+
<Loader2 className="w-4 h-4 animate-spin" />
|
|
395
|
+
) : (
|
|
396
|
+
<ArchiveRestore className="w-4 h-4" />
|
|
397
|
+
)}
|
|
398
|
+
Unarchive Request
|
|
399
|
+
</button>
|
|
400
|
+
</div>
|
|
401
|
+
</div>
|
|
402
|
+
</motion.div>
|
|
403
|
+
)}
|
|
404
|
+
</AnimatePresence>
|
|
405
|
+
</motion.div>
|
|
406
|
+
);
|
|
407
|
+
})}
|
|
408
|
+
</div>
|
|
409
|
+
</motion.div>
|
|
410
|
+
)}
|
|
411
|
+
</AnimatePresence>
|
|
412
|
+
</div>
|
|
413
|
+
)}
|
|
414
|
+
|
|
415
|
+
</div>
|
|
416
|
+
)}
|
|
417
|
+
</div>
|
|
418
|
+
</div>
|
|
419
|
+
</div>
|
|
420
|
+
</main>
|
|
421
|
+
);
|
|
422
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// @generated-test
|
|
2
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
3
|
+
import { render, screen, act } from '@testing-library/react';
|
|
4
|
+
import { BrowserRouter } from 'react-router-dom';
|
|
5
|
+
import { AuthProvider } from '../lib/AuthContext';
|
|
6
|
+
import { ThemeProvider } from '../lib/ThemeContext';
|
|
7
|
+
import { ResetPassword } from './ResetPassword';
|
|
8
|
+
|
|
9
|
+
// Global mocks
|
|
10
|
+
Object.defineProperty(window, 'matchMedia', {
|
|
11
|
+
writable: true,
|
|
12
|
+
value: vi.fn().mockImplementation(query => ({
|
|
13
|
+
matches: false,
|
|
14
|
+
media: query,
|
|
15
|
+
onchange: null,
|
|
16
|
+
addListener: vi.fn(),
|
|
17
|
+
removeListener: vi.fn(),
|
|
18
|
+
addEventListener: vi.fn(),
|
|
19
|
+
removeEventListener: vi.fn(),
|
|
20
|
+
dispatchEvent: vi.fn(),
|
|
21
|
+
})),
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
vi.mock('../lib/AuthContext', () => ({
|
|
25
|
+
AuthProvider: ({ children }: any) => <>{children}</>,
|
|
26
|
+
useAuth: () => ({
|
|
27
|
+
user: { uid: 'mock-user-123', email: 'test@example.com' },
|
|
28
|
+
userWorkspaces: [],
|
|
29
|
+
activeWorkspace: null,
|
|
30
|
+
activeOrg: null,
|
|
31
|
+
loading: false
|
|
32
|
+
})
|
|
33
|
+
}));
|
|
34
|
+
|
|
35
|
+
vi.mock('../lib/ThemeContext', () => ({
|
|
36
|
+
ThemeProvider: ({ children }: any) => <>{children}</>,
|
|
37
|
+
useTheme: () => ({ themeMode: 'light', setThemeMode: vi.fn(), activeConfig: {} })
|
|
38
|
+
}));
|
|
39
|
+
|
|
40
|
+
vi.mock('firebase/auth', () => ({
|
|
41
|
+
getAuth: vi.fn(() => ({})),
|
|
42
|
+
onAuthStateChanged: vi.fn((auth, cb) => { cb({ uid: 'mock-user-123', email: 'test@example.com', getIdToken: vi.fn(() => Promise.resolve('mock-token')) }); return () => {}; })
|
|
43
|
+
}));
|
|
44
|
+
vi.mock('firebase/firestore', () => ({
|
|
45
|
+
getFirestore: vi.fn(() => ({})),
|
|
46
|
+
collection: vi.fn(),
|
|
47
|
+
doc: vi.fn(),
|
|
48
|
+
setDoc: vi.fn(() => Promise.resolve()),
|
|
49
|
+
addDoc: vi.fn(() => Promise.resolve()),
|
|
50
|
+
updateDoc: vi.fn(() => Promise.resolve()),
|
|
51
|
+
deleteDoc: vi.fn(() => Promise.resolve()),
|
|
52
|
+
query: vi.fn(),
|
|
53
|
+
where: vi.fn(),
|
|
54
|
+
orderBy: vi.fn(),
|
|
55
|
+
limit: vi.fn(),
|
|
56
|
+
getDoc: vi.fn(() => Promise.resolve({ exists: () => true, data: () => ({ role: 'super_admin' }) })),
|
|
57
|
+
getDocs: vi.fn(() => Promise.resolve({ docs: [], forEach: vi.fn() })),
|
|
58
|
+
onSnapshot: vi.fn((...args: any[]) => {
|
|
59
|
+
let cb = args[1];
|
|
60
|
+
if (typeof args[2] === 'function') {
|
|
61
|
+
cb = args[2];
|
|
62
|
+
}
|
|
63
|
+
if (typeof cb === 'function') {
|
|
64
|
+
cb({docs: [], forEach: vi.fn(), data: () => ({}), exists: () => true});
|
|
65
|
+
}
|
|
66
|
+
return () => {};
|
|
67
|
+
})
|
|
68
|
+
}));
|
|
69
|
+
vi.mock('firebase/storage', () => ({
|
|
70
|
+
getStorage: vi.fn(() => ({})),
|
|
71
|
+
ref: vi.fn(),
|
|
72
|
+
listAll: vi.fn(() => Promise.resolve({ items: [], prefixes: [] })),
|
|
73
|
+
getDownloadURL: vi.fn(() => Promise.resolve('mock-url')),
|
|
74
|
+
getMetadata: vi.fn(() => Promise.resolve({ size: 1024, timeCreated: new Date().toISOString() }))
|
|
75
|
+
}));
|
|
76
|
+
|
|
77
|
+
describe('ResetPassword Component', () => {
|
|
78
|
+
it('renders without crashing', async () => {
|
|
79
|
+
// Wrap in standard application providers inside act to process async side effects and prevent warnings
|
|
80
|
+
await act(async () => {
|
|
81
|
+
render(
|
|
82
|
+
<BrowserRouter>
|
|
83
|
+
<AuthProvider>
|
|
84
|
+
<ThemeProvider>
|
|
85
|
+
{/* @ts-ignore */}
|
|
86
|
+
<ResetPassword />
|
|
87
|
+
</ThemeProvider>
|
|
88
|
+
</AuthProvider>
|
|
89
|
+
</BrowserRouter>
|
|
90
|
+
);
|
|
91
|
+
// Wait a tick to flush background state updates
|
|
92
|
+
await new Promise(resolve => setTimeout(resolve, 0));
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
// Check if the document has anything rendered without throwing
|
|
96
|
+
expect(document.body).toBeDefined();
|
|
97
|
+
});
|
|
98
|
+
});
|