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,194 @@
|
|
|
1
|
+
import { useState, useEffect, useRef } from 'react';
|
|
2
|
+
import { Bell, User, Mail, HelpCircle } from 'lucide-react';
|
|
3
|
+
import { motion, AnimatePresence } from 'framer-motion';
|
|
4
|
+
import { db } from '../lib/firebase';
|
|
5
|
+
import { collection, query, orderBy, limit, onSnapshot } from 'firebase/firestore';
|
|
6
|
+
import { useNavigate } from 'react-router-dom';
|
|
7
|
+
|
|
8
|
+
interface NotificationItem {
|
|
9
|
+
id: string;
|
|
10
|
+
type: 'user' | 'submission' | 'request';
|
|
11
|
+
title: string;
|
|
12
|
+
subtitle: string;
|
|
13
|
+
time: any;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function AdminNotifications() {
|
|
17
|
+
const [open, setOpen] = useState(false);
|
|
18
|
+
const [notifications, setNotifications] = useState<NotificationItem[]>([]);
|
|
19
|
+
const dropdownRef = useRef<HTMLDivElement>(null);
|
|
20
|
+
const navigate = useNavigate();
|
|
21
|
+
|
|
22
|
+
useEffect(() => {
|
|
23
|
+
function handleClickOutside(event: MouseEvent) {
|
|
24
|
+
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
|
25
|
+
setOpen(false);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
document.addEventListener('mousedown', handleClickOutside);
|
|
29
|
+
return () => document.removeEventListener('mousedown', handleClickOutside);
|
|
30
|
+
}, []);
|
|
31
|
+
|
|
32
|
+
// Use a ref to combine data reliably on client side
|
|
33
|
+
const dataRef = useRef({ users: [] as NotificationItem[], submissions: [] as NotificationItem[], requests: [] as NotificationItem[] });
|
|
34
|
+
|
|
35
|
+
const updateNotifications = (type: 'users' | 'submissions' | 'requests', data: NotificationItem[]) => {
|
|
36
|
+
dataRef.current[type] = data;
|
|
37
|
+
const combined = [...dataRef.current.users, ...dataRef.current.submissions, ...dataRef.current.requests];
|
|
38
|
+
combined.sort((a, b) => {
|
|
39
|
+
const tA = a.time?.toDate ? a.time.toDate().getTime() : new Date(a.time).getTime();
|
|
40
|
+
const tB = b.time?.toDate ? b.time.toDate().getTime() : new Date(b.time).getTime();
|
|
41
|
+
return (tB || 0) - (tA || 0);
|
|
42
|
+
});
|
|
43
|
+
setNotifications(combined.slice(0, 8)); // keep top 8
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
useEffect(() => {
|
|
47
|
+
// Listen to recent users
|
|
48
|
+
const usersQ = query(collection(db, 'user_profiles'), orderBy('createdAt', 'desc'), limit(5));
|
|
49
|
+
const unsubUsers = onSnapshot(usersQ, (snap) => {
|
|
50
|
+
const usersData = snap.docs.map(doc => {
|
|
51
|
+
const data = doc.data();
|
|
52
|
+
return {
|
|
53
|
+
id: `u_${doc.id}`,
|
|
54
|
+
type: 'user' as const,
|
|
55
|
+
title: 'New user registered',
|
|
56
|
+
subtitle: data.email || 'Unknown',
|
|
57
|
+
time: data.createdAt
|
|
58
|
+
};
|
|
59
|
+
});
|
|
60
|
+
updateNotifications('users', usersData);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
// Listen to recent submissions
|
|
64
|
+
const subsQ = query(collection(db, 'user_submissions'), orderBy('submittedAt', 'desc'), limit(5));
|
|
65
|
+
const unsubSubs = onSnapshot(subsQ, (snap) => {
|
|
66
|
+
const subsData = snap.docs.map(doc => {
|
|
67
|
+
const data = doc.data();
|
|
68
|
+
return {
|
|
69
|
+
id: `s_${doc.id}`,
|
|
70
|
+
type: 'submission' as const,
|
|
71
|
+
title: 'New form submission',
|
|
72
|
+
subtitle: data.name || data.email || 'Anonymous',
|
|
73
|
+
time: data.submittedAt
|
|
74
|
+
};
|
|
75
|
+
});
|
|
76
|
+
updateNotifications('submissions', subsData);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
// Listen to recent user requests
|
|
80
|
+
const reqQ = query(collection(db, 'user_requests'), orderBy('submittedAt', 'desc'), limit(5));
|
|
81
|
+
const unsubReq = onSnapshot(reqQ, (snap) => {
|
|
82
|
+
const reqData = snap.docs.map(doc => {
|
|
83
|
+
const data = doc.data();
|
|
84
|
+
return {
|
|
85
|
+
id: `r_${doc.id}`,
|
|
86
|
+
type: 'request' as const,
|
|
87
|
+
title: 'New support request',
|
|
88
|
+
subtitle: data.name || data.submitterName || data.contactEmail || data.email || 'Anonymous',
|
|
89
|
+
time: data.submittedAt
|
|
90
|
+
};
|
|
91
|
+
});
|
|
92
|
+
updateNotifications('requests', reqData);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
return () => {
|
|
96
|
+
unsubUsers();
|
|
97
|
+
unsubSubs();
|
|
98
|
+
unsubReq();
|
|
99
|
+
};
|
|
100
|
+
}, []);
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
const [lastReadTime, setLastReadTime] = useState(() => {
|
|
105
|
+
if (typeof window !== 'undefined') {
|
|
106
|
+
return parseInt(localStorage.getItem('admin_notifications_last_read') || '0', 10);
|
|
107
|
+
}
|
|
108
|
+
return 0;
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
const handleToggleOpen = () => {
|
|
112
|
+
const nextState = !open;
|
|
113
|
+
setOpen(nextState);
|
|
114
|
+
if (nextState) {
|
|
115
|
+
const now = Date.now();
|
|
116
|
+
setLastReadTime(now);
|
|
117
|
+
if (typeof window !== 'undefined') {
|
|
118
|
+
localStorage.setItem('admin_notifications_last_read', now.toString());
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
const hasUnread = notifications.some(n => {
|
|
124
|
+
if (!n.time) return true;
|
|
125
|
+
const t = n.time?.toDate ? n.time.toDate().getTime() : new Date(n.time).getTime();
|
|
126
|
+
return t > lastReadTime || isNaN(t);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
const handleNavigate = (type: 'user' | 'submission' | 'request') => {
|
|
130
|
+
setOpen(false);
|
|
131
|
+
if (type === 'user') navigate('/users');
|
|
132
|
+
else if (type === 'submission') navigate('/submissions');
|
|
133
|
+
else navigate('/requests');
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
const formatTime = (ts: any) => {
|
|
137
|
+
if (!ts) return '';
|
|
138
|
+
const date = ts.toDate ? ts.toDate() : new Date(ts);
|
|
139
|
+
return new Intl.DateTimeFormat('en-US', { month: 'short', day: 'numeric', hour: 'numeric', minute: 'numeric' }).format(date);
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
return (
|
|
143
|
+
<div className="relative z-50 flex-shrink-0 mr-1 md:mr-2" ref={dropdownRef}>
|
|
144
|
+
<button
|
|
145
|
+
onClick={handleToggleOpen}
|
|
146
|
+
className="p-2 rounded-xl glass-panel transition-all border border-transparent relative text-notification-icon hover:bg-foreground/5 hover:border-[var(--panel-border)]"
|
|
147
|
+
aria-label="Admin Notifications"
|
|
148
|
+
>
|
|
149
|
+
<Bell className="w-4 h-4 md:w-5 md:h-5" />
|
|
150
|
+
{hasUnread && (
|
|
151
|
+
<span className="absolute top-1.5 right-1.5 w-2 h-2 bg-red-500 rounded-full animate-pulse" />
|
|
152
|
+
)}
|
|
153
|
+
</button>
|
|
154
|
+
|
|
155
|
+
<AnimatePresence>
|
|
156
|
+
{open && (
|
|
157
|
+
<motion.div
|
|
158
|
+
initial={{ opacity: 0, y: -5, scale: 0.98 }}
|
|
159
|
+
animate={{ opacity: 1, y: 0, scale: 1 }}
|
|
160
|
+
exit={{ opacity: 0, scale: 0.98, y: -5 }}
|
|
161
|
+
className="absolute top-full mt-2 right-[-60px] md:right-0 w-[320px] md:w-[400px] glass-panel border border-[var(--panel-border)] rounded-2xl shadow-2xl z-50 flex flex-col origin-top-right overflow-hidden bg-[var(--panel-bg)]"
|
|
162
|
+
>
|
|
163
|
+
<div className="px-4 border-b border-[var(--panel-border)]/50 pb-3 pt-4 flex items-center justify-between">
|
|
164
|
+
<span className="font-bold text-[14px] text-foreground tracking-wide">Notifications</span>
|
|
165
|
+
<span className="text-[10px] font-bold uppercase tracking-widest text-accent bg-accent/10 px-2 py-0.5 rounded-md">Admin</span>
|
|
166
|
+
</div>
|
|
167
|
+
<div className="max-h-[350px] overflow-y-auto w-full p-2 flex flex-col gap-1">
|
|
168
|
+
{notifications.length === 0 ? (
|
|
169
|
+
<div className="p-6 text-center text-foreground/40 text-[13px] font-medium">No recent activity.</div>
|
|
170
|
+
) : (
|
|
171
|
+
notifications.map((n) => (
|
|
172
|
+
<button
|
|
173
|
+
key={n.id}
|
|
174
|
+
onClick={() => handleNavigate(n.type)}
|
|
175
|
+
className="w-full flex items-start gap-3 p-3 rounded-xl hover:bg-foreground/5 transition-all text-left group border border-transparent hover:border-[var(--panel-border)]"
|
|
176
|
+
>
|
|
177
|
+
<div className="mt-0.5 w-8 h-8 rounded-full flex items-center justify-center shrink-0 bg-foreground/5 text-notification-icon">
|
|
178
|
+
{n.type === 'user' ? <User className="w-4 h-4" /> : n.type === 'submission' ? <Mail className="w-4 h-4" /> : <HelpCircle className="w-4 h-4" />}
|
|
179
|
+
</div>
|
|
180
|
+
<div className="flex flex-col flex-1 min-w-0 pr-2">
|
|
181
|
+
<span className="text-[13px] font-semibold text-foreground group-hover:text-accent transition-colors truncate">{n.title}</span>
|
|
182
|
+
<span className="text-[12px] text-foreground/60 truncate">{n.subtitle}</span>
|
|
183
|
+
<span className="text-[10px] font-mono tracking-wide text-foreground/40 mt-1">{formatTime(n.time)}</span>
|
|
184
|
+
</div>
|
|
185
|
+
</button>
|
|
186
|
+
))
|
|
187
|
+
)}
|
|
188
|
+
</div>
|
|
189
|
+
</motion.div>
|
|
190
|
+
)}
|
|
191
|
+
</AnimatePresence>
|
|
192
|
+
</div>
|
|
193
|
+
);
|
|
194
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
import { render, screen, fireEvent } from '@testing-library/react';
|
|
3
|
+
import { Button } from '../components/Button';
|
|
4
|
+
|
|
5
|
+
describe('Button component', () => {
|
|
6
|
+
it('renders correctly with given text', () => {
|
|
7
|
+
render(<Button>Click me</Button>);
|
|
8
|
+
expect(screen.getByText('Click me')).toBeInTheDocument();
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it('triggers onClick when properly clicked', () => {
|
|
12
|
+
const handleClick = vi.fn();
|
|
13
|
+
render(<Button onClick={handleClick}>Submit</Button>);
|
|
14
|
+
fireEvent.click(screen.getByText('Submit'));
|
|
15
|
+
expect(handleClick).toHaveBeenCalledTimes(1);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('shows loading state properly', () => {
|
|
19
|
+
render(<Button isLoading={true}>Submit</Button>);
|
|
20
|
+
expect(screen.queryByText('Submit')?.closest('span')).toHaveClass('opacity-0');
|
|
21
|
+
});
|
|
22
|
+
});
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { motion, type HTMLMotionProps } from 'framer-motion';
|
|
3
|
+
import { clsx, type ClassValue } from 'clsx';
|
|
4
|
+
import { twMerge } from 'tailwind-merge';
|
|
5
|
+
|
|
6
|
+
function cn(...inputs: ClassValue[]) {
|
|
7
|
+
return twMerge(clsx(inputs));
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
interface ButtonProps extends HTMLMotionProps<"button"> {
|
|
11
|
+
variant?: 'primary' | 'secondary' | 'ghost';
|
|
12
|
+
isLoading?: boolean;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
|
16
|
+
({ className, variant = 'primary', isLoading, children, ...props }, ref) => {
|
|
17
|
+
|
|
18
|
+
const variants = {
|
|
19
|
+
primary: 'btn-primary !border-none',
|
|
20
|
+
secondary: 'btn-secondary !border-none',
|
|
21
|
+
ghost: 'bg-transparent hover:bg-black/5 dark:hover:bg-white/5 text-foreground/60 hover:text-foreground transition-all duration-300 !border-none',
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
return (
|
|
25
|
+
<motion.button
|
|
26
|
+
ref={ref}
|
|
27
|
+
whileHover={{ scale: 1.02, y: -2 }}
|
|
28
|
+
whileTap={{ scale: 0.98 }}
|
|
29
|
+
className={cn(
|
|
30
|
+
'relative inline-flex items-center justify-center px-5 py-2 text-sm font-medium transition-all duration-300 overflow-hidden group',
|
|
31
|
+
variants[variant],
|
|
32
|
+
className
|
|
33
|
+
)}
|
|
34
|
+
style={{ borderRadius: 'var(--btn-radius, 0.75rem)' }}
|
|
35
|
+
disabled={isLoading || props.disabled}
|
|
36
|
+
{...props}
|
|
37
|
+
>
|
|
38
|
+
{isLoading ? (
|
|
39
|
+
<span className="absolute inset-0 flex items-center justify-center">
|
|
40
|
+
<svg className="w-5 h-5 animate-spin text-current" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
|
41
|
+
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
|
42
|
+
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
|
43
|
+
</svg>
|
|
44
|
+
</span>
|
|
45
|
+
) : null}
|
|
46
|
+
<span className={cn("inline-flex items-center justify-center gap-2", isLoading && 'opacity-0')}>
|
|
47
|
+
{children as React.ReactNode}
|
|
48
|
+
</span>
|
|
49
|
+
</motion.button>
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
);
|
|
53
|
+
Button.displayName = 'Button';
|
|
@@ -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 { ConfirmModal } from './ConfirmModal';
|
|
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('ConfirmModal 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
|
+
<ConfirmModal />
|
|
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,73 @@
|
|
|
1
|
+
import { motion, AnimatePresence } from 'framer-motion';
|
|
2
|
+
import { AlertCircle, Loader2 } from 'lucide-react';
|
|
3
|
+
import { Button } from './Button';
|
|
4
|
+
|
|
5
|
+
export interface ConfirmModalProps {
|
|
6
|
+
isOpen: boolean;
|
|
7
|
+
title: string;
|
|
8
|
+
message: React.ReactNode;
|
|
9
|
+
confirmText?: string;
|
|
10
|
+
cancelText?: string;
|
|
11
|
+
onConfirm: () => void;
|
|
12
|
+
onCancel: () => void;
|
|
13
|
+
isProcessing?: boolean;
|
|
14
|
+
disableConfirm?: boolean;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function ConfirmModal({
|
|
18
|
+
isOpen,
|
|
19
|
+
title,
|
|
20
|
+
message,
|
|
21
|
+
confirmText = 'Delete',
|
|
22
|
+
cancelText = 'Cancel',
|
|
23
|
+
onConfirm,
|
|
24
|
+
onCancel,
|
|
25
|
+
isProcessing = false,
|
|
26
|
+
disableConfirm = false
|
|
27
|
+
}: ConfirmModalProps) {
|
|
28
|
+
return (
|
|
29
|
+
<AnimatePresence>
|
|
30
|
+
{isOpen && (
|
|
31
|
+
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
|
32
|
+
<motion.div
|
|
33
|
+
onClick={() => !isProcessing && onCancel()}
|
|
34
|
+
initial={{ opacity: 0 }}
|
|
35
|
+
animate={{ opacity: 1 }}
|
|
36
|
+
exit={{ opacity: 0 }}
|
|
37
|
+
className="absolute inset-0 bg-background/80 backdrop-blur-xl cursor-pointer"
|
|
38
|
+
/>
|
|
39
|
+
<motion.div
|
|
40
|
+
initial={{ opacity: 0, scale: 0.95, y: 10 }}
|
|
41
|
+
animate={{ opacity: 1, scale: 1, y: 0 }}
|
|
42
|
+
exit={{ opacity: 0, scale: 0.95, y: 10 }}
|
|
43
|
+
className="w-full max-w-[400px] p-6 rounded-3xl glass-panel border border-[var(--panel-border)] shadow-2xl relative z-10 text-center"
|
|
44
|
+
>
|
|
45
|
+
<div className="w-full flex flex-col items-center">
|
|
46
|
+
<h3 className="text-xl font-extrabold mb-2 text-foreground tracking-tight">{title}</h3>
|
|
47
|
+
<p className="text-[14px] font-medium text-foreground/60 leading-relaxed mb-6">
|
|
48
|
+
{message}
|
|
49
|
+
</p>
|
|
50
|
+
<div className="flex items-center gap-3 w-full">
|
|
51
|
+
<Button
|
|
52
|
+
variant="secondary"
|
|
53
|
+
onClick={onCancel}
|
|
54
|
+
disabled={isProcessing}
|
|
55
|
+
className="flex-1 py-2.5 text-[13px] font-bold rounded-xl"
|
|
56
|
+
>
|
|
57
|
+
{cancelText}
|
|
58
|
+
</Button>
|
|
59
|
+
<Button
|
|
60
|
+
onClick={onConfirm}
|
|
61
|
+
disabled={isProcessing || disableConfirm}
|
|
62
|
+
className="flex-1 py-2.5 text-[13px] font-bold rounded-xl bg-red-500 hover:bg-red-600 border-none text-white shadow-xl hover:shadow-red-500/20 glow-hover"
|
|
63
|
+
>
|
|
64
|
+
{isProcessing ? <Loader2 className="w-5 h-5 animate-spin mx-auto" /> : confirmText}
|
|
65
|
+
</Button>
|
|
66
|
+
</div>
|
|
67
|
+
</div>
|
|
68
|
+
</motion.div>
|
|
69
|
+
</div>
|
|
70
|
+
)}
|
|
71
|
+
</AnimatePresence>
|
|
72
|
+
);
|
|
73
|
+
}
|
|
@@ -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 { ContactPopup } from './ContactPopup';
|
|
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('ContactPopup 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
|
+
<ContactPopup />
|
|
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
|
+
});
|