firebase-os 1.1.4 → 1.1.5
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 +2 -17
- package/dist/firebase-os.es.js +63 -74
- 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 +80 -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 +227 -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 +401 -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 +372 -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 { Submissions } from './Submissions';
|
|
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('Submissions 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
|
+
<Submissions />
|
|
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,372 @@
|
|
|
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 { MessageSquare, Calendar, Mail, Phone, ChevronDown, HelpCircle, User, Archive, ArchiveRestore, Loader2, FolderArchive } from 'lucide-react';
|
|
7
|
+
import { Link } from 'react-router-dom';
|
|
8
|
+
|
|
9
|
+
interface Submission {
|
|
10
|
+
id: string;
|
|
11
|
+
name: string;
|
|
12
|
+
email: string;
|
|
13
|
+
phone: string;
|
|
14
|
+
questions: string;
|
|
15
|
+
uid: string;
|
|
16
|
+
submittedAt: any;
|
|
17
|
+
avatarUrl?: string;
|
|
18
|
+
isArchived?: boolean;
|
|
19
|
+
[key: string]: any;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function Submissions() {
|
|
23
|
+
const [submissions, setSubmissions] = useState<Submission[]>([]);
|
|
24
|
+
const [loading, setLoading] = useState(true);
|
|
25
|
+
const [expandedId, setExpandedId] = useState<string | null>(null);
|
|
26
|
+
const [showArchived, setShowArchived] = useState(false);
|
|
27
|
+
const [isArchiving, setIsArchiving] = useState<string | null>(null);
|
|
28
|
+
|
|
29
|
+
useEffect(() => {
|
|
30
|
+
const q = query(
|
|
31
|
+
collection(db, 'user_submissions'),
|
|
32
|
+
orderBy('submittedAt', 'desc')
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
const unsubscribe = onSnapshot(q, async (snapshot) => {
|
|
36
|
+
const data: Submission[] = [];
|
|
37
|
+
for (const docSnap of snapshot.docs) {
|
|
38
|
+
const subData = docSnap.data() as Submission;
|
|
39
|
+
let avatarUrl = '';
|
|
40
|
+
|
|
41
|
+
// Attempt to fetch profile avatar if available
|
|
42
|
+
if (subData.uid && subData.uid !== 'guest') {
|
|
43
|
+
try {
|
|
44
|
+
const profileSnap = await getDoc(doc(db, 'user_profiles', subData.uid));
|
|
45
|
+
if (profileSnap.exists()) {
|
|
46
|
+
const pData = profileSnap.data();
|
|
47
|
+
if (pData.avatar) avatarUrl = pData.avatar;
|
|
48
|
+
if (pData.name && !subData.name) subData.name = pData.name;
|
|
49
|
+
if (pData.email && !subData.email) subData.email = pData.email;
|
|
50
|
+
}
|
|
51
|
+
} catch (e) {
|
|
52
|
+
console.error('Failed to fetch avatar', e);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
data.push({ ...subData, id: docSnap.id, avatarUrl });
|
|
57
|
+
}
|
|
58
|
+
setSubmissions(data);
|
|
59
|
+
setLoading(false);
|
|
60
|
+
}, (error) => {
|
|
61
|
+
console.error("Error fetching submissions:", error);
|
|
62
|
+
setLoading(false);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
return () => unsubscribe();
|
|
66
|
+
}, []);
|
|
67
|
+
|
|
68
|
+
const formatDate = (timestamp: any) => {
|
|
69
|
+
if (!timestamp) return 'Just now';
|
|
70
|
+
const date = timestamp.toDate ? timestamp.toDate() : new Date(timestamp);
|
|
71
|
+
return new Intl.DateTimeFormat('en-US', {
|
|
72
|
+
month: 'short',
|
|
73
|
+
day: 'numeric',
|
|
74
|
+
hour: 'numeric',
|
|
75
|
+
minute: 'numeric',
|
|
76
|
+
}).format(date);
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
const toggleExpand = (id: string) => {
|
|
80
|
+
setExpandedId(prev => (prev === id ? null : id));
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const handleArchive = async (id: string, currentStatus: boolean = false) => {
|
|
84
|
+
setIsArchiving(id);
|
|
85
|
+
try {
|
|
86
|
+
await updateDoc(doc(db, 'user_submissions', id), {
|
|
87
|
+
isArchived: !currentStatus
|
|
88
|
+
});
|
|
89
|
+
if (!currentStatus && expandedId === id) {
|
|
90
|
+
setExpandedId(null);
|
|
91
|
+
}
|
|
92
|
+
} catch (e) {
|
|
93
|
+
console.error("Failed to archive/unarchive", e);
|
|
94
|
+
}
|
|
95
|
+
setIsArchiving(null);
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
const activeSubmissions = submissions.filter(s => !s.isArchived);
|
|
99
|
+
const archivedSubmissions = submissions.filter(s => s.isArchived);
|
|
100
|
+
|
|
101
|
+
return (
|
|
102
|
+
<main className="flex-1 w-full max-w-7xl mx-auto p-4 md:p-8 z-10 flex flex-col">
|
|
103
|
+
<motion.div
|
|
104
|
+
initial={{ opacity: 0, y: 20 }}
|
|
105
|
+
animate={{ opacity: 1, y: 0 }}
|
|
106
|
+
className="mb-8"
|
|
107
|
+
>
|
|
108
|
+
<h1 className="w-fit inline-block text-3xl md:text-4xl lg:text-5xl font-extrabold tracking-tight mb-3 text-gradient mt-1">
|
|
109
|
+
Form Submissions
|
|
110
|
+
</h1>
|
|
111
|
+
<div className="flex items-center gap-2 text-foreground/40 font-bold uppercase tracking-[0.2em] text-[11px]">
|
|
112
|
+
<span className="w-8 h-[1px] bg-foreground/10" />
|
|
113
|
+
/submissions
|
|
114
|
+
</div>
|
|
115
|
+
</motion.div>
|
|
116
|
+
|
|
117
|
+
<div className="flex flex-col gap-4 flex-1 min-h-[500px]">
|
|
118
|
+
<div className="flex justify-start">
|
|
119
|
+
<DashboardNav />
|
|
120
|
+
</div>
|
|
121
|
+
|
|
122
|
+
<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)]">
|
|
123
|
+
<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" />
|
|
124
|
+
|
|
125
|
+
<div className="relative z-10 h-full flex flex-col">
|
|
126
|
+
{loading ? (
|
|
127
|
+
<div className="flex-1 flex items-center justify-center min-h-[500px]">
|
|
128
|
+
<div className="w-8 h-8 md:w-10 md:h-10 border-4 border-accent/20 border-t-accent rounded-full animate-spin" />
|
|
129
|
+
</div>
|
|
130
|
+
) : submissions.length === 0 ? (
|
|
131
|
+
<div className="flex-1 flex flex-col items-center justify-center text-center p-8">
|
|
132
|
+
<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">
|
|
133
|
+
<MessageSquare className="w-6 h-6 outline-none" />
|
|
134
|
+
</div>
|
|
135
|
+
<h2 className="text-2xl font-extrabold text-foreground mb-2">No Submissions</h2>
|
|
136
|
+
<p className="text-[14px] text-foreground/50 font-medium max-w-md">Try submitting a form on the public layout to see it appear here.</p>
|
|
137
|
+
</div>
|
|
138
|
+
) : (
|
|
139
|
+
<div className="flex flex-col gap-3 pb-8">
|
|
140
|
+
{activeSubmissions.length === 0 && (
|
|
141
|
+
<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">
|
|
142
|
+
<p className="text-[14px] font-bold text-foreground/50">All submissions have been archived.</p>
|
|
143
|
+
</div>
|
|
144
|
+
)}
|
|
145
|
+
<AnimatePresence>
|
|
146
|
+
{activeSubmissions.map((submission, index) => {
|
|
147
|
+
const isExpanded = expandedId === submission.id;
|
|
148
|
+
|
|
149
|
+
return (
|
|
150
|
+
<motion.div
|
|
151
|
+
key={submission.id}
|
|
152
|
+
initial={{ opacity: 0, y: 10 }}
|
|
153
|
+
animate={{ opacity: 1, y: 0 }}
|
|
154
|
+
transition={{ delay: index * 0.05 }}
|
|
155
|
+
className="glass-panel border border-[var(--panel-border)] rounded-2xl flex flex-col overflow-hidden hover:border-accent/40 transition-all duration-300"
|
|
156
|
+
>
|
|
157
|
+
<button
|
|
158
|
+
onClick={() => toggleExpand(submission.id)}
|
|
159
|
+
className="w-full flex items-center justify-between p-4 md:px-6 hover:bg-foreground/[0.02] transition-colors text-left"
|
|
160
|
+
>
|
|
161
|
+
<div className="flex items-center gap-4 flex-1 min-w-0">
|
|
162
|
+
<div className="relative shrink-0">
|
|
163
|
+
<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">
|
|
164
|
+
<Mail className="w-4 h-4" />
|
|
165
|
+
</div>
|
|
166
|
+
</div>
|
|
167
|
+
|
|
168
|
+
<div className="flex flex-col min-w-0 flex-1 justify-center">
|
|
169
|
+
<div className="flex items-center gap-2">
|
|
170
|
+
<span className="font-bold text-[14px] md:text-[15px] text-foreground truncate">{submission.name || submission.full_name || 'Anonymous Submission'}</span>
|
|
171
|
+
{submission.formId && (
|
|
172
|
+
<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">
|
|
173
|
+
{submission.formId.replace(/_/g, ' ')}
|
|
174
|
+
</span>
|
|
175
|
+
)}
|
|
176
|
+
</div>
|
|
177
|
+
<span className="text-[13px] text-foreground/50 truncate flex items-center gap-1.5 font-medium mt-0.5">
|
|
178
|
+
{submission.email || submission.contactEmail || 'No email provided'}
|
|
179
|
+
</span>
|
|
180
|
+
</div>
|
|
181
|
+
</div>
|
|
182
|
+
|
|
183
|
+
<div className="hidden md:flex items-center gap-4 ml-6 shrink-0">
|
|
184
|
+
<div className="text-[12px] font-medium text-foreground/40">
|
|
185
|
+
{formatDate(submission.submittedAt)}
|
|
186
|
+
</div>
|
|
187
|
+
<button
|
|
188
|
+
onClick={(e) => { e.stopPropagation(); handleArchive(submission.id, false); }}
|
|
189
|
+
disabled={isArchiving === submission.id}
|
|
190
|
+
className="p-1.5 rounded-lg text-foreground/30 hover:text-orange-500 hover:bg-orange-500/10 transition-colors"
|
|
191
|
+
title="Archive Submission"
|
|
192
|
+
>
|
|
193
|
+
{isArchiving === submission.id ? <Loader2 className="w-4 h-4 animate-spin" /> : <Archive className="w-4 h-4" />}
|
|
194
|
+
</button>
|
|
195
|
+
<div className={`p-1.5 rounded-xl glass-panel transition-transform ${isExpanded ? 'rotate-180 bg-foreground/5' : ''}`}>
|
|
196
|
+
<ChevronDown className="w-4 h-4 text-foreground/50" />
|
|
197
|
+
</div>
|
|
198
|
+
</div>
|
|
199
|
+
</button>
|
|
200
|
+
|
|
201
|
+
<AnimatePresence>
|
|
202
|
+
{isExpanded && (
|
|
203
|
+
<motion.div
|
|
204
|
+
initial={{ height: 0, opacity: 0 }}
|
|
205
|
+
animate={{ height: 'auto', opacity: 1 }}
|
|
206
|
+
exit={{ height: 0, opacity: 0 }}
|
|
207
|
+
className="overflow-hidden"
|
|
208
|
+
>
|
|
209
|
+
<div className="p-4 md:p-6 pt-0 mt-2 border-t border-[var(--panel-border)]/30">
|
|
210
|
+
<div className="md:hidden text-[12px] font-medium text-foreground/40 w-fit mb-4">
|
|
211
|
+
{formatDate(submission.submittedAt)}
|
|
212
|
+
</div>
|
|
213
|
+
{Object.entries(submission)
|
|
214
|
+
.filter(([key]) => !['id', 'uid', 'submittedAt', 'avatarUrl', 'name', 'full_name', 'email', 'contactEmail'].includes(key) && !key.toLowerCase().startsWith('submitter') && key.toLowerCase() !== 'formid')
|
|
215
|
+
.map(([key, val]) => {
|
|
216
|
+
const formattedKey = key.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase());
|
|
217
|
+
return (
|
|
218
|
+
<div key={key} className="bg-foreground/[0.02] rounded-2xl p-5 border border-[var(--panel-border)] relative mb-3">
|
|
219
|
+
<div className="flex items-center gap-2 mb-3 text-foreground/60 w-full justify-between">
|
|
220
|
+
<div className="flex items-center gap-2 text-accent">
|
|
221
|
+
<span className="text-[12px] font-bold uppercase tracking-widest text-accent">{formattedKey}</span>
|
|
222
|
+
</div>
|
|
223
|
+
</div>
|
|
224
|
+
<p className="text-[14px] md:text-[15px] text-foreground/80 leading-relaxed whitespace-pre-wrap font-medium">
|
|
225
|
+
{typeof val === 'boolean' ? (val ? 'Yes' : 'No') : String(val || "No data provided")}
|
|
226
|
+
</p>
|
|
227
|
+
</div>
|
|
228
|
+
);
|
|
229
|
+
})}
|
|
230
|
+
|
|
231
|
+
</div>
|
|
232
|
+
</motion.div>
|
|
233
|
+
)}
|
|
234
|
+
</AnimatePresence>
|
|
235
|
+
</motion.div>
|
|
236
|
+
);
|
|
237
|
+
})}
|
|
238
|
+
</AnimatePresence>
|
|
239
|
+
|
|
240
|
+
{/* Archived Folders Toggle */}
|
|
241
|
+
{archivedSubmissions.length > 0 && (
|
|
242
|
+
<div className="mt-6 pt-4 border-t border-[var(--panel-border)]/30">
|
|
243
|
+
<div className="flex justify-center">
|
|
244
|
+
<button
|
|
245
|
+
onClick={() => setShowArchived(!showArchived)}
|
|
246
|
+
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"
|
|
247
|
+
>
|
|
248
|
+
{showArchived ? 'Hide' : 'View'} {archivedSubmissions.length} Archived Submission{archivedSubmissions.length !== 1 ? 's' : ''}
|
|
249
|
+
<ChevronDown className={`w-3.5 h-3.5 transition-transform duration-300 ${showArchived ? 'rotate-180' : ''}`} />
|
|
250
|
+
</button>
|
|
251
|
+
</div>
|
|
252
|
+
|
|
253
|
+
<AnimatePresence>
|
|
254
|
+
{showArchived && (
|
|
255
|
+
<motion.div
|
|
256
|
+
initial={{ height: 0, opacity: 0 }}
|
|
257
|
+
animate={{ height: 'auto', opacity: 1 }}
|
|
258
|
+
exit={{ height: 0, opacity: 0 }}
|
|
259
|
+
className="overflow-hidden mt-4"
|
|
260
|
+
>
|
|
261
|
+
<div className="flex flex-col gap-3">
|
|
262
|
+
{archivedSubmissions.map((submission, index) => {
|
|
263
|
+
const isExpanded = expandedId === submission.id;
|
|
264
|
+
return (
|
|
265
|
+
<motion.div
|
|
266
|
+
key={submission.id}
|
|
267
|
+
initial={{ opacity: 0, y: 10 }}
|
|
268
|
+
animate={{ opacity: 1, y: 0 }}
|
|
269
|
+
transition={{ delay: index * 0.05 }}
|
|
270
|
+
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"
|
|
271
|
+
>
|
|
272
|
+
<button
|
|
273
|
+
onClick={() => toggleExpand(submission.id)}
|
|
274
|
+
className="w-full flex items-center justify-between p-4 md:px-6 hover:bg-foreground/[0.02] transition-colors text-left"
|
|
275
|
+
>
|
|
276
|
+
<div className="flex items-center gap-4 flex-1 min-w-0">
|
|
277
|
+
<div className="relative shrink-0 grayscale">
|
|
278
|
+
<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">
|
|
279
|
+
<Mail className="w-4 h-4" />
|
|
280
|
+
</div>
|
|
281
|
+
</div>
|
|
282
|
+
|
|
283
|
+
<div className="flex flex-col min-w-0 flex-1 justify-center">
|
|
284
|
+
<div className="flex items-center gap-2">
|
|
285
|
+
<span className="font-bold text-[14px] md:text-[15px] text-foreground truncate">{submission.name || submission.full_name || 'Anonymous Submission'}</span>
|
|
286
|
+
{submission.formId && (
|
|
287
|
+
<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">
|
|
288
|
+
{submission.formId.replace(/_/g, ' ')}
|
|
289
|
+
</span>
|
|
290
|
+
)}
|
|
291
|
+
</div>
|
|
292
|
+
<span className="text-[13px] text-foreground/50 truncate flex items-center gap-1.5 font-medium mt-0.5">
|
|
293
|
+
{submission.email || submission.contactEmail || 'No email provided'}
|
|
294
|
+
</span>
|
|
295
|
+
</div>
|
|
296
|
+
</div>
|
|
297
|
+
|
|
298
|
+
<div className="hidden md:flex items-center gap-6 ml-6 shrink-0">
|
|
299
|
+
<div className="text-[12px] font-medium text-foreground/40">
|
|
300
|
+
{formatDate(submission.submittedAt)}
|
|
301
|
+
</div>
|
|
302
|
+
<div className={`p-2 rounded-xl glass-panel transition-transform ${isExpanded ? 'rotate-180 bg-foreground/5' : ''}`}>
|
|
303
|
+
<ChevronDown className="w-4 h-4 text-foreground/50" />
|
|
304
|
+
</div>
|
|
305
|
+
</div>
|
|
306
|
+
</button>
|
|
307
|
+
|
|
308
|
+
<AnimatePresence>
|
|
309
|
+
{isExpanded && (
|
|
310
|
+
<motion.div
|
|
311
|
+
initial={{ height: 0, opacity: 0 }}
|
|
312
|
+
animate={{ height: 'auto', opacity: 1 }}
|
|
313
|
+
exit={{ height: 0, opacity: 0 }}
|
|
314
|
+
className="overflow-hidden"
|
|
315
|
+
>
|
|
316
|
+
<div className="p-4 md:p-6 pt-0 mt-2 border-t border-[var(--panel-border)]/30">
|
|
317
|
+
<div className="md:hidden text-[12px] font-medium text-foreground/40 w-fit mb-4">
|
|
318
|
+
{formatDate(submission.submittedAt)}
|
|
319
|
+
</div>
|
|
320
|
+
{Object.entries(submission)
|
|
321
|
+
.filter(([key]) => !['id', 'uid', 'submittedAt', 'avatarUrl', 'name', 'full_name', 'email', 'contactEmail', 'isArchived'].includes(key) && !key.toLowerCase().startsWith('submitter') && key.toLowerCase() !== 'formid')
|
|
322
|
+
.map(([key, val]) => {
|
|
323
|
+
const formattedKey = key.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase());
|
|
324
|
+
return (
|
|
325
|
+
<div key={key} className="bg-foreground/[0.02] rounded-2xl p-5 border border-[var(--panel-border)] relative mb-3">
|
|
326
|
+
<div className="flex items-center gap-2 mb-3 text-foreground/60 w-full justify-between">
|
|
327
|
+
<div className="flex items-center gap-2 text-accent">
|
|
328
|
+
<span className="text-[12px] font-bold uppercase tracking-widest text-accent">{formattedKey}</span>
|
|
329
|
+
</div>
|
|
330
|
+
</div>
|
|
331
|
+
<p className="text-[14px] md:text-[15px] text-foreground/80 leading-relaxed whitespace-pre-wrap font-medium">
|
|
332
|
+
{typeof val === 'boolean' ? (val ? 'Yes' : 'No') : String(val || "No data provided")}
|
|
333
|
+
</p>
|
|
334
|
+
</div>
|
|
335
|
+
);
|
|
336
|
+
})}
|
|
337
|
+
|
|
338
|
+
<div className="mt-6 flex justify-end">
|
|
339
|
+
<button
|
|
340
|
+
onClick={() => handleArchive(submission.id, submission.isArchived)}
|
|
341
|
+
disabled={isArchiving === submission.id}
|
|
342
|
+
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"
|
|
343
|
+
>
|
|
344
|
+
{isArchiving === submission.id ? (
|
|
345
|
+
<Loader2 className="w-4 h-4 animate-spin" />
|
|
346
|
+
) : (
|
|
347
|
+
<ArchiveRestore className="w-4 h-4" />
|
|
348
|
+
)}
|
|
349
|
+
Unarchive Submission
|
|
350
|
+
</button>
|
|
351
|
+
</div>
|
|
352
|
+
</div>
|
|
353
|
+
</motion.div>
|
|
354
|
+
)}
|
|
355
|
+
</AnimatePresence>
|
|
356
|
+
</motion.div>
|
|
357
|
+
);
|
|
358
|
+
})}
|
|
359
|
+
</div>
|
|
360
|
+
</motion.div>
|
|
361
|
+
)}
|
|
362
|
+
</AnimatePresence>
|
|
363
|
+
</div>
|
|
364
|
+
)}
|
|
365
|
+
</div>
|
|
366
|
+
)}
|
|
367
|
+
</div>
|
|
368
|
+
</div>
|
|
369
|
+
</div>
|
|
370
|
+
</main>
|
|
371
|
+
);
|
|
372
|
+
}
|
|
@@ -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 { Templates } from './Templates';
|
|
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('Templates 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
|
+
<Templates />
|
|
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
|
+
});
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { motion } from 'framer-motion';
|
|
2
|
+
import { Link } from 'react-router-dom';
|
|
3
|
+
import { LayoutTemplate, AppWindow, CheckCheck, Kanban, Table as TableIcon, Calendar } from 'lucide-react';
|
|
4
|
+
|
|
5
|
+
export function Templates() {
|
|
6
|
+
const templates = [
|
|
7
|
+
{
|
|
8
|
+
id: 'form',
|
|
9
|
+
title: 'Inline Form',
|
|
10
|
+
description: 'A static, embedded contact form designed directly into the page layout.',
|
|
11
|
+
icon: LayoutTemplate,
|
|
12
|
+
path: '/templates/inline_form',
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
id: 'popup',
|
|
16
|
+
title: 'Modal Popup Form',
|
|
17
|
+
description: 'A centralized popup contact form that visually blurs the background context.',
|
|
18
|
+
icon: AppWindow,
|
|
19
|
+
path: '/templates/popup_form',
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
id: 'confirmation',
|
|
23
|
+
title: 'Action Confirmation',
|
|
24
|
+
description: 'A classic confirmation dialog styled for decisive user actions.',
|
|
25
|
+
icon: CheckCheck,
|
|
26
|
+
path: '/templates/confirmation',
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
id: 'board',
|
|
30
|
+
title: 'Board View',
|
|
31
|
+
description: 'A modern, drag-and-drop styled Kanban board aesthetic for task management.',
|
|
32
|
+
icon: Kanban,
|
|
33
|
+
path: '/templates/board',
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
id: 'table',
|
|
37
|
+
title: 'Data Table',
|
|
38
|
+
description: 'A clean, column-based data table optimized for large datasets and bulk actions.',
|
|
39
|
+
icon: TableIcon,
|
|
40
|
+
path: '/templates/table',
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
id: 'calendar',
|
|
44
|
+
title: 'Calendar View',
|
|
45
|
+
description: 'A structured monthly calendar grid for scheduling and date-based resource planning.',
|
|
46
|
+
icon: Calendar,
|
|
47
|
+
path: '/templates/calendar',
|
|
48
|
+
}
|
|
49
|
+
];
|
|
50
|
+
|
|
51
|
+
return (
|
|
52
|
+
<main className="flex-1 w-full max-w-7xl mx-auto p-4 md:p-8 z-10 flex flex-col pt-12">
|
|
53
|
+
<motion.div
|
|
54
|
+
initial={{ opacity: 0, y: 20 }}
|
|
55
|
+
animate={{ opacity: 1, y: 0 }}
|
|
56
|
+
className="mb-10 text-center"
|
|
57
|
+
>
|
|
58
|
+
<h1 className="w-fit mx-auto inline-block text-4xl md:text-5xl lg:text-[4.8rem] leading-[1.1] font-extrabold tracking-tight mb-4 text-gradient animate-float">
|
|
59
|
+
Premium Templates
|
|
60
|
+
</h1>
|
|
61
|
+
<p className="text-foreground/60 text-lg font-medium max-w-2xl mx-auto">
|
|
62
|
+
Explore our collection of fully functional, beautifully designed boilerplate components.
|
|
63
|
+
</p>
|
|
64
|
+
</motion.div>
|
|
65
|
+
|
|
66
|
+
<div className="w-full max-w-4xl mx-auto flex flex-col gap-4 relative">
|
|
67
|
+
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[80%] h-[80%] bg-accent/5 blur-[120px] rounded-full pointer-events-none -z-10" />
|
|
68
|
+
|
|
69
|
+
{templates.map((tpl, i) => {
|
|
70
|
+
const Icon = tpl.icon;
|
|
71
|
+
return (
|
|
72
|
+
<motion.div
|
|
73
|
+
key={tpl.id}
|
|
74
|
+
initial={{ opacity: 0, y: 10 }}
|
|
75
|
+
animate={{ opacity: 1, y: 0 }}
|
|
76
|
+
transition={{ delay: i * 0.05 }}
|
|
77
|
+
>
|
|
78
|
+
<Link
|
|
79
|
+
to={tpl.path}
|
|
80
|
+
className="flex items-center gap-6 w-full p-4 md:px-6 glass-panel border border-[var(--panel-border)] rounded-2xl hover:border-accent/40 transition-all duration-300 group hover:bg-foreground/[0.02]"
|
|
81
|
+
>
|
|
82
|
+
<div className="w-12 h-12 rounded-xl bg-accent/10 flex items-center justify-center text-accent shrink-0 border border-accent/20 group-hover:scale-105 transition-transform">
|
|
83
|
+
<Icon className="w-5 h-5" />
|
|
84
|
+
</div>
|
|
85
|
+
|
|
86
|
+
<div className="flex flex-col flex-1 min-w-0">
|
|
87
|
+
<h3 className="text-[15px] font-bold text-foreground truncate">{tpl.title}</h3>
|
|
88
|
+
<p className="text-[13px] text-foreground/50 truncate font-medium">
|
|
89
|
+
{tpl.description}
|
|
90
|
+
</p>
|
|
91
|
+
</div>
|
|
92
|
+
|
|
93
|
+
<div className="hidden md:flex items-center gap-2 text-[13px] font-bold px-5 py-2.5 rounded-xl btn-primary shrink-0 transition-transform shadow-lg group-hover:-translate-y-0.5">
|
|
94
|
+
View <span className="font-black leading-none ml-0.5">→</span>
|
|
95
|
+
</div>
|
|
96
|
+
</Link>
|
|
97
|
+
</motion.div>
|
|
98
|
+
);
|
|
99
|
+
})}
|
|
100
|
+
</div>
|
|
101
|
+
</main>
|
|
102
|
+
);
|
|
103
|
+
}
|