mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-22 12:31:12 -05:00
Refactor: Implement Data & Export tab modernization
This commit is contained in:
@@ -1,216 +1,485 @@
|
||||
import { useState } from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
import { useProfile } from '@/hooks/useProfile';
|
||||
import { supabase } from '@/integrations/supabase/client';
|
||||
import { Download, BarChart3, Upload, FileText, History } from 'lucide-react';
|
||||
import { handleError, handleSuccess, AppError } from '@/lib/errorHandler';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { Download, Activity, BarChart3, AlertCircle, Clock } from 'lucide-react';
|
||||
import type {
|
||||
UserStatistics,
|
||||
ActivityLogEntry,
|
||||
ExportOptions,
|
||||
ExportRequestResult
|
||||
} from '@/types/data-export';
|
||||
import {
|
||||
exportOptionsSchema,
|
||||
DEFAULT_EXPORT_OPTIONS
|
||||
} from '@/lib/dataExportValidation';
|
||||
|
||||
export function DataExportTab() {
|
||||
const { user } = useAuth();
|
||||
const { data: profile } = useProfile(user?.id);
|
||||
const {
|
||||
toast
|
||||
} = useToast();
|
||||
const [exportLoading, setExportLoading] = useState(false);
|
||||
const [exportProgress, setExportProgress] = useState(0);
|
||||
const handleDataExport = async () => {
|
||||
if (!user) return;
|
||||
setExportLoading(true);
|
||||
setExportProgress(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [exporting, setExporting] = useState(false);
|
||||
const [statistics, setStatistics] = useState<UserStatistics | null>(null);
|
||||
const [recentActivity, setRecentActivity] = useState<ActivityLogEntry[]>([]);
|
||||
const [exportOptions, setExportOptions] = useState<ExportOptions>(DEFAULT_EXPORT_OPTIONS);
|
||||
const [rateLimited, setRateLimited] = useState(false);
|
||||
const [nextAvailableAt, setNextAvailableAt] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (user && profile) {
|
||||
loadStatistics();
|
||||
loadRecentActivity();
|
||||
}
|
||||
}, [user, profile]);
|
||||
|
||||
const loadStatistics = async () => {
|
||||
if (!user || !profile) return;
|
||||
|
||||
try {
|
||||
// Simulate export progress
|
||||
const steps = ['Collecting profile data...', 'Gathering reviews...', 'Collecting ride credits...', 'Gathering top lists...', 'Packaging data...', 'Preparing download...'];
|
||||
for (let i = 0; i < steps.length; i++) {
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
setExportProgress((i + 1) / steps.length * 100);
|
||||
}
|
||||
// Fetch additional counts
|
||||
const { count: photoCount } = await supabase
|
||||
.from('photos')
|
||||
.select('*', { count: 'exact', head: true })
|
||||
.eq('submitted_by', user.id);
|
||||
|
||||
// In a real implementation, this would:
|
||||
// 1. Fetch all user data from various tables
|
||||
// 2. Package it into a structured format (JSON/CSV)
|
||||
// 3. Create a downloadable file
|
||||
// Note: user_lists table needs to be created for this to work
|
||||
// For now, setting to 0 as placeholder
|
||||
const listCount = 0;
|
||||
|
||||
const exportData = {
|
||||
profile: profile,
|
||||
export_date: new Date().toISOString(),
|
||||
data_types: ['profile', 'reviews', 'ride_credits', 'top_lists']
|
||||
const { count: submissionCount } = await supabase
|
||||
.from('content_submissions')
|
||||
.select('*', { count: 'exact', head: true })
|
||||
.eq('user_id', user.id);
|
||||
|
||||
const stats: UserStatistics = {
|
||||
ride_count: profile.ride_count || 0,
|
||||
coaster_count: profile.coaster_count || 0,
|
||||
park_count: profile.park_count || 0,
|
||||
review_count: profile.review_count || 0,
|
||||
reputation_score: profile.reputation_score || 0,
|
||||
photo_count: photoCount || 0,
|
||||
list_count: listCount || 0,
|
||||
submission_count: submissionCount || 0,
|
||||
account_created: profile.created_at,
|
||||
last_updated: profile.updated_at
|
||||
};
|
||||
const blob = new Blob([JSON.stringify(exportData, null, 2)], {
|
||||
type: 'application/json'
|
||||
|
||||
setStatistics(stats);
|
||||
|
||||
logger.info('User statistics loaded', {
|
||||
userId: user.id,
|
||||
action: 'load_statistics'
|
||||
});
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `thrillwiki-data-export-${new Date().toISOString().split('T')[0]}.json`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
toast({
|
||||
title: 'Export complete',
|
||||
description: 'Your data has been exported and downloaded successfully.'
|
||||
} catch (error) {
|
||||
logger.error('Failed to load statistics', {
|
||||
userId: user.id,
|
||||
action: 'load_statistics',
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
});
|
||||
} catch (error: any) {
|
||||
toast({
|
||||
title: 'Export failed',
|
||||
description: error.message || 'Failed to export your data',
|
||||
variant: 'destructive'
|
||||
|
||||
handleError(error, {
|
||||
action: 'Load statistics',
|
||||
userId: user.id
|
||||
});
|
||||
} finally {
|
||||
setExportLoading(false);
|
||||
setExportProgress(0);
|
||||
}
|
||||
};
|
||||
const handleImportData = () => {
|
||||
toast({
|
||||
title: 'Coming soon',
|
||||
description: 'Data import functionality will be available in a future update.'
|
||||
|
||||
const loadRecentActivity = async () => {
|
||||
if (!user) return;
|
||||
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from('profile_audit_log')
|
||||
.select('id, action, changes, created_at, changed_by, ip_address_hash, user_agent')
|
||||
.eq('user_id', user.id)
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(10);
|
||||
|
||||
if (error) {
|
||||
logger.error('Failed to load activity log', {
|
||||
userId: user.id,
|
||||
action: 'load_activity_log',
|
||||
error: error.message,
|
||||
errorCode: error.code
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Transform the data to match our type
|
||||
const activityData: ActivityLogEntry[] = (data || []).map(item => ({
|
||||
id: item.id,
|
||||
action: item.action,
|
||||
changes: item.changes as Record<string, any>,
|
||||
created_at: item.created_at,
|
||||
changed_by: item.changed_by,
|
||||
ip_address_hash: item.ip_address_hash || undefined,
|
||||
user_agent: item.user_agent || undefined
|
||||
}));
|
||||
|
||||
setRecentActivity(activityData);
|
||||
|
||||
logger.info('Activity log loaded', {
|
||||
userId: user.id,
|
||||
action: 'load_activity_log',
|
||||
count: activityData.length
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Error loading activity log', {
|
||||
userId: user.id,
|
||||
action: 'load_activity_log',
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
});
|
||||
|
||||
handleError(error, {
|
||||
action: 'Load activity log',
|
||||
userId: user.id
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDataExport = async () => {
|
||||
if (!user) return;
|
||||
|
||||
setExporting(true);
|
||||
|
||||
try {
|
||||
// Validate export options
|
||||
const validatedOptions = exportOptionsSchema.parse(exportOptions);
|
||||
|
||||
logger.info('Starting data export', {
|
||||
userId: user.id,
|
||||
action: 'export_data',
|
||||
options: validatedOptions
|
||||
});
|
||||
|
||||
// Call edge function for secure export
|
||||
const { data, error } = await supabase.functions.invoke<ExportRequestResult>(
|
||||
'export-user-data',
|
||||
{
|
||||
body: validatedOptions
|
||||
}
|
||||
);
|
||||
|
||||
if (error) {
|
||||
logger.error('Edge function invocation failed', {
|
||||
userId: user.id,
|
||||
action: 'export_data',
|
||||
error: error.message
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (!data?.success) {
|
||||
if (data?.rate_limited) {
|
||||
setRateLimited(true);
|
||||
setNextAvailableAt(data.next_available_at || null);
|
||||
|
||||
logger.warn('Export rate limited', {
|
||||
userId: user.id,
|
||||
action: 'export_data',
|
||||
nextAvailableAt: data.next_available_at
|
||||
});
|
||||
|
||||
handleError(
|
||||
new AppError(
|
||||
'Rate limited',
|
||||
'RATE_LIMIT_EXCEEDED',
|
||||
data.error || 'You can export your data once per hour. Please try again later.'
|
||||
),
|
||||
{ action: 'Export data', userId: user.id }
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
throw new Error(data?.error || 'Export failed');
|
||||
}
|
||||
|
||||
// Download the data as JSON
|
||||
const blob = new Blob([JSON.stringify(data.data, null, 2)], {
|
||||
type: 'application/json'
|
||||
});
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = `thrillwiki-data-export-${new Date().toISOString().split('T')[0]}.json`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
logger.info('Data export completed', {
|
||||
userId: user.id,
|
||||
action: 'export_data',
|
||||
dataSize: JSON.stringify(data.data).length
|
||||
});
|
||||
|
||||
handleSuccess(
|
||||
'Data exported successfully',
|
||||
'Your data has been downloaded as a JSON file.'
|
||||
);
|
||||
|
||||
// Refresh activity log to show the export action
|
||||
await loadRecentActivity();
|
||||
} catch (error) {
|
||||
logger.error('Data export failed', {
|
||||
userId: user.id,
|
||||
action: 'export_data',
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
});
|
||||
|
||||
handleError(error, {
|
||||
action: 'Export data',
|
||||
userId: user.id
|
||||
});
|
||||
} finally {
|
||||
setExporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const formatActionName = (action: string): string => {
|
||||
return action
|
||||
.split('_')
|
||||
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(' ');
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string): string => {
|
||||
return new Date(dateString).toLocaleString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
};
|
||||
return <div className="space-y-8">
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<Skeleton className="h-6 w-48" />
|
||||
<Skeleton className="h-4 w-full max-w-md" />
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<Skeleton className="h-12 w-full" />
|
||||
<Skeleton className="h-12 w-full" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* Personal Statistics */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<BarChart3 className="w-5 h-5" />
|
||||
<h3 className="text-lg font-medium">Personal Statistics</h3>
|
||||
</div>
|
||||
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardDescription>
|
||||
Overview of your activity and contributions to ThrillWiku.
|
||||
Your activity and contribution statistics on ThrillWiki
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div className="text-center p-4 bg-muted/50 rounded-lg">
|
||||
<div className="text-2xl font-bold text-primary">
|
||||
{profile?.review_count || 0}
|
||||
{statistics && (
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm text-muted-foreground">Reviews</p>
|
||||
<p className="text-2xl font-bold">{statistics.review_count}</p>
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">Reviews</div>
|
||||
</div>
|
||||
|
||||
<div className="text-center p-4 bg-muted/50 rounded-lg">
|
||||
<div className="text-2xl font-bold text-primary">
|
||||
{profile?.ride_count || 0}
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm text-muted-foreground">Rides Tracked</p>
|
||||
<p className="text-2xl font-bold">{statistics.ride_count}</p>
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">Rides</div>
|
||||
</div>
|
||||
|
||||
<div className="text-center p-4 bg-muted/50 rounded-lg">
|
||||
<div className="text-2xl font-bold text-primary">
|
||||
{profile?.coaster_count || 0}
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm text-muted-foreground">Coasters</p>
|
||||
<p className="text-2xl font-bold">{statistics.coaster_count}</p>
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">Coasters</div>
|
||||
</div>
|
||||
|
||||
<div className="text-center p-4 bg-muted/50 rounded-lg">
|
||||
<div className="text-2xl font-bold text-primary">
|
||||
{profile?.park_count || 0}
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm text-muted-foreground">Parks Visited</p>
|
||||
<p className="text-2xl font-bold">{statistics.park_count}</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm text-muted-foreground">Photos</p>
|
||||
<p className="text-2xl font-bold">{statistics.photo_count}</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm text-muted-foreground">Lists</p>
|
||||
<p className="text-2xl font-bold">{statistics.list_count}</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm text-muted-foreground">Submissions</p>
|
||||
<p className="text-2xl font-bold">{statistics.submission_count}</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm text-muted-foreground">Reputation</p>
|
||||
<p className="text-2xl font-bold">{statistics.reputation_score}</p>
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">Parks</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Data Export */}
|
||||
{/* Export Your Data */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Download className="w-5 h-5" />
|
||||
<h3 className="text-lg font-medium">Export Your Data</h3>
|
||||
</div>
|
||||
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Download Your Data</CardTitle>
|
||||
<CardDescription>
|
||||
Export all your personal data in a machine-readable format. This includes your profile,
|
||||
reviews, ride credits, top lists, and account activity.
|
||||
Export all your ThrillWiki data in JSON format. This includes your profile, reviews, lists, and activity history.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Badge variant="secondary">Profile Information</Badge>
|
||||
<Badge variant="secondary">Reviews & Ratings</Badge>
|
||||
<Badge variant="secondary">Ride Credits</Badge>
|
||||
<Badge variant="secondary">Top Lists</Badge>
|
||||
<Badge variant="secondary">Account Activity</Badge>
|
||||
</div>
|
||||
|
||||
{exportLoading && <div className="space-y-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span>Exporting data...</span>
|
||||
<span>{Math.round(exportProgress)}%</span>
|
||||
<CardContent className="space-y-6">
|
||||
{rateLimited && nextAvailableAt && (
|
||||
<div className="flex items-start gap-3 p-4 border border-yellow-500/20 bg-yellow-500/10 rounded-lg">
|
||||
<Clock className="w-5 h-5 text-yellow-500 mt-0.5" />
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium">Rate Limited</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
You can export your data once per hour. Next export available at{' '}
|
||||
{formatDate(nextAvailableAt)}.
|
||||
</p>
|
||||
</div>
|
||||
<Progress value={exportProgress} />
|
||||
</div>}
|
||||
|
||||
<Button onClick={handleDataExport} disabled={exportLoading} className="w-fit">
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Choose what to include in your export:
|
||||
</p>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="include_reviews">Include Reviews</Label>
|
||||
<Switch
|
||||
id="include_reviews"
|
||||
checked={exportOptions.include_reviews}
|
||||
onCheckedChange={(checked) =>
|
||||
setExportOptions({ ...exportOptions, include_reviews: checked })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="include_lists">Include Lists</Label>
|
||||
<Switch
|
||||
id="include_lists"
|
||||
checked={exportOptions.include_lists}
|
||||
onCheckedChange={(checked) =>
|
||||
setExportOptions({ ...exportOptions, include_lists: checked })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="include_activity_log">Include Activity Log</Label>
|
||||
<Switch
|
||||
id="include_activity_log"
|
||||
checked={exportOptions.include_activity_log}
|
||||
onCheckedChange={(checked) =>
|
||||
setExportOptions({ ...exportOptions, include_activity_log: checked })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="include_preferences">Include Preferences</Label>
|
||||
<Switch
|
||||
id="include_preferences"
|
||||
checked={exportOptions.include_preferences}
|
||||
onCheckedChange={(checked) =>
|
||||
setExportOptions({ ...exportOptions, include_preferences: checked })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-3 p-4 border rounded-lg">
|
||||
<AlertCircle className="w-5 h-5 text-blue-500 mt-0.5" />
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium">GDPR Compliance</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
This export includes all personal data we store about you. You can use this for backup purposes or to migrate to another service.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={handleDataExport}
|
||||
disabled={exporting || rateLimited}
|
||||
className="w-full"
|
||||
>
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
{exportLoading ? 'Exporting...' : 'Export My Data'}
|
||||
{exporting ? 'Exporting Data...' : 'Export My Data'}
|
||||
</Button>
|
||||
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Your data will be provided in JSON format. Processing may take a few moments
|
||||
for accounts with lots of activity.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
{/* Data Import */}
|
||||
|
||||
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Account Activity */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<History className="w-5 h-5" />
|
||||
<Activity className="w-5 h-5" />
|
||||
<h3 className="text-lg font-medium">Account Activity</h3>
|
||||
</div>
|
||||
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Recent Activity Log</CardTitle>
|
||||
<CardDescription>
|
||||
Your recent account activities and important events.
|
||||
Recent account activity and changes
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-3 p-3 border rounded-lg">
|
||||
<div className="w-2 h-2 bg-primary rounded-full"></div>
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium">Profile updated</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{profile?.updated_at ? new Date(profile.updated_at).toLocaleString() : 'Recently'}
|
||||
</p>
|
||||
</div>
|
||||
{recentActivity.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground text-center py-4">
|
||||
No recent activity to display
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{recentActivity.map((activity) => (
|
||||
<div key={activity.id} className="flex items-start gap-3 pb-4 border-b last:border-0 last:pb-0">
|
||||
<Activity className="w-4 h-4 text-muted-foreground mt-1" />
|
||||
<div className="flex-1 space-y-1">
|
||||
<p className="text-sm font-medium">
|
||||
{formatActionName(activity.action)}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{formatDate(activity.created_at)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 p-3 border rounded-lg">
|
||||
<div className="w-2 h-2 bg-muted rounded-full"></div>
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium">Account created</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{profile?.created_at ? new Date(profile.created_at).toLocaleString() : 'N/A'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user