firebase-os 1.1.3 → 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.
Files changed (124) hide show
  1. package/dist/FirebaseOS.d.ts +15 -0
  2. package/dist/firebase-os.cjs.js +2 -17
  3. package/dist/firebase-os.es.js +63 -72
  4. package/dist/index.d.ts +3 -0
  5. package/dist/lib/ConfigContext.d.ts +12 -0
  6. package/package.json +3 -2
  7. package/scripts/postinstall.js +89 -10
  8. package/src/App.css +184 -0
  9. package/src/App.tsx +214 -0
  10. package/src/FirebaseOS.tsx +80 -0
  11. package/src/assets/hero.png +0 -0
  12. package/src/assets/react.svg +1 -0
  13. package/src/assets/vite.svg +1 -0
  14. package/src/components/AdminNotifications.test.tsx +98 -0
  15. package/src/components/AdminNotifications.tsx +194 -0
  16. package/src/components/Button.test.tsx +22 -0
  17. package/src/components/Button.tsx +53 -0
  18. package/src/components/ConfirmModal.test.tsx +98 -0
  19. package/src/components/ConfirmModal.tsx +73 -0
  20. package/src/components/ContactPopup.test.tsx +98 -0
  21. package/src/components/ContactPopup.tsx +437 -0
  22. package/src/components/CustomSelect.test.tsx +47 -0
  23. package/src/components/CustomSelect.tsx +89 -0
  24. package/src/components/DashboardNav.test.tsx +98 -0
  25. package/src/components/DashboardNav.tsx +281 -0
  26. package/src/components/Input.test.tsx +33 -0
  27. package/src/components/Input.tsx +61 -0
  28. package/src/components/JsonEditor.tsx +579 -0
  29. package/src/components/Navbar.test.tsx +98 -0
  30. package/src/components/Navbar.tsx +563 -0
  31. package/src/configs/forms/contactForm.config.ts +15 -0
  32. package/src/configs/forms/index.ts +29 -0
  33. package/src/configs/forms/pubForm.config.ts +11 -0
  34. package/src/configs/forms/supportForm.config.ts +14 -0
  35. package/src/configs/forms/userForm.config.ts +11 -0
  36. package/src/configs/pages/admin.config.ts +29 -0
  37. package/src/configs/pages/contact.config.ts +6 -0
  38. package/src/configs/pages/home.config.ts +18 -0
  39. package/src/configs/pages/mem.config.ts +2 -0
  40. package/src/configs/pages/menuOrders.config.ts +11 -0
  41. package/src/configs/pages/pub.config.ts +11 -0
  42. package/src/configs/pages/shared.config.ts +29 -0
  43. package/src/configs/pages/support.config.ts +7 -0
  44. package/src/configs/pages/tabOrders.config.ts +33 -0
  45. package/src/configs/pages/user.config.ts +29 -0
  46. package/src/configs/theme.config.ts +93 -0
  47. package/src/index.css +403 -0
  48. package/src/index.ts +22 -0
  49. package/src/lib/AuthContext.test.tsx +88 -0
  50. package/src/lib/AuthContext.tsx +191 -0
  51. package/src/lib/ConfigContext.tsx +45 -0
  52. package/src/lib/ThemeContext.tsx +227 -0
  53. package/src/lib/firebase.ts +91 -0
  54. package/src/main.tsx +22 -0
  55. package/src/microcomponents/AdminExampleContent.tsx +44 -0
  56. package/src/microcomponents/PrivateExampleContent.tsx +39 -0
  57. package/src/microcomponents/Public.tsx +126 -0
  58. package/src/microcomponents/SharedExampleContent.tsx +53 -0
  59. package/src/pages/Dashboard.test.tsx +98 -0
  60. package/src/pages/Dashboard.tsx +60 -0
  61. package/src/pages/DynamicPage.tsx +237 -0
  62. package/src/pages/FormsAdmin.test.tsx +98 -0
  63. package/src/pages/FormsAdmin.tsx +459 -0
  64. package/src/pages/Home.test.tsx +98 -0
  65. package/src/pages/Home.tsx +144 -0
  66. package/src/pages/Login.test.tsx +98 -0
  67. package/src/pages/Login.tsx +108 -0
  68. package/src/pages/PagesAdmin.test.tsx +98 -0
  69. package/src/pages/PagesAdmin.tsx +1022 -0
  70. package/src/pages/Profile.test.tsx +98 -0
  71. package/src/pages/Profile.tsx +319 -0
  72. package/src/pages/Register.test.tsx +98 -0
  73. package/src/pages/Register.tsx +116 -0
  74. package/src/pages/Requests.test.tsx +95 -0
  75. package/src/pages/Requests.tsx +422 -0
  76. package/src/pages/ResetPassword.test.tsx +98 -0
  77. package/src/pages/ResetPassword.tsx +92 -0
  78. package/src/pages/Settings.test.tsx +98 -0
  79. package/src/pages/Settings.tsx +393 -0
  80. package/src/pages/Setup.tsx +401 -0
  81. package/src/pages/StorageAdmin.test.tsx +150 -0
  82. package/src/pages/StorageAdmin.tsx +769 -0
  83. package/src/pages/Submissions.test.tsx +95 -0
  84. package/src/pages/Submissions.tsx +372 -0
  85. package/src/pages/Templates.test.tsx +98 -0
  86. package/src/pages/Templates.tsx +103 -0
  87. package/src/pages/ThemeAdmin.test.tsx +144 -0
  88. package/src/pages/ThemeAdmin.tsx +1000 -0
  89. package/src/pages/Users.test.tsx +95 -0
  90. package/src/pages/Users.tsx +334 -0
  91. package/src/pages/Verify.test.tsx +98 -0
  92. package/src/pages/Verify.tsx +95 -0
  93. package/src/prompts/index.ts +13 -0
  94. package/src/prompts/pages/publicPage.ts +44 -0
  95. package/src/prompts/sharedConstants.ts +12 -0
  96. package/src/prompts/tabs/board/adminboard.ts +32 -0
  97. package/src/prompts/tabs/board/privateboard.ts +36 -0
  98. package/src/prompts/tabs/board/publicboard.ts +36 -0
  99. package/src/prompts/tabs/calendar/admincalendar.ts +32 -0
  100. package/src/prompts/tabs/calendar/privatecalendar.ts +36 -0
  101. package/src/prompts/tabs/calendar/publiccalendar.ts +36 -0
  102. package/src/prompts/tabs/crud/admin.ts +54 -0
  103. package/src/prompts/tabs/crud/private.ts +55 -0
  104. package/src/prompts/tabs/crud/shared.ts +53 -0
  105. package/src/prompts/tabs/table/admintable.ts +32 -0
  106. package/src/prompts/tabs/table/privatetable.ts +36 -0
  107. package/src/prompts/tabs/table/publictable.ts +36 -0
  108. package/src/setupTests.ts +1 -0
  109. package/src/templates/AdminPageTemplate.tsx +678 -0
  110. package/src/templates/PrivatePageTemplate.tsx +594 -0
  111. package/src/templates/PublicPageTemplate.tsx +92 -0
  112. package/src/templates/SharedPageTemplate.tsx +551 -0
  113. package/src/templates/TemplateBoard.test.tsx +106 -0
  114. package/src/templates/TemplateBoard.tsx +642 -0
  115. package/src/templates/TemplateCalendar.test.tsx +106 -0
  116. package/src/templates/TemplateCalendar.tsx +848 -0
  117. package/src/templates/TemplateConfirmation.test.tsx +106 -0
  118. package/src/templates/TemplateConfirmation.tsx +145 -0
  119. package/src/templates/TemplateInlineForm.test.tsx +106 -0
  120. package/src/templates/TemplateInlineForm.tsx +129 -0
  121. package/src/templates/TemplatePopupForm.test.tsx +106 -0
  122. package/src/templates/TemplatePopupForm.tsx +174 -0
  123. package/src/templates/TemplateTable.test.tsx +106 -0
  124. 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
+ });