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"
)}
>
{/* Logo */}
@@ -236,4 +258,101 @@ export function Sidebar() {
);
+
+ // Mobile sidebar (overlay)
+ const mobileSidebar = (
+ <>
+ {/* Backdrop */}
+
+
+ {/* Slide-in sidebar */}
+
+
+ {/* Mobile header with close button */}
+
+
+
+

+
+
+
+ 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() {