diff --git a/apps/admin/package.json b/apps/admin/package.json index 594f134..0512340 100755 --- a/apps/admin/package.json +++ b/apps/admin/package.json @@ -12,7 +12,6 @@ "dependencies": { "@emoji-mart/data": "^1.2.1", "@emoji-mart/react": "^1.1.1", - "emoji-mart": "^5.6.0", "@radix-ui/react-avatar": "^1.0.4", "@radix-ui/react-dialog": "^1.0.5", "@radix-ui/react-dropdown-menu": "^2.0.6", @@ -28,6 +27,7 @@ "class-variance-authority": "^0.7.0", "clsx": "^2.1.0", "date-fns": "^3.2.0", + "emoji-mart": "^5.6.0", "lucide-react": "^0.309.0", "openai": "^4.24.0", "react": "^18.2.0", diff --git a/apps/admin/src/App.tsx b/apps/admin/src/App.tsx index 4760795..a6d195d 100755 --- a/apps/admin/src/App.tsx +++ b/apps/admin/src/App.tsx @@ -16,6 +16,7 @@ import { RedemptionCodesPage } from '@/pages/RedemptionCodesPage'; import { FeedbackPage } from '@/pages/FeedbackPage'; import { UsageInsightsPage } from '@/pages/UsageInsightsPage'; import { AiSettingsPage } from '@/pages/AiSettingsPage'; +import { AppSettingsPage } from '@/pages/AppSettingsPage'; function App() { return ( @@ -137,6 +138,14 @@ function App() { } /> + + + + } + /> {/* Catch all - redirect to dashboard */} diff --git a/apps/admin/src/components/layout/AppLayout.tsx b/apps/admin/src/components/layout/AppLayout.tsx index 729b43f..09287e8 100644 --- a/apps/admin/src/components/layout/AppLayout.tsx +++ b/apps/admin/src/components/layout/AppLayout.tsx @@ -1,23 +1,40 @@ +import { useState } from 'react'; import { Outlet } from 'react-router-dom'; +import { Menu } from 'lucide-react'; import { Sidebar } from './Sidebar'; import { Breadcrumbs } from './Breadcrumbs'; import { Toaster } from 'sonner'; +import { Button } from '@/components/ui/button'; export function AppLayout() { + const [mobileMenuOpen, setMobileMenuOpen] = useState(false); + return (
{/* Subtle gradient overlay */}
- + setMobileMenuOpen(false)} + />
{/* Header */} -
+
+ {/* Mobile menu button - visible below xl breakpoint */} +
{/* Main content */} -
+
diff --git a/apps/admin/src/components/layout/Sidebar.tsx b/apps/admin/src/components/layout/Sidebar.tsx index edda53d..9fdad7f 100644 --- a/apps/admin/src/components/layout/Sidebar.tsx +++ b/apps/admin/src/components/layout/Sidebar.tsx @@ -13,11 +13,13 @@ import { MessageSquareText, Target, Bot, + Smartphone, + X, } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Separator } from '@/components/ui/separator'; import { Avatar, AvatarFallback } from '@/components/ui/avatar'; -import { useState } from 'react'; +import { useState, useEffect } from 'react'; import logoImage from '@/assets/logo.png'; interface NavItem { @@ -70,6 +72,12 @@ const systemNav: NavItem[] = [ icon: , requiredPermission: PERMISSION_KEYS.MANAGE_AI_CONFIG, }, + { + label: 'App Settings', + href: '/app-settings', + icon: , + requiredPermission: PERMISSION_KEYS.MANAGE_APP_CONFIG, + }, { label: 'Redemption Codes', href: '/redemption-codes', @@ -84,11 +92,23 @@ const systemNav: NavItem[] = [ }, ]; -export function Sidebar() { +interface SidebarProps { + mobileOpen?: boolean; + onMobileClose?: () => void; +} + +export function Sidebar({ mobileOpen, onMobileClose }: SidebarProps) { const location = useLocation(); const { user, permissions, logout, hasPermission } = useAuth(); const [collapsed, setCollapsed] = useState(false); + // Close mobile sidebar on route change + useEffect(() => { + if (mobileOpen && onMobileClose) { + onMobileClose(); + } + }, [location.pathname]); + const NavLink = ({ item }: { item: NavItem }) => { if (item.requiredPermission && !hasPermission(item.requiredPermission)) return null; @@ -102,7 +122,7 @@ export function Sidebar() { isActive ? "bg-gradient-rose text-white shadow-lg glow-rose-sm" : "text-muted-foreground hover:bg-white/5 hover:text-foreground", - collapsed && "justify-center px-2" + collapsed && "justify-center px-2 lg:justify-center lg:px-2" )} >
); + + // Mobile sidebar (overlay) + const mobileSidebar = ( + <> + {/* Backdrop */} +
+ + {/* Slide-in sidebar */} +
+
+ {/* Mobile header with close button */} +
+ +
+ Sauci +
+
+ + Sauci + + + Admin Portal + +
+ + +
+ + {/* Navigation */} +
+ + + +
+ + + + {/* User info */} +
+
+ + + {user?.email?.charAt(0).toUpperCase() || 'A'} + + +
+

+ {user?.email} +

+

+ {permissions?.role?.replace('_', ' ')} +

+
+
+ +
+
+
+ + ); + + return ( + <> + {sidebarContent} + {mobileSidebar} + + ); } diff --git a/apps/admin/src/components/ui/emoji-picker.tsx b/apps/admin/src/components/ui/emoji-picker.tsx index 7856130..72046ef 100644 --- a/apps/admin/src/components/ui/emoji-picker.tsx +++ b/apps/admin/src/components/ui/emoji-picker.tsx @@ -30,7 +30,7 @@ export function EmojiPicker({ value, onChange }: EmojiPickerProps) { }; }, [open]); - const handleEmojiSelect = (emoji: any) => { + const handleEmojiSelect = (emoji: { native: string; id: string; name: string }) => { onChange(emoji.native); setOpen(false); }; diff --git a/apps/admin/src/contexts/AuthContext.tsx b/apps/admin/src/contexts/AuthContext.tsx index 33fc322..b88ed78 100644 --- a/apps/admin/src/contexts/AuthContext.tsx +++ b/apps/admin/src/contexts/AuthContext.tsx @@ -22,6 +22,7 @@ export const PERMISSION_KEYS = { MANAGE_CODES: 'manage_codes', MANAGE_ADMINS: 'manage_admins', MANAGE_AI_CONFIG: 'manage_ai_config', + MANAGE_APP_CONFIG: 'manage_app_config', VIEW_AUDIT_LOGS: 'view_audit_logs', } as const; @@ -40,6 +41,7 @@ export const PERMISSION_METADATA: Record Promise; + updateConfig: (updates: Partial) => Promise<{ error: string | null }>; +} + +// Cache for the config to avoid refetching on every use +let cachedConfig: AppConfig | null = null; +let cacheTimestamp: number = 0; +const CACHE_TTL = 5 * 60 * 1000; // 5 minutes + +export function useAppConfig(): UseAppConfigReturn { + const [config, setConfig] = useState(cachedConfig); + const [loading, setLoading] = useState(!cachedConfig); + const [error, setError] = useState(null); + + const fetchConfig = useCallback(async () => { + // Use cache if valid + if (cachedConfig && Date.now() - cacheTimestamp < CACHE_TTL) { + setConfig(cachedConfig); + setLoading(false); + return; + } + + setLoading(true); + setError(null); + + try { + const { data, error: fetchError } = await supabase + .from('app_config') + .select('*') + .limit(1) + .maybeSingle(); + + if (fetchError) { + setError(fetchError.message); + setConfig(null); + } else if (data) { + cachedConfig = data as AppConfig; + cacheTimestamp = Date.now(); + setConfig(data as AppConfig); + } else { + // No config row exists yet + setConfig(null); + setError('App config not initialized. Please contact support.'); + } + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to fetch app config'); + } finally { + setLoading(false); + } + }, []); + + const updateConfig = useCallback(async (updates: Partial): Promise<{ error: string | null }> => { + if (!config?.id) { + return { error: 'No config to update' }; + } + + try { + const { error: updateError } = await supabase + .from('app_config') + .update(updates) + .eq('id', config.id); + + if (updateError) { + return { error: updateError.message }; + } + + // Invalidate cache and refetch + cachedConfig = null; + cacheTimestamp = 0; + await fetchConfig(); + + return { error: null }; + } catch (err) { + return { error: err instanceof Error ? err.message : 'Failed to update app config' }; + } + }, [config?.id, fetchConfig]); + + useEffect(() => { + fetchConfig(); + }, [fetchConfig]); + + return { + config, + loading, + error, + refetch: fetchConfig, + updateConfig, + }; +} + +/** + * Clear the config cache (useful when logging out) + */ +export function clearAppConfigCache(): void { + cachedConfig = null; + cacheTimestamp = 0; +} diff --git a/apps/admin/src/pages/AppSettingsPage.tsx b/apps/admin/src/pages/AppSettingsPage.tsx new file mode 100644 index 0000000..f0ac2be --- /dev/null +++ b/apps/admin/src/pages/AppSettingsPage.tsx @@ -0,0 +1,181 @@ +import { useState, useEffect } from 'react'; +import { useAppConfig, AppConfig } from '@/hooks/useAppConfig'; +import { useAuth, PERMISSION_KEYS } from '@/contexts/AuthContext'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Skeleton } from '@/components/ui/skeleton'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; +import { Save, AlertTriangle, RefreshCw, Users } from 'lucide-react'; +import { toast } from 'sonner'; +import { format } from 'date-fns'; + +export function AppSettingsPage() { + const { hasPermission, isSuperAdmin } = useAuth(); + const { config, loading, error, updateConfig, refetch } = useAppConfig(); + + // Local form state + const [formData, setFormData] = useState>({}); + const [isSaving, setIsSaving] = useState(false); + const [hasChanges, setHasChanges] = useState(false); + + // Permission check - only super admins or those with MANAGE_APP_CONFIG can access + const canManage = isSuperAdmin || hasPermission(PERMISSION_KEYS.MANAGE_APP_CONFIG); + + // Initialize form data when config loads + useEffect(() => { + if (config) { + setFormData({ + answer_gap_threshold: config.answer_gap_threshold ?? 10, + }); + setHasChanges(false); + } + }, [config]); + + const handleChange = (field: K, value: AppConfig[K]) => { + setFormData(prev => ({ ...prev, [field]: value })); + setHasChanges(true); + }; + + const handleSave = async () => { + setIsSaving(true); + try { + const { error: saveError } = await updateConfig(formData); + if (saveError) { + toast.error(saveError); + } else { + toast.success('App settings saved successfully'); + setHasChanges(false); + } + } catch { + toast.error('Failed to save settings'); + } finally { + setIsSaving(false); + } + }; + + const handleReset = () => { + if (config) { + setFormData({ + answer_gap_threshold: config.answer_gap_threshold ?? 10, + }); + setHasChanges(false); + } + }; + + if (!canManage) { + return ( +
+ + + Access Denied + + You don't have permission to manage app settings. Contact a super admin for access. + + +
+ ); + } + + if (loading) { + return ( +
+
+

App Settings

+

Configure mobile app behavior settings

+
+
+ +
+
+ ); + } + + if (error) { + return ( +
+
+

App Settings

+

Configure mobile app behavior settings

+
+ + + Error loading settings + + {error} + + + +
+ ); + } + + return ( +
+ {/* Header */} +
+
+

App Settings

+

+ Configure mobile app behavior settings +

+
+
+ {hasChanges && ( + + )} + +
+
+ + {config?.updated_at && ( +

+ Last updated: {format(new Date(config.updated_at), 'MMM d, yyyy HH:mm')} +

+ )} + + {/* Answer Spam Prevention */} + + + + + Answer Spam Prevention + + + Prevent one partner from getting too far ahead in answering questions + + + +
+ + handleChange('answer_gap_threshold', parseInt(e.target.value) || 0)} + className="max-w-[200px]" + /> +

+ Maximum number of questions a user can answer ahead of their partner. + When exceeded, they'll see a "Waiting for your partner" message. +

+

+ Set to 0 to disable this feature. +

+
+
+
+
+ ); +} diff --git a/apps/admin/src/pages/DashboardPage.tsx b/apps/admin/src/pages/DashboardPage.tsx index 660c061..e4a6855 100644 --- a/apps/admin/src/pages/DashboardPage.tsx +++ b/apps/admin/src/pages/DashboardPage.tsx @@ -165,13 +165,13 @@ export function DashboardPage() { ]; return ( -
+
{/* Header */}
-
+
-
-

+
+

Dashboard

-

+

Welcome back. Here's what's happening with Sauci.

diff --git a/apps/admin/src/pages/content/CategoriesPage.tsx b/apps/admin/src/pages/content/CategoriesPage.tsx index 0450be6..d09b140 100644 --- a/apps/admin/src/pages/content/CategoriesPage.tsx +++ b/apps/admin/src/pages/content/CategoriesPage.tsx @@ -224,12 +224,12 @@ export function CategoriesPage() { } return ( -
+
{/* Header */} -
+
-

Categories

-

+

Categories

+

Organize your question packs into categories

@@ -238,7 +238,8 @@ export function CategoriesPage() {