Compare commits

...

2 Commits

Author SHA1 Message Date
Gitea Actions Bot 617f292516 chore: bump version to 1.0.67 (backend + frontend)
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2025-07-18 20:35:03 +00:00
paul 8e95004022 feat: fix analytics dashboard and implement complete Umami integration
Mirror to GitHub / mirror (push) Successful in 38s
Test and Lint / backend-test (push) Successful in 1m21s
continuous-integration/drone/push Build is passing
Test and Lint / frontend-test (push) Successful in 2m24s
Version and Release / version-bump (push) Successful in 1m1s
Version and Release / trigger-drone (push) Successful in 4s
- Fix backend analytics to include both 'download' and 'download_all' actions
- Add Analytics tab to Settings page for Umami configuration
- Update public settings endpoint to expose Umami config when enabled
- Implement dynamic Umami initialization from backend settings
- Fix frontend analytics calculations (remove hardcoded estimations)
- Add proper download counts and unique visitor tracking
- Update CLAUDE.md with production safety guidelines

The analytics dashboard now shows accurate data for all metrics, and Umami
can be configured through the admin panel instead of environment variables.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-18 22:29:09 +02:00
11 changed files with 360 additions and 50 deletions
+68 -1
View File
@@ -39,6 +39,12 @@ docker-compose -f docker-compose.prod.yml up -d # Production deployment
pm2 start ecosystem.config.js # Alternative: PM2 deployment
```
**⚠️ CRITICAL PRODUCTION NOTICE:**
- Production runs on a SEPARATE SERVER - never assume local changes affect production
- ALWAYS request production server details before any troubleshooting
- NO trial-and-error approaches in production - data loss is unacceptable
- Every change must be thoroughly analyzed and tested locally first
## Key Product Requirements (from PRD)
### Core Features
@@ -127,6 +133,41 @@ Background services run as separate processes:
5. **Frontend Status**: Only skeleton exists - requires full implementation based on PRD
6. **Umami Analytics**: Track password entries, downloads, views, expiration warnings
## Troubleshooting Guidelines
### Before ANY Production Troubleshooting:
1. **ALWAYS request specific details**:
- Production server URL/IP
- Current error messages/logs
- Recent changes or deployments
- Affected users/galleries
- Time of issue occurrence
2. **Thorough Analysis Required**:
- Use detailed thinking/analysis for EVERY troubleshooting task
- Review all related code before suggesting changes
- Consider all potential side effects
- Never make assumptions about production environment
3. **Safe Troubleshooting Steps**:
- First, reproduce issue in local/dev environment
- Analyze logs without modifying production
- Create detailed action plan before any changes
- Always have rollback strategy ready
- Document every step taken
### Common Issues & Safe Approaches:
- **Email not sending**: Check email_queue table, SMTP settings, service status
- **Photos not loading**: Verify file permissions, storage paths, nginx config
- **Gallery access issues**: Check JWT tokens, expiration dates, access_logs
- **Performance problems**: Analyze with monitoring tools first, never experiment
### Data Safety Rules:
- NEVER delete or modify production data without explicit backup confirmation
- ALWAYS verify backups exist before any data operations
- NO direct database modifications without transaction safety
- Log all actions for audit trail
## Environment Variables
### Backend (.env)
@@ -258,4 +299,30 @@ const { theme, setTheme, setThemeByName } = useTheme();
- Guest satisfaction: >90%
- System uptime: 99.9%
- Email delivery rate: >98%
- Successful archiving: 100%
- Successful archiving: 100%
## Documentation & Development Practices
### Documentation Guidelines:
- **NEVER create new documentation files for simple tasks**
- **ALWAYS update existing documentation (like this CLAUDE.md)**
- Only create new .md files when explicitly requested
- Avoid creating temporary scripts for one-off tasks
### Development Best Practices:
- Test all changes thoroughly in local environment first
- Use version control for all changes
- Keep commits atomic and well-described
- Review impact on all integrated services
- Consider backward compatibility
- Update tests when changing functionality
### Production Deployment Checklist:
- [ ] All tests passing locally
- [ ] Linting and type checks pass
- [ ] Database migrations tested with rollback plan
- [ ] Environment variables documented
- [ ] Backup strategy confirmed
- [ ] Monitoring alerts configured
- [ ] Rollback procedure documented
- [ ] Stakeholders notified of maintenance window
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "picpeak-backend",
"version": "1.0.66",
"version": "1.0.67",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "picpeak-backend",
"version": "1.0.66",
"version": "1.0.67",
"dependencies": {
"adm-zip": "^0.5.16",
"archiver": "^5.3.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "picpeak-backend",
"version": "1.0.66",
"version": "1.0.67",
"description": "Backend for PicPeak event photo sharing platform",
"main": "server.js",
"scripts": {
+11 -10
View File
@@ -48,9 +48,9 @@ router.get('/stats', adminAuth, async (req, res) => {
.count('id as count')
.first();
// Get total downloads (last 30 days)
// Get total downloads (last 30 days) - include both single and bulk downloads
const totalDownloads = await db('access_logs')
.where('action', 'download')
.whereIn('action', ['download', 'download_all'])
.where('timestamp', '>=', thirtyDaysAgo.toISOString())
.count('id as count')
.first();
@@ -73,7 +73,7 @@ router.get('/stats', adminAuth, async (req, res) => {
.first();
const previousDownloads = await db('access_logs')
.where('action', 'download')
.whereIn('action', ['download', 'download_all'])
.where('timestamp', '>=', sixtyDaysAgo.toISOString())
.where('timestamp', '<', thirtyDaysAgo.toISOString())
.count('id as count')
@@ -243,10 +243,10 @@ router.get('/analytics', adminAuth, async (req, res) => {
.where('timestamp', '>=', startDateStr)
.groupByRaw('DATE(timestamp)');
// Get downloads per day
// Get downloads per day - include both single and bulk downloads
const downloadsData = await db('access_logs')
.select(db.raw('DATE(timestamp) as date'), db.raw('COUNT(*) as count'))
.where('action', 'download')
.whereIn('action', ['download', 'download_all'])
.where('timestamp', '>=', startDateStr)
.groupByRaw('DATE(timestamp)');
@@ -272,14 +272,15 @@ router.get('/analytics', adminAuth, async (req, res) => {
if (dateObj) dateObj.uniqueVisitors = row.count;
});
// Get top galleries by views
// Get top galleries by views with additional metrics
const topGalleries = await db('access_logs')
.select('events.event_name', 'events.slug')
.select(db.raw('COUNT(*) as views'))
.select('events.id', 'events.event_name', 'events.slug')
.select(db.raw('COUNT(CASE WHEN action = \'view\' THEN 1 END) as views'))
.select(db.raw('COUNT(DISTINCT CASE WHEN action = \'view\' THEN ip_address END) as uniqueVisitors'))
.select(db.raw('COUNT(CASE WHEN action IN (\'download\', \'download_all\') THEN 1 END) as downloads'))
.join('events', 'access_logs.event_id', 'events.id')
.where('access_logs.action', 'view')
.where('access_logs.timestamp', '>=', startDateStr)
.groupBy('events.id')
.groupBy('events.id', 'events.event_name', 'events.slug')
.orderBy('views', 'desc')
.limit(5);
+8 -3
View File
@@ -5,9 +5,9 @@ const router = express.Router();
// Get public settings (branding and theme)
router.get('/', async (req, res) => {
try {
// Fetch branding, theme, general, and select security settings
// Fetch branding, theme, general, security, and analytics settings
const settings = await db('app_settings')
.whereIn('setting_type', ['branding', 'theme', 'general', 'security'])
.whereIn('setting_type', ['branding', 'theme', 'general', 'security', 'analytics'])
.select('setting_key', 'setting_value');
// Convert to object format
@@ -41,7 +41,12 @@ router.get('/', async (req, res) => {
enable_analytics: settingsObject.general_enable_analytics !== false,
enable_recaptcha: settingsObject.security_enable_recaptcha === true || settingsObject.security_enable_recaptcha === 'true',
recaptcha_site_key: settingsObject.security_recaptcha_site_key || null,
maintenance_mode: settingsObject.general_maintenance_mode === true || settingsObject.general_maintenance_mode === 'true'
maintenance_mode: settingsObject.general_maintenance_mode === true || settingsObject.general_maintenance_mode === 'true',
// Umami analytics configuration (only if enabled)
umami_enabled: settingsObject.analytics_umami_enabled === true || settingsObject.analytics_umami_enabled === 'true',
umami_url: settingsObject.analytics_umami_enabled ? (settingsObject.analytics_umami_url || null) : null,
umami_website_id: settingsObject.analytics_umami_enabled ? (settingsObject.analytics_umami_website_id || null) : null,
umami_share_url: settingsObject.analytics_umami_enabled ? (settingsObject.analytics_umami_share_url || null) : null
};
res.json(publicSettings);
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "picpeak-frontend",
"version": "1.0.65",
"version": "1.0.67",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "picpeak-frontend",
"version": "1.0.65",
"version": "1.0.67",
"dependencies": {
"@tanstack/react-query": "^5.0.0",
"@tiptap/extension-character-count": "^2.26.1",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "picpeak-frontend",
"private": true,
"version": "1.0.65",
"version": "1.0.67",
"type": "module",
"scripts": {
"dev": "vite",
+27 -13
View File
@@ -44,17 +44,26 @@ function App() {
// Initialize Umami Analytics based on settings
useEffect(() => {
const initializeAnalytics = async () => {
const umamiUrl = import.meta.env.VITE_UMAMI_URL;
const umamiWebsiteId = import.meta.env.VITE_UMAMI_WEBSITE_ID;
if (umamiUrl && umamiWebsiteId) {
try {
// Fetch public settings to check if analytics is enabled
const response = await fetch(`${getApiBaseUrl()}/public/settings`);
const settings = await response.json();
try {
// Fetch public settings to get Umami configuration
const response = await fetch(`${getApiBaseUrl()}/public/settings`);
const settings = await response.json();
// Check if Umami is enabled and configured in backend settings
if (settings.umami_enabled && settings.umami_url && settings.umami_website_id) {
// Use backend configuration
analyticsService.initialize({
websiteId: settings.umami_website_id,
hostUrl: settings.umami_url,
autoTrack: true,
doNotTrack: true
});
} else {
// Fall back to environment variables if backend not configured
const umamiUrl = import.meta.env.VITE_UMAMI_URL;
const umamiWebsiteId = import.meta.env.VITE_UMAMI_WEBSITE_ID;
// Only initialize if analytics is enabled in settings
if (settings.enable_analytics !== false) {
if (umamiUrl && umamiWebsiteId && settings.enable_analytics !== false) {
analyticsService.initialize({
websiteId: umamiWebsiteId,
hostUrl: umamiUrl,
@@ -62,9 +71,14 @@ function App() {
doNotTrack: true
});
}
} catch (error) {
console.error('Failed to fetch settings for analytics:', error);
// Initialize analytics anyway if settings fetch fails
}
} catch (error) {
console.error('Failed to fetch settings for analytics:', error);
// Fall back to environment variables on error
const umamiUrl = import.meta.env.VITE_UMAMI_URL;
const umamiWebsiteId = import.meta.env.VITE_UMAMI_WEBSITE_ID;
if (umamiUrl && umamiWebsiteId) {
analyticsService.initialize({
websiteId: umamiWebsiteId,
hostUrl: umamiUrl,
+24
View File
@@ -494,6 +494,30 @@
"sent": "Sent",
"failed": "Failed",
"lastUpdate": "Last update"
},
"analytics": {
"title": "Analytics",
"umamiIntegration": "Umami Analytics Integration",
"enableUmami": "Enable Umami Analytics",
"umamiUrl": "Umami URL",
"umamiUrlHelp": "The URL of your Umami instance (e.g., https://analytics.yourdomain.com)",
"websiteId": "Website ID",
"websiteIdHelp": "Your Umami website ID (found in Umami dashboard)",
"shareUrl": "Share URL (Optional)",
"shareUrlHelp": "Public share URL for embedding the full dashboard (create in Umami)",
"umamiInfo": "About Umami Analytics",
"umamiInfoText": "Umami is a privacy-focused, open-source analytics platform. It tracks page views, unique visitors, and custom events without using cookies.",
"learnMore": "Learn more about Umami",
"saveAnalyticsSettings": "Save Analytics Settings",
"backendAnalytics": "Backend Analytics",
"backendAnalyticsText": "The system also tracks basic analytics server-side for security and performance monitoring.",
"tracked": "Tracked Metrics",
"galleryViews": "Gallery page views",
"photoDownloads": "Individual and bulk downloads",
"uniqueVisitors": "Unique visitors by IP",
"deviceTypes": "Device types from user agents",
"privacy": "Privacy",
"privacyText": "IP addresses are hashed for privacy. No personal data is stored. Analytics data is retained for 90 days."
}
},
"analytics": {
+49 -15
View File
@@ -1,4 +1,4 @@
import React, { useState } from 'react';
import React, { useState, useEffect } from 'react';
import {
BarChart3,
TrendingUp,
@@ -52,10 +52,8 @@ export const AnalyticsPage: React.FC = () => {
const [dateRange, setDateRange] = useState<'7d' | '30d' | '90d'>('7d');
const [isEmbedMode, setIsEmbedMode] = useState(false);
// Check if Umami is configured
const umamiUrl = import.meta.env.VITE_UMAMI_URL;
// const umamiWebsiteId = import.meta.env.VITE_UMAMI_WEBSITE_ID;
const umamiShareUrl = import.meta.env.VITE_UMAMI_SHARE_URL;
// Check if Umami is configured from settings or environment
const [umamiConfig, setUmamiConfig] = useState<{ url?: string; shareUrl?: string }>({});
// Fetch analytics data from backend
const { data: apiData, isLoading, refetch } = useQuery({
@@ -73,6 +71,38 @@ export const AnalyticsPage: React.FC = () => {
queryFn: () => adminService.getDashboardStats(),
});
// Fetch public settings to get Umami config
useEffect(() => {
const fetchUmamiConfig = async () => {
try {
const response = await fetch(`${import.meta.env.VITE_API_URL}/api/public/settings`);
const settings = await response.json();
if (settings.umami_enabled) {
setUmamiConfig({
url: settings.umami_url,
shareUrl: settings.umami_share_url
});
} else {
// Fall back to environment variables
setUmamiConfig({
url: import.meta.env.VITE_UMAMI_URL,
shareUrl: import.meta.env.VITE_UMAMI_SHARE_URL
});
}
} catch (error) {
console.error('Failed to fetch Umami config:', error);
// Fall back to environment variables
setUmamiConfig({
url: import.meta.env.VITE_UMAMI_URL,
shareUrl: import.meta.env.VITE_UMAMI_SHARE_URL
});
}
};
fetchUmamiConfig();
}, []);
// Calculate trends and format data
const analytics: ComponentAnalyticsData | undefined = React.useMemo(() => {
if (!apiData) return undefined;
@@ -96,11 +126,15 @@ export const AnalyticsPage: React.FC = () => {
const secondHalfDownloads = apiData.chartData.slice(halfPoint).reduce((sum, day) => sum + day.downloads, 0);
const downloadsTrend = firstHalfDownloads > 0 ? ((secondHalfDownloads - firstHalfDownloads) / firstHalfDownloads) * 100 : 0;
// Format top galleries for downloads
const topGalleriesWithDownloads = apiData.topGalleries.map(gallery => ({
name: gallery.event_name,
downloads: gallery.views // Using views as download count for now
}));
// Get actual download data for top galleries - sort by downloads
const topGalleriesWithDownloads = apiData.topGalleries
.filter(gallery => gallery.downloads > 0) // Only show galleries with downloads
.sort((a, b) => (b.downloads || 0) - (a.downloads || 0)) // Sort by downloads
.slice(0, 5) // Take top 5
.map(gallery => ({
name: gallery.event_name,
downloads: gallery.downloads || 0
}));
return {
pageViews: {
@@ -122,7 +156,7 @@ export const AnalyticsPage: React.FC = () => {
topPages: apiData.topGalleries.map(gallery => ({
path: `/gallery/${gallery.slug}`,
views: gallery.views,
uniqueVisitors: Math.round(gallery.views * 0.4) // Estimate unique visitors
uniqueVisitors: gallery.uniqueVisitors || gallery.views // Use actual unique visitors if available
}))
};
}, [apiData]);
@@ -166,7 +200,7 @@ export const AnalyticsPage: React.FC = () => {
}
// If Umami is configured and embed mode is enabled, show the Umami dashboard
if (isEmbedMode && umamiShareUrl) {
if (isEmbedMode && umamiConfig.shareUrl) {
return (
<div>
<div className="flex justify-between items-center mb-6">
@@ -185,7 +219,7 @@ export const AnalyticsPage: React.FC = () => {
<Card padding="none" className="overflow-hidden" style={{ height: '800px' }}>
<iframe
src={umamiShareUrl}
src={umamiConfig.shareUrl}
className="w-full h-full border-0"
title="Umami Analytics Dashboard"
/>
@@ -203,7 +237,7 @@ export const AnalyticsPage: React.FC = () => {
<p className="text-neutral-600 mt-1">{t('analytics.subtitle')}</p>
</div>
<div className="flex items-center gap-3">
{umamiShareUrl && (
{umamiConfig.shareUrl && (
<Button
variant="outline"
onClick={() => setIsEmbedMode(true)}
@@ -405,7 +439,7 @@ export const AnalyticsPage: React.FC = () => {
</div>
{/* Configuration Notice */}
{!umamiUrl && (
{!umamiConfig.url && (
<Card padding="md" className="mt-6 bg-amber-50 border-amber-200">
<div className="flex items-start gap-3">
<Activity className="w-5 h-5 text-amber-600 flex-shrink-0" />
+167 -2
View File
@@ -21,7 +21,7 @@ import { settingsService } from '../../services/settings.service';
import { useTranslation } from 'react-i18next';
export const SettingsPage: React.FC = () => {
const [activeTab, setActiveTab] = useState<'general' | 'status' | 'security' | 'categories'>('general');
const [activeTab, setActiveTab] = useState<'general' | 'status' | 'security' | 'categories' | 'analytics'>('general');
const queryClient = useQueryClient();
const { t, i18n } = useTranslation();
@@ -72,6 +72,14 @@ export const SettingsPage: React.FC = () => {
recaptcha_secret_key: ''
});
// Analytics settings state
const [analyticsSettings, setAnalyticsSettings] = useState({
umami_enabled: false,
umami_url: '',
umami_website_id: '',
umami_share_url: ''
});
React.useEffect(() => {
if (settings) {
// Set the language if it's different from current
@@ -104,8 +112,16 @@ export const SettingsPage: React.FC = () => {
recaptcha_site_key: settings.security_recaptcha_site_key || '',
recaptcha_secret_key: settings.security_recaptcha_secret_key || ''
});
// Extract analytics settings
setAnalyticsSettings({
umami_enabled: settings.analytics_umami_enabled || false,
umami_url: settings.analytics_umami_url || '',
umami_website_id: settings.analytics_umami_website_id || '',
umami_share_url: settings.analytics_umami_share_url || ''
});
}
}, [settings]);
}, [settings, i18n]);
// Save mutations
const saveGeneralMutation = useMutation({
@@ -144,6 +160,24 @@ export const SettingsPage: React.FC = () => {
}
});
const saveAnalyticsMutation = useMutation({
mutationFn: async () => {
// Convert to the format expected by the API
const settingsData: Record<string, any> = {};
Object.entries(analyticsSettings).forEach(([key, value]) => {
settingsData[`analytics_${key}`] = value;
});
return settingsService.updateSettings(settingsData);
},
onSuccess: () => {
toast.success(t('toast.settingsSaved'));
queryClient.invalidateQueries({ queryKey: ['admin-settings'] });
},
onError: () => {
toast.error(t('toast.saveError'));
}
});
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-[400px]">
@@ -202,6 +236,16 @@ export const SettingsPage: React.FC = () => {
>
{t('settings.categories.title')}
</button>
<button
onClick={() => setActiveTab('analytics')}
className={`py-2 px-1 border-b-2 font-medium text-sm transition-colors ${
activeTab === 'analytics'
? 'border-primary-600 text-primary-600'
: 'border-transparent text-neutral-500 hover:text-neutral-700'
}`}
>
{t('settings.analytics.title')}
</button>
</nav>
</div>
@@ -754,6 +798,127 @@ export const SettingsPage: React.FC = () => {
</Card>
</div>
)}
{/* Analytics Tab */}
{activeTab === 'analytics' && (
<div className="space-y-6">
<Card padding="md">
<h2 className="text-lg font-semibold text-neutral-900 mb-4">{t('settings.analytics.umamiIntegration')}</h2>
<div className="space-y-4">
<label className="flex items-center">
<input
type="checkbox"
checked={analyticsSettings.umami_enabled}
onChange={(e) => setAnalyticsSettings(prev => ({ ...prev, umami_enabled: e.target.checked }))}
className="w-4 h-4 text-primary-600 rounded focus:ring-primary-500"
/>
<span className="ml-2 text-sm text-neutral-700">{t('settings.analytics.enableUmami')}</span>
</label>
{analyticsSettings.umami_enabled && (
<>
<div>
<label className="block text-sm font-medium text-neutral-700 mb-1">
{t('settings.analytics.umamiUrl')}
</label>
<Input
type="url"
value={analyticsSettings.umami_url}
onChange={(e) => setAnalyticsSettings(prev => ({ ...prev, umami_url: e.target.value }))}
placeholder="https://analytics.yourdomain.com"
leftIcon={<Globe className="w-5 h-5 text-neutral-400" />}
/>
<p className="text-xs text-neutral-500 mt-1">
{t('settings.analytics.umamiUrlHelp')}
</p>
</div>
<div>
<label className="block text-sm font-medium text-neutral-700 mb-1">
{t('settings.analytics.websiteId')}
</label>
<Input
type="text"
value={analyticsSettings.umami_website_id}
onChange={(e) => setAnalyticsSettings(prev => ({ ...prev, umami_website_id: e.target.value }))}
placeholder="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
leftIcon={<Key className="w-5 h-5 text-neutral-400" />}
/>
<p className="text-xs text-neutral-500 mt-1">
{t('settings.analytics.websiteIdHelp')}
</p>
</div>
<div>
<label className="block text-sm font-medium text-neutral-700 mb-1">
{t('settings.analytics.shareUrl')}
</label>
<Input
type="url"
value={analyticsSettings.umami_share_url}
onChange={(e) => setAnalyticsSettings(prev => ({ ...prev, umami_share_url: e.target.value }))}
placeholder="https://analytics.yourdomain.com/share/..."
leftIcon={<Activity className="w-5 h-5 text-neutral-400" />}
/>
<p className="text-xs text-neutral-500 mt-1">
{t('settings.analytics.shareUrlHelp')}
</p>
</div>
</>
)}
<div className="p-4 bg-blue-50 border border-blue-200 rounded-lg">
<div className="flex items-start gap-3">
<AlertCircle className="w-5 h-5 text-blue-600 flex-shrink-0" />
<div className="text-sm text-blue-800">
<p className="font-medium mb-1">{t('settings.analytics.umamiInfo')}</p>
<p>{t('settings.analytics.umamiInfoText')}</p>
<a href="https://umami.is" target="_blank" rel="noopener noreferrer" className="underline mt-1 inline-block">
{t('settings.analytics.learnMore')}
</a>
</div>
</div>
</div>
</div>
<div className="mt-6">
<Button
variant="primary"
onClick={() => saveAnalyticsMutation.mutate()}
isLoading={saveAnalyticsMutation.isPending}
leftIcon={<Save className="w-5 h-5" />}
>
{t('settings.analytics.saveAnalyticsSettings')}
</Button>
</div>
</Card>
{/* Backend Analytics Info */}
<Card padding="md">
<h2 className="text-lg font-semibold text-neutral-900 mb-4">{t('settings.analytics.backendAnalytics')}</h2>
<p className="text-sm text-neutral-700 mb-4">{t('settings.analytics.backendAnalyticsText')}</p>
<div className="grid grid-cols-2 gap-4">
<div className="bg-neutral-50 rounded-lg p-4">
<h3 className="text-sm font-medium text-neutral-900 mb-2">{t('settings.analytics.tracked')}</h3>
<ul className="text-xs text-neutral-600 space-y-1">
<li>• {t('settings.analytics.galleryViews')}</li>
<li>• {t('settings.analytics.photoDownloads')}</li>
<li>• {t('settings.analytics.uniqueVisitors')}</li>
<li>• {t('settings.analytics.deviceTypes')}</li>
</ul>
</div>
<div className="bg-neutral-50 rounded-lg p-4">
<h3 className="text-sm font-medium text-neutral-900 mb-2">{t('settings.analytics.privacy')}</h3>
<p className="text-xs text-neutral-600">
{t('settings.analytics.privacyText')}
</p>
</div>
</div>
</Card>
</div>
)}
</div>
);
};