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,437 @@
|
|
|
1
|
+
import { useState, useRef, useEffect } from 'react';
|
|
2
|
+
import { createPortal } from 'react-dom';
|
|
3
|
+
import { motion } from 'framer-motion';
|
|
4
|
+
import { CheckCircle2, AlertCircle, ChevronDown, Loader2, ArrowLeft } from 'lucide-react';
|
|
5
|
+
import { auth, db } from '../lib/firebase';
|
|
6
|
+
import { collection, addDoc, serverTimestamp } from 'firebase/firestore';
|
|
7
|
+
import { Input } from './Input';
|
|
8
|
+
import { Button } from './Button';
|
|
9
|
+
import { CustomSelect } from './CustomSelect';
|
|
10
|
+
import { useTheme } from '../lib/ThemeContext';
|
|
11
|
+
|
|
12
|
+
import { PhoneInput, defaultCountries } from 'react-international-phone';
|
|
13
|
+
import 'react-international-phone/style.css';
|
|
14
|
+
|
|
15
|
+
const popularCountryCodes = ['us', 'gb', 'ca', 'au', 'nz', 'ie', 'ae', 'sg'];
|
|
16
|
+
const customizedCountries = [
|
|
17
|
+
...defaultCountries.filter((c) => popularCountryCodes.includes(c[1])),
|
|
18
|
+
...defaultCountries.filter((c) => !popularCountryCodes.includes(c[1]))
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
import { doc, getDoc } from 'firebase/firestore';
|
|
22
|
+
|
|
23
|
+
interface ContactPopupProps {
|
|
24
|
+
onClose?: () => void;
|
|
25
|
+
formId?: string;
|
|
26
|
+
formConfig?: any; // Fallback
|
|
27
|
+
isInline?: boolean;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function PhoneInputField({ field, value, onChange }: { field: any, value: string, onChange: (val: string) => void }) {
|
|
31
|
+
return (
|
|
32
|
+
<div className="flex flex-col w-full relative z-[99999]">
|
|
33
|
+
<label className="text-[13px] font-medium text-foreground/50 select-none mb-1.5 ml-1">
|
|
34
|
+
{field.label} {field.required && <span className="text-red-500">*</span>}
|
|
35
|
+
</label>
|
|
36
|
+
<PhoneInput
|
|
37
|
+
defaultCountry="us"
|
|
38
|
+
countries={customizedCountries}
|
|
39
|
+
value={value}
|
|
40
|
+
onChange={(val) => onChange(val)}
|
|
41
|
+
forceDialCode={true}
|
|
42
|
+
className="w-full flex items-center rounded-xl text-[15px] text-foreground transition-all duration-300 focus-within:border-accent glow-focus glass-panel overflow-visible relative h-12"
|
|
43
|
+
inputClassName="flex-1 w-full !bg-transparent !border-none !outline-none !shadow-none px-3 text-[15px] text-foreground m-0 p-0 h-full tracking-[0.5px] no-glow"
|
|
44
|
+
countrySelectorStyleProps={{
|
|
45
|
+
buttonClassName: "!bg-transparent !border-0 border-r !border-[var(--panel-border)]/50 !outline-none !shadow-none !px-3 hover:!bg-foreground/5 transition-colors h-full !rounded-xl !rounded-r-none flex items-center justify-center shrink-0",
|
|
46
|
+
}}
|
|
47
|
+
/>
|
|
48
|
+
</div>
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
import { contactFormConfig } from '../configs/forms/contactForm.config';
|
|
53
|
+
import { supportFormConfig } from '../configs/forms/supportForm.config';
|
|
54
|
+
|
|
55
|
+
export function ContactPopup({ onClose, formId, formConfig: propsConfig, isInline }: ContactPopupProps) {
|
|
56
|
+
const [formData, setFormData] = useState<any>({});
|
|
57
|
+
const [status, setStatus] = useState<'idle' | 'loading' | 'success' | 'error'>('idle');
|
|
58
|
+
const [errorMessage, setErrorMessage] = useState('');
|
|
59
|
+
|
|
60
|
+
const popupRef = useRef<HTMLDivElement>(null);
|
|
61
|
+
const [formConfig, setFormConfig] = useState<any>(propsConfig || null);
|
|
62
|
+
const [loadingConfig, setLoadingConfig] = useState(!propsConfig);
|
|
63
|
+
|
|
64
|
+
useEffect(() => {
|
|
65
|
+
if (propsConfig) {
|
|
66
|
+
setFormConfig(propsConfig);
|
|
67
|
+
setLoadingConfig(false);
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const fetchConfig = async () => {
|
|
72
|
+
try {
|
|
73
|
+
if (!formId) {
|
|
74
|
+
setFormConfig(contactFormConfig);
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
const configDoc = await getDoc(doc(db, 'sys_forms', formId));
|
|
78
|
+
if (configDoc.exists()) {
|
|
79
|
+
setFormConfig({ ...configDoc.data(), id: configDoc.id });
|
|
80
|
+
} else {
|
|
81
|
+
if (formId === 'contact') {
|
|
82
|
+
setFormConfig(contactFormConfig);
|
|
83
|
+
} else if (formId === 'support') {
|
|
84
|
+
setFormConfig(supportFormConfig);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
} catch (err) {
|
|
88
|
+
console.error('Failed to load form config:', err);
|
|
89
|
+
} finally {
|
|
90
|
+
setLoadingConfig(false);
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
fetchConfig();
|
|
94
|
+
}, [formId, propsConfig]);
|
|
95
|
+
|
|
96
|
+
useEffect(() => {
|
|
97
|
+
if (formConfig && formConfig.fields) {
|
|
98
|
+
setFormData((prev: any) => {
|
|
99
|
+
const newData = { ...prev };
|
|
100
|
+
let changed = false;
|
|
101
|
+
formConfig.fields.forEach((f: any) => {
|
|
102
|
+
if ((f.name === 'contactEmail' || f.type === 'email') && !newData[f.name]) {
|
|
103
|
+
if (auth.currentUser?.email) {
|
|
104
|
+
newData[f.name] = auth.currentUser.email;
|
|
105
|
+
changed = true;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
return changed ? newData : prev;
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
}, [formConfig]);
|
|
113
|
+
|
|
114
|
+
// Close popup when clicking outside
|
|
115
|
+
useEffect(() => {
|
|
116
|
+
function handleClickOutside(event: MouseEvent) {
|
|
117
|
+
if (popupRef.current && !popupRef.current.contains(event.target as Node)) {
|
|
118
|
+
onClose?.();
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
document.addEventListener('mousedown', handleClickOutside);
|
|
122
|
+
return () => document.removeEventListener('mousedown', handleClickOutside);
|
|
123
|
+
}, [onClose]);
|
|
124
|
+
|
|
125
|
+
useEffect(() => {
|
|
126
|
+
if (isInline) return;
|
|
127
|
+
const handleEscape = (e: KeyboardEvent) => {
|
|
128
|
+
if (e.key === 'Escape') onClose?.();
|
|
129
|
+
};
|
|
130
|
+
document.addEventListener('keydown', handleEscape);
|
|
131
|
+
return () => document.removeEventListener('keydown', handleEscape);
|
|
132
|
+
}, [onClose, isInline]);
|
|
133
|
+
|
|
134
|
+
// Prevent background scrolling when popup is open
|
|
135
|
+
useEffect(() => {
|
|
136
|
+
if (!isInline) {
|
|
137
|
+
document.body.style.overflow = 'hidden';
|
|
138
|
+
return () => {
|
|
139
|
+
document.body.style.overflow = 'unset';
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
}, [isInline]);
|
|
143
|
+
|
|
144
|
+
const handleSubmit = async (e: React.FormEvent) => {
|
|
145
|
+
e.preventDefault();
|
|
146
|
+
setStatus('loading');
|
|
147
|
+
setErrorMessage('');
|
|
148
|
+
|
|
149
|
+
try {
|
|
150
|
+
const collectionName = formConfig?.submitPage === 'requests' ? 'user_requests' : 'user_submissions';
|
|
151
|
+
|
|
152
|
+
const userPayload = auth.currentUser ? {
|
|
153
|
+
uid: auth.currentUser.uid,
|
|
154
|
+
submitterName: auth.currentUser.displayName || '',
|
|
155
|
+
submitterEmail: auth.currentUser.email || '',
|
|
156
|
+
submitterAvatar: auth.currentUser.photoURL || ''
|
|
157
|
+
} : { uid: 'guest' };
|
|
158
|
+
|
|
159
|
+
await addDoc(collection(db, collectionName), {
|
|
160
|
+
...formData,
|
|
161
|
+
...userPayload,
|
|
162
|
+
formId: formId || 'unknown',
|
|
163
|
+
submittedAt: serverTimestamp(),
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
setStatus('success');
|
|
167
|
+
setTimeout(() => {
|
|
168
|
+
if (formConfig?.redirectTo) {
|
|
169
|
+
window.location.href = formConfig.redirectTo;
|
|
170
|
+
} else {
|
|
171
|
+
onClose?.();
|
|
172
|
+
setStatus('idle');
|
|
173
|
+
setFormData({});
|
|
174
|
+
}
|
|
175
|
+
}, 3000);
|
|
176
|
+
} catch (error: any) {
|
|
177
|
+
console.error('Submission error:', error);
|
|
178
|
+
setStatus('error');
|
|
179
|
+
setErrorMessage('We could not send your message right now. Please try again.');
|
|
180
|
+
}
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
const [showLoader, setShowLoader] = useState(false);
|
|
184
|
+
|
|
185
|
+
useEffect(() => {
|
|
186
|
+
let timer: any;
|
|
187
|
+
if (loadingConfig || (!formConfig && !propsConfig)) {
|
|
188
|
+
timer = setTimeout(() => setShowLoader(true), 150);
|
|
189
|
+
} else {
|
|
190
|
+
setShowLoader(false);
|
|
191
|
+
}
|
|
192
|
+
return () => clearTimeout(timer);
|
|
193
|
+
}, [loadingConfig, formConfig, propsConfig]);
|
|
194
|
+
|
|
195
|
+
if (loadingConfig || !formConfig) {
|
|
196
|
+
if (!showLoader) return null;
|
|
197
|
+
if (isInline) {
|
|
198
|
+
return (
|
|
199
|
+
<div className="w-full flex justify-center py-[10vh]">
|
|
200
|
+
<Loader2 className="w-8 h-8 animate-spin text-accent" />
|
|
201
|
+
</div>
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
return createPortal(
|
|
205
|
+
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-background/60 backdrop-blur-xl">
|
|
206
|
+
<Loader2 className="w-8 h-8 animate-spin text-accent" />
|
|
207
|
+
</div>,
|
|
208
|
+
document.body
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const content = (
|
|
213
|
+
<div className="relative z-10 w-full">
|
|
214
|
+
{status === 'success' ? (
|
|
215
|
+
<motion.div
|
|
216
|
+
initial={{ opacity: 0, y: 10 }}
|
|
217
|
+
animate={{ opacity: 1, y: 0 }}
|
|
218
|
+
className="flex flex-col items-center justify-center py-8 text-center"
|
|
219
|
+
>
|
|
220
|
+
<div className="w-16 h-16 rounded-full bg-green-500/10 border border-green-500/20 flex items-center justify-center text-green-500 mb-4">
|
|
221
|
+
<CheckCircle2 className="w-8 h-8" />
|
|
222
|
+
</div>
|
|
223
|
+
<h4 className="text-lg font-bold text-foreground mb-2">Message Sent!</h4>
|
|
224
|
+
<p className="text-[14px] text-foreground/60 leading-relaxed">Thank you for your interest. We'll get back to you shortly.</p>
|
|
225
|
+
</motion.div>
|
|
226
|
+
) : status === 'loading' ? (
|
|
227
|
+
<motion.div
|
|
228
|
+
initial={{ opacity: 0, scale: 0.95 }}
|
|
229
|
+
animate={{ opacity: 1, scale: 1 }}
|
|
230
|
+
className="flex flex-col items-center justify-center py-16 text-center"
|
|
231
|
+
>
|
|
232
|
+
<Loader2 className="w-10 h-10 animate-spin text-accent mb-4" />
|
|
233
|
+
<h4 className="text-lg font-bold text-foreground tracking-tight">Processing</h4>
|
|
234
|
+
<p className="text-[13px] text-foreground/50 mt-1">Please wait a moment...</p>
|
|
235
|
+
</motion.div>
|
|
236
|
+
) : (
|
|
237
|
+
<>
|
|
238
|
+
<div className="mb-6 text-center">
|
|
239
|
+
<h3 className="text-2xl sm:text-3xl font-extrabold mb-2 text-foreground tracking-tight break-words">{formConfig.title}</h3>
|
|
240
|
+
{formConfig.description && <p className="text-[14px] md:text-[15px] font-medium text-foreground/60 tracking-wide leading-relaxed">{formConfig.description}</p>}
|
|
241
|
+
</div>
|
|
242
|
+
{status === 'error' && (
|
|
243
|
+
<motion.div initial={{ opacity: 0, y: -10 }} animate={{ opacity: 1, y: 0 }} className="mb-4 p-3 sm:p-4 rounded-xl bg-red-500/10 border border-red-500/20 flex items-start gap-3">
|
|
244
|
+
<AlertCircle className="w-5 h-5 text-red-500 shrink-0 mt-0.5" />
|
|
245
|
+
<p className="text-[14px] font-medium text-red-500/90">{errorMessage}</p>
|
|
246
|
+
</motion.div>
|
|
247
|
+
)}
|
|
248
|
+
|
|
249
|
+
<form onSubmit={handleSubmit} className="flex flex-col gap-4 sm:gap-5 w-full">
|
|
250
|
+
{[...(formConfig.fields || [])].sort((a: any, b: any) => {
|
|
251
|
+
const aIsCheckbox = a.type === 'checkbox';
|
|
252
|
+
const bIsCheckbox = b.type === 'checkbox';
|
|
253
|
+
if (aIsCheckbox && !bIsCheckbox) return 1;
|
|
254
|
+
if (!aIsCheckbox && bIsCheckbox) return -1;
|
|
255
|
+
return 0;
|
|
256
|
+
}).map((field: any, idx: number) => {
|
|
257
|
+
if (field.type === 'tel') {
|
|
258
|
+
return (
|
|
259
|
+
<PhoneInputField
|
|
260
|
+
key={idx}
|
|
261
|
+
field={field}
|
|
262
|
+
value={formData[field.name] || ''}
|
|
263
|
+
onChange={val => setFormData({ ...formData, [field.name]: val })}
|
|
264
|
+
/>
|
|
265
|
+
);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
if (field.type === 'textarea') {
|
|
269
|
+
return (
|
|
270
|
+
<div key={idx} className="flex flex-col w-full relative">
|
|
271
|
+
<label className="text-[13px] font-medium text-foreground/50 select-none mb-1.5 ml-1">
|
|
272
|
+
{field.label}
|
|
273
|
+
</label>
|
|
274
|
+
<textarea
|
|
275
|
+
required={field.required}
|
|
276
|
+
rows={3}
|
|
277
|
+
placeholder={field.placeholder}
|
|
278
|
+
value={formData[field.name] || ''}
|
|
279
|
+
onChange={e => setFormData({ ...formData, [field.name]: e.target.value })}
|
|
280
|
+
className="flex w-full rounded-xl text-[15px] text-foreground px-4 py-3 transition-all duration-300 placeholder:text-foreground/40 focus:outline-none focus:border-accent glow-focus disabled:cursor-not-allowed disabled:opacity-50 glass-panel min-h-[100px] resize-none"
|
|
281
|
+
/>
|
|
282
|
+
</div>
|
|
283
|
+
);
|
|
284
|
+
}
|
|
285
|
+
if (field.type === 'select') {
|
|
286
|
+
return (
|
|
287
|
+
<div key={idx} className="w-full">
|
|
288
|
+
<CustomSelect
|
|
289
|
+
label={field.label}
|
|
290
|
+
value={formData[field.name] || ''}
|
|
291
|
+
options={[
|
|
292
|
+
{ value: '', label: <span className="text-foreground/40">Select an option...</span> },
|
|
293
|
+
...(field.options || []).map((opt: string) => ({ value: opt, label: opt }))
|
|
294
|
+
]}
|
|
295
|
+
onChange={val => setFormData({ ...formData, [field.name]: val })}
|
|
296
|
+
/>
|
|
297
|
+
</div>
|
|
298
|
+
);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
if (field.type === 'checkbox') {
|
|
302
|
+
return (
|
|
303
|
+
<label key={idx} className="flex items-center gap-3 w-full cursor-pointer group py-2">
|
|
304
|
+
<div className="relative flex items-center justify-center w-5 h-5 rounded border border-foreground/20 bg-background/50 group-hover:border-accent transition-colors">
|
|
305
|
+
<input
|
|
306
|
+
type="checkbox"
|
|
307
|
+
required={field.required}
|
|
308
|
+
checked={!!formData[field.name]}
|
|
309
|
+
onChange={e => setFormData({...formData, [field.name]: e.target.checked})}
|
|
310
|
+
className="absolute opacity-0 w-full h-full cursor-pointer"
|
|
311
|
+
/>
|
|
312
|
+
{formData[field.name] && <span className="w-2.5 h-2.5 bg-accent rounded-sm shrink-0"></span>}
|
|
313
|
+
</div>
|
|
314
|
+
<span className="text-[14px] font-medium text-foreground/80 leading-snug group-hover:text-foreground transition-colors">{field.label}</span>
|
|
315
|
+
</label>
|
|
316
|
+
);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
if (field.type === 'age') {
|
|
320
|
+
return (
|
|
321
|
+
<Input
|
|
322
|
+
key={idx}
|
|
323
|
+
label={field.label}
|
|
324
|
+
required={field.required}
|
|
325
|
+
type="number"
|
|
326
|
+
min={0}
|
|
327
|
+
max={120}
|
|
328
|
+
placeholder={field.placeholder || "Age (Max 120)"}
|
|
329
|
+
value={formData[field.name] || ''}
|
|
330
|
+
onChange={e => {
|
|
331
|
+
const rawVal = e.target.value;
|
|
332
|
+
if (rawVal === '') {
|
|
333
|
+
setFormData({ ...formData, [field.name]: '' });
|
|
334
|
+
} else {
|
|
335
|
+
const num = Math.max(0, Math.min(120, parseInt(rawVal, 10)));
|
|
336
|
+
setFormData({ ...formData, [field.name]: isNaN(num) ? '' : String(num) });
|
|
337
|
+
}
|
|
338
|
+
}}
|
|
339
|
+
/>
|
|
340
|
+
);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
if (field.type === 'currency' || (field.type === 'number' && field.name === 'budget')) {
|
|
344
|
+
return (
|
|
345
|
+
<div key={idx} className="flex flex-col w-full relative">
|
|
346
|
+
<label className="text-[13px] font-medium text-foreground/50 select-none mb-1.5 ml-1">
|
|
347
|
+
{field.label}
|
|
348
|
+
</label>
|
|
349
|
+
<div className="relative flex items-center justify-center">
|
|
350
|
+
<span className="absolute left-4 text-foreground/50 font-bold flex items-center h-full">{field.currencySymbol || '$'}</span>
|
|
351
|
+
<input
|
|
352
|
+
required={field.required}
|
|
353
|
+
type="number"
|
|
354
|
+
placeholder={field.placeholder}
|
|
355
|
+
value={formData[field.name] || ''}
|
|
356
|
+
onChange={e => setFormData({ ...formData, [field.name]: e.target.value })}
|
|
357
|
+
className="block h-12 w-full text-[15px] text-foreground pl-8 pr-4 py-2 transition-all duration-300 placeholder:text-foreground/40 focus:outline-none focus:border-accent glow-focus disabled:cursor-not-allowed disabled:opacity-50 glass-panel"
|
|
358
|
+
style={{ borderRadius: 'var(--input-radius, 0.75rem)' }}
|
|
359
|
+
/>
|
|
360
|
+
</div>
|
|
361
|
+
</div>
|
|
362
|
+
);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
return (
|
|
366
|
+
<Input
|
|
367
|
+
key={idx}
|
|
368
|
+
label={field.label}
|
|
369
|
+
required={field.required}
|
|
370
|
+
type={field.type}
|
|
371
|
+
placeholder={field.placeholder}
|
|
372
|
+
currencySymbol={field.currencySymbol}
|
|
373
|
+
value={formData[field.name] || ''}
|
|
374
|
+
onChange={e => setFormData({ ...formData, [field.name]: e.target.value })}
|
|
375
|
+
/>
|
|
376
|
+
);
|
|
377
|
+
})}
|
|
378
|
+
|
|
379
|
+
<div className="pt-2">
|
|
380
|
+
<Button
|
|
381
|
+
type="submit"
|
|
382
|
+
className="w-full px-6 md:px-8 py-3 md:py-3.5 rounded-xl font-semibold tracking-wide btn-primary text-[15px] shadow-xl transition-all duration-300 hover:-translate-y-1 active:scale-95 flex items-center justify-center"
|
|
383
|
+
isLoading={false}
|
|
384
|
+
>
|
|
385
|
+
{formConfig.submitText || formConfig.buttonName || 'Submit'}
|
|
386
|
+
</Button>
|
|
387
|
+
</div>
|
|
388
|
+
</form>
|
|
389
|
+
</>
|
|
390
|
+
)}
|
|
391
|
+
</div>
|
|
392
|
+
);
|
|
393
|
+
|
|
394
|
+
if (isInline) {
|
|
395
|
+
return (
|
|
396
|
+
<div className="w-full flex justify-center flex-col items-center">
|
|
397
|
+
<div className="w-full max-w-[500px] mb-6 px-2">
|
|
398
|
+
<button onClick={() => window.history.back()} className="flex items-center gap-2 text-foreground/40 hover:text-foreground font-medium transition-colors w-fit group">
|
|
399
|
+
<ArrowLeft className="w-4 h-4 group-hover:-translate-x-1 transition-transform" />
|
|
400
|
+
Go Back
|
|
401
|
+
</button>
|
|
402
|
+
</div>
|
|
403
|
+
<motion.div
|
|
404
|
+
initial={{ opacity: 0, y: 10 }}
|
|
405
|
+
animate={{ opacity: 1, y: 0 }}
|
|
406
|
+
className="w-full max-w-[500px] p-6 sm:p-8 lg:p-10 rounded-3xl md:rounded-3xl glass-panel relative glow-hover shadow-[0_8px_32px_rgba(0,0,0,0.08)] z-10 mx-auto"
|
|
407
|
+
>
|
|
408
|
+
<div className="absolute -inset-[1px] bg-gradient-to-br from-[var(--primary-glow)] to-transparent opacity-30 blur-[20px] rounded-3xl md:rounded-3xl -z-10" />
|
|
409
|
+
{content}
|
|
410
|
+
</motion.div>
|
|
411
|
+
</div>
|
|
412
|
+
);
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
return createPortal(
|
|
416
|
+
<div className="fixed inset-0 z-[100] flex items-start justify-center p-4 py-20 md:py-10 h-[-webkit-fill-available] md:h-screen w-screen overflow-y-auto no-scrollbar">
|
|
417
|
+
<motion.div
|
|
418
|
+
onClick={onClose}
|
|
419
|
+
initial={{ opacity: 0 }}
|
|
420
|
+
animate={{ opacity: 1 }}
|
|
421
|
+
exit={{ opacity: 0 }}
|
|
422
|
+
className="fixed inset-0 bg-background/60 backdrop-blur-xl cursor-pointer"
|
|
423
|
+
/>
|
|
424
|
+
<motion.div
|
|
425
|
+
initial={{ opacity: 0, scale: 0.95, y: 10 }}
|
|
426
|
+
animate={{ opacity: 1, scale: 1, y: 0 }}
|
|
427
|
+
exit={{ opacity: 0, scale: 0.95, y: 10 }}
|
|
428
|
+
ref={popupRef}
|
|
429
|
+
className="w-full max-w-[500px] p-6 sm:p-8 lg:p-10 rounded-3xl md:rounded-3xl glass-panel relative glow-hover shadow-[0_8px_32px_rgba(0,0,0,0.08)] z-10 mx-auto my-auto border border-[var(--panel-border)] shrink-0"
|
|
430
|
+
>
|
|
431
|
+
<div className="absolute -inset-[1px] bg-gradient-to-br from-[var(--primary-glow)] to-transparent opacity-30 blur-[20px] rounded-3xl md:rounded-3xl -z-10" />
|
|
432
|
+
{content}
|
|
433
|
+
</motion.div>
|
|
434
|
+
</div>,
|
|
435
|
+
document.body
|
|
436
|
+
);
|
|
437
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
import { render, screen, fireEvent } from '@testing-library/react';
|
|
3
|
+
import { CustomSelect } from '../components/CustomSelect';
|
|
4
|
+
|
|
5
|
+
describe('CustomSelect Component', () => {
|
|
6
|
+
const options = [
|
|
7
|
+
{ value: 'apple', label: 'Apple' },
|
|
8
|
+
{ value: 'banana', label: 'Banana' },
|
|
9
|
+
{ value: 'cherry', label: 'Cherry' },
|
|
10
|
+
];
|
|
11
|
+
|
|
12
|
+
it('renders default selected option', () => {
|
|
13
|
+
render(<CustomSelect value="banana" options={options} onChange={() => {}} />);
|
|
14
|
+
expect(screen.getByText('Banana')).toBeInTheDocument();
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('opens dropdown when clicked', () => {
|
|
18
|
+
render(<CustomSelect value="apple" options={options} onChange={() => {}} />);
|
|
19
|
+
|
|
20
|
+
// Apple should be visible as the chosen item
|
|
21
|
+
const selectedItem = screen.getByText('Apple');
|
|
22
|
+
fireEvent.click(selectedItem);
|
|
23
|
+
|
|
24
|
+
// Dropdown should show 'Banana'
|
|
25
|
+
expect(screen.getByText('Banana')).toBeInTheDocument();
|
|
26
|
+
expect(screen.getByText('Cherry')).toBeInTheDocument();
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('fires onChange callback when option is clicked', () => {
|
|
30
|
+
const handleChange = vi.fn();
|
|
31
|
+
render(<CustomSelect value="apple" options={options} onChange={handleChange} />);
|
|
32
|
+
|
|
33
|
+
// Open dropdown
|
|
34
|
+
fireEvent.click(screen.getByText('Apple'));
|
|
35
|
+
|
|
36
|
+
// Click 'Cherry'
|
|
37
|
+
fireEvent.click(screen.getByText('Cherry'));
|
|
38
|
+
|
|
39
|
+
expect(handleChange).toHaveBeenCalledWith('cherry');
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('does not open if disabled is true', () => {
|
|
43
|
+
render(<CustomSelect value="apple" options={options} onChange={() => {}} disabled />);
|
|
44
|
+
const container = screen.getByText('Apple').closest('div')?.parentElement;
|
|
45
|
+
expect(container).toHaveClass('opacity-50 pointer-events-none');
|
|
46
|
+
});
|
|
47
|
+
});
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import React, { useState, useRef, useEffect } from 'react';
|
|
2
|
+
import { motion, AnimatePresence } from 'framer-motion';
|
|
3
|
+
import { ChevronDown, Check } from 'lucide-react';
|
|
4
|
+
|
|
5
|
+
interface Option {
|
|
6
|
+
value: string;
|
|
7
|
+
label: React.ReactNode;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
interface CustomSelectProps {
|
|
11
|
+
value: string;
|
|
12
|
+
options: Option[];
|
|
13
|
+
onChange: (val: string) => void;
|
|
14
|
+
label?: string;
|
|
15
|
+
disabled?: boolean;
|
|
16
|
+
variant?: 'default' | 'ghost';
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function CustomSelect({ value, options, onChange, label, disabled, variant = 'default' }: CustomSelectProps) {
|
|
20
|
+
const [open, setOpen] = useState(false);
|
|
21
|
+
const ref = useRef<HTMLDivElement>(null);
|
|
22
|
+
useEffect(() => {
|
|
23
|
+
function handleClickOutside(event: MouseEvent) {
|
|
24
|
+
if (ref.current && !ref.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
|
+
const selectedOption = options.find(o => o.value === value) || options[0];
|
|
33
|
+
|
|
34
|
+
return (
|
|
35
|
+
<div className={`flex flex-col gap-2 relative ${disabled ? 'opacity-50 pointer-events-none' : ''}`} ref={ref}>
|
|
36
|
+
{label && <label className="text-[14px] font-medium text-foreground/80">{label}</label>}
|
|
37
|
+
|
|
38
|
+
<div
|
|
39
|
+
onClick={() => {
|
|
40
|
+
if (options.length > 1) setOpen(!open);
|
|
41
|
+
}}
|
|
42
|
+
className={`w-full rounded-xl text-[14px] font-medium text-foreground px-4 transition-all flex items-center justify-between ${
|
|
43
|
+
options.length <= 1 ? 'cursor-default opacity-80' : 'cursor-pointer'
|
|
44
|
+
} ${
|
|
45
|
+
variant === 'ghost'
|
|
46
|
+
? 'h-10 hover:bg-foreground/5 bg-transparent'
|
|
47
|
+
: 'h-12 bg-foreground/[0.02] border border-[var(--panel-border)] hover:border-foreground/30 glass-panel shadow-sm'
|
|
48
|
+
}`}
|
|
49
|
+
>
|
|
50
|
+
<span className="truncate">{selectedOption?.label}</span>
|
|
51
|
+
{options.length > 1 && (
|
|
52
|
+
<ChevronDown className={`w-4 h-4 text-foreground/50 transition-transform duration-300 ${open ? 'rotate-180' : ''}`} />
|
|
53
|
+
)}
|
|
54
|
+
</div>
|
|
55
|
+
|
|
56
|
+
<AnimatePresence>
|
|
57
|
+
{open && (
|
|
58
|
+
<motion.div
|
|
59
|
+
initial={{ opacity: 0, y: 5, scale: 0.98 }}
|
|
60
|
+
animate={{ opacity: 1, y: 0, scale: 1 }}
|
|
61
|
+
exit={{ opacity: 0, y: 5, scale: 0.98 }}
|
|
62
|
+
transition={{ duration: 0.15 }}
|
|
63
|
+
className="absolute z-[99999] top-[calc(100%+8px)] left-0 w-full border border-[var(--panel-border)] bg-[var(--bg-color)] rounded-xl shadow-[0_15px_40px_rgba(0,0,0,0.5)] overflow-hidden ring-1 ring-[var(--panel-border)]"
|
|
64
|
+
>
|
|
65
|
+
<div className="max-h-60 overflow-y-auto no-scrollbar p-1">
|
|
66
|
+
{options.map((opt) => (
|
|
67
|
+
<div
|
|
68
|
+
key={opt.value}
|
|
69
|
+
onClick={() => {
|
|
70
|
+
onChange(opt.value);
|
|
71
|
+
setOpen(false);
|
|
72
|
+
}}
|
|
73
|
+
className={`flex items-center justify-between px-3 py-2.5 rounded-lg cursor-pointer transition-colors text-[13px] font-medium ${
|
|
74
|
+
value === opt.value
|
|
75
|
+
? 'bg-accent/10 text-accent'
|
|
76
|
+
: 'text-foreground/80 hover:bg-foreground/5 hover:text-foreground'
|
|
77
|
+
}`}
|
|
78
|
+
>
|
|
79
|
+
<span className="truncate">{opt.label}</span>
|
|
80
|
+
{value === opt.value && <Check className="w-3.5 h-3.5 shrink-0" />}
|
|
81
|
+
</div>
|
|
82
|
+
))}
|
|
83
|
+
</div>
|
|
84
|
+
</motion.div>
|
|
85
|
+
)}
|
|
86
|
+
</AnimatePresence>
|
|
87
|
+
</div>
|
|
88
|
+
);
|
|
89
|
+
}
|
|
@@ -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 { DashboardNav } from './DashboardNav';
|
|
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('DashboardNav 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
|
+
<DashboardNav />
|
|
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
|
+
});
|