This commit is contained in:
Tomas Dvorak
2025-11-02 21:31:00 +01:00
parent b9cea0cd77
commit 087f30e82c
130 changed files with 20104 additions and 34330 deletions
+351 -1
View File
@@ -1,4 +1,4 @@
import React, { useEffect, useState } from 'react';
import React, { useEffect, useMemo, useRef, useState } from 'react';
import {
Box,
Button,
@@ -17,9 +17,20 @@ import {
VStack,
HStack,
Link as ChakraLink,
Select,
Avatar,
Badge,
Progress,
useColorModeValue,
SimpleGrid,
Image,
IconButton,
} from '@chakra-ui/react';
import { useAuth } from '../contexts/AuthContext';
import api from '../services/api';
import { getLeaderboard, LeaderboardItem, getProfile, EngagementProfile, getRewards, RewardItem, redeemReward, patchAvatar, patchProfile, getMyTransactions, PointsTx, getAchievements } from '../services/engagement';
import { getMyWinnings } from '../services/sweepstakes';
import { Upload, RefreshCw, Pencil, Gift } from 'lucide-react';
const SemiAdminPage: React.FC = () => {
const { user, updateUser } = useAuth();
@@ -52,6 +63,40 @@ const SemiAdminPage: React.FC = () => {
})();
}, []);
const loadProfile = async () => {
setLoadingProf(true);
try {
const p = await getProfile();
setProf(p);
setUsernameEdit(p.username || '');
} finally {
setLoadingProf(false);
}
};
const loadRewards = async () => {
setLoadingRewards(true);
try { setRewards(await getRewards()); } finally { setLoadingRewards(false); }
};
useEffect(() => { loadProfile(); loadRewards(); }, []);
useEffect(() => {
(async () => {
setTxLoading(true);
try { const items = await getMyTransactions({ limit: 100 }); setTxItems(items); } finally { setTxLoading(false); }
})();
(async () => {
setAchLoading(true);
try { const res = await getAchievements(); setAchItems(res.achievements || []); } finally { setAchLoading(false); }
})();
}, []);
useEffect(() => {
const onRefresh = () => { loadProfile().catch(()=>{}); };
window.addEventListener('engagement:refresh', onRefresh as any);
return () => window.removeEventListener('engagement:refresh', onRefresh as any);
}, []);
const handleSave = async (e: React.FormEvent) => {
e.preventDefault();
setIsSaving(true);
@@ -71,14 +116,217 @@ const SemiAdminPage: React.FC = () => {
};
const prefsUrl = prefsToken ? `/newsletter/preferences?token=${encodeURIComponent(prefsToken)}` : '';
const [metric, setMetric] = useState<'points'|'level'|'xp'>('points');
const [leaders, setLeaders] = useState<LeaderboardItem[]>([]);
const [loadingLb, setLoadingLb] = useState<boolean>(false);
const [txLoading, setTxLoading] = useState<boolean>(false);
const [txItems, setTxItems] = useState<PointsTx[]>([]);
const [achLoading, setAchLoading] = useState<boolean>(false);
const [achItems, setAchItems] = useState<any[]>([]);
const [winsLoading, setWinsLoading] = useState<boolean>(false);
const [wins, setWins] = useState<Array<{ id:number; prize_name?: string; claim_status: string; created_at?: string }>>([]);
useEffect(() => {
(async () => {
try {
setWinsLoading(true);
const res = await getMyWinnings();
setWins((res.items || []).map((w:any) => ({ id: w.id, prize_name: w.prize_name, claim_status: w.claim_status, created_at: w.created_at })));
} catch {
setWins([]);
} finally {
setWinsLoading(false);
}
})();
}, []);
// Engagement profile
const [prof, setProf] = useState<EngagementProfile | null>(null);
const [loadingProf, setLoadingProf] = useState<boolean>(true);
const [rewards, setRewards] = useState<RewardItem[]>([]);
const [loadingRewards, setLoadingRewards] = useState<boolean>(false);
const [usernameEdit, setUsernameEdit] = useState<string>('');
const [usernameEditing, setUsernameEditing] = useState<boolean>(false);
const fileRef = useRef<HTMLInputElement>(null);
const cardBg = useColorModeValue('white', 'gray.800');
const border = useColorModeValue('gray.200', 'gray.700');
useEffect(() => {
let mounted = true;
(async () => {
try {
setLoadingLb(true);
const res = await getLeaderboard(metric, 20);
if (mounted) setLeaders(res.items || []);
} catch {
if (mounted) setLeaders([]);
} finally {
if (mounted) setLoadingLb(false);
}
})();
return () => { mounted = false; };
}, [metric]);
// XP thresholds helpers
const levelInfo = useMemo(() => {
if (!prof) return { level: 1, xp: 0, currentBase: 0, nextBase: 100, pct: 0 };
const L = Math.max(1, Number(prof.level || 1));
const xp = Number(prof.xp || 0);
// total needed to reach level L: 100 + 200 + ... + 100*(L-1) = 50*(L-1)*L
const totalToL = 50 * (L - 1) * L;
const nextInc = 100 * L;
const totalToNext = totalToL + nextInc;
const inLevel = Math.max(0, xp - totalToL);
const pct = Math.max(0, Math.min(100, Math.floor((inLevel / Math.max(1, nextInc)) * 100)));
return { level: L, xp, currentBase: totalToL, nextBase: totalToNext, pct, inLevel, nextInc };
}, [prof]);
const baseNameColor = useColorModeValue('gray.800', 'gray.100');
const nameColor = useMemo(() => {
const L = levelInfo.level;
if (L >= 20) return 'yellow.400'; // gold
if (L >= 15) return 'purple.400'; // epic
if (L >= 10) return 'blue.400'; // rare
if (L >= 5) return 'teal.400'; // uncommon
return baseNameColor;
}, [levelInfo.level, baseNameColor]);
const triggerUpload = () => {
if (!prof?.avatar_upload_unlocked) {
toast({ status: 'info', title: 'Odemkněte nahrání avataru', description: 'V obchodě níže můžete odemknout možnost nahrát vlastní profilový obrázek.', duration: 3500 });
const el = document.getElementById('rewards-store'); if (el) el.scrollIntoView({ behavior: 'smooth' });
return;
}
fileRef.current?.click();
};
const onFileSelected = async (e: React.ChangeEvent<HTMLInputElement>) => {
const f = e.target.files?.[0];
if (!f) return;
try {
const fd = new FormData();
fd.append('file', f);
const res = await api.post('/upload', fd, { headers: { 'Content-Type': 'multipart/form-data' } });
const url = res.data?.url || res.data?.absolute_url;
if (!url) throw new Error('Upload selhal');
await patchAvatar({ avatar_url: url });
toast({ status: 'success', title: 'Avatar aktualizován' });
await loadProfile();
} catch (err: any) {
const msg = err?.response?.data?.error || 'Nahrání selhalo';
toast({ status: 'error', title: 'Chyba', description: msg });
} finally {
if (fileRef.current) fileRef.current.value = '';
}
};
const randomizeAvatar = async () => {
const seed = Math.random().toString(36).slice(2, 10);
const url = `https://api.dicebear.com/7.x/pixel-art/svg?radius=50&seed=${encodeURIComponent(seed)}`;
await patchAvatar({ avatar_url: url });
await loadProfile();
toast({ status: 'success', title: 'Náhodný avatar nastaven' });
};
const saveUsername = async () => {
const v = usernameEdit.trim();
if (!v) { toast({ status: 'warning', title: 'Uživatelské jméno je prázdné' }); return; }
try {
setUsernameEditing(true);
await patchProfile({ username: v });
toast({ status: 'success', title: 'Uživatelské jméno uloženo' });
await loadProfile();
} catch (e: any) {
toast({ status: 'error', title: 'Chyba', description: e?.response?.data?.error || 'Nelze uložit' });
} finally { setUsernameEditing(false); }
};
return (
<Container maxW="5xl" py={8}>
<Heading size="lg" mb={6}>Fan zóna</Heading>
{/* Profile header */}
<Box borderWidth="1px" borderColor={border} bg={cardBg} borderRadius="lg" p={5} mb={6} textAlign="center">
<VStack spacing={3} align="center">
<Box position="relative" display="inline-block">
<Avatar size="2xl" name={user?.name || prof?.username || 'Uživatel'} src={prof?.animated_avatar_url || prof?.avatar_url || undefined} />
{/* Upload icon (left) */}
<IconButton aria-label="Nahrát avatar" icon={<Upload size={16} />} size="sm" variant="solid" colorScheme="blue" position="absolute" left="-10px" top="50%" transform="translateY(-50%)" onClick={triggerUpload} />
{/* Level badge (right) */}
<Badge position="absolute" right="-10px" top="50%" transform="translateY(-50%)" colorScheme="yellow" fontSize="0.8rem" p={2} borderRadius="md">Lv {levelInfo.level}</Badge>
{/* Randomize (bottom) */}
<IconButton aria-label="Náhodný avatar" icon={<RefreshCw size={16} />} size="xs" variant="ghost" position="absolute" bottom="-6px" right="50%" transform="translateX(50%)" onClick={randomizeAvatar} />
<input ref={fileRef} type="file" accept="image/*,image/gif" style={{ display: 'none' }} onChange={onFileSelected} />
</Box>
{/* Username */}
<HStack spacing={2}>
{!usernameEditing && (
<Text fontSize="xl" fontWeight="700" color={nameColor}>
{(prof?.username || '').trim() || 'Nastavte uživatelské jméno'}
</Text>
)}
<IconButton aria-label="Upravit" size="xs" variant="ghost" icon={<Pencil size={16} />} onClick={() => setUsernameEditing((v)=>!v)} />
</HStack>
{usernameEditing && (
<HStack spacing={2}>
<Input value={usernameEdit} onChange={(e)=>setUsernameEdit(e.target.value)} placeholder="uživatelské-jméno" maxW="260px" />
<Button colorScheme="blue" size="sm" isLoading={usernameEditing} onClick={saveUsername}>Uložit</Button>
</HStack>
)}
{/* Full name */}
<Text color={useColorModeValue('gray.600','gray.400')}>{`${firstName || ''} ${lastName || ''}`.trim() || '—'}</Text>
{/* XP progress */}
<HStack w="100%" maxW="lg" spacing={3} align="center">
<Box flex={1}>
<Progress value={levelInfo.pct} size="md" borderRadius="full" colorScheme="blue" />
<Text fontSize="sm" color={useColorModeValue('gray.600','gray.400')} mt={1}>{levelInfo.inLevel || 0} / {levelInfo.nextInc} XP do další úrovně</Text>
</Box>
<Badge colorScheme="yellow">Lv {levelInfo.level}</Badge>
</HStack>
{/* Points */}
<Text>Aktuální body: <Text as="span" fontWeight="700">{prof?.points ?? 0}</Text></Text>
</VStack>
</Box>
{/* Store */}
<Box id="rewards-store" borderWidth="1px" borderColor={border} bg={cardBg} borderRadius="lg" p={5} mb={8}>
<Heading size="md" mb={3}>Obchod s odměnami</Heading>
{loadingRewards ? (
<Text>Načítám</Text>
) : (
<SimpleGrid minChildWidth="220px" spacing={4}>
{rewards.map((r) => (
<Box key={r.id} borderWidth="1px" borderColor={border} borderRadius="md" p={3}>
<VStack align="stretch" spacing={2}>
<Text fontWeight="700">{r.name}</Text>
{r.image_url && <Image src={r.image_url} alt={r.name} borderRadius="md" />}
<Text fontSize="sm" color={useColorModeValue('gray.600','gray.400')}>Cena: {r.cost_points} bodů</Text>
<Button size="sm" colorScheme="blue" onClick={async ()=>{
try { const res = await redeemReward(r.id); toast({ status:'success', title: 'Odměna uplatněna', description: res.status }); await loadProfile(); }
catch(e:any){ toast({ status:'error', title:'Chyba', description: e?.response?.data?.error || 'Nelze uplatnit odměnu' }); }
}}>Uplatnit</Button>
</VStack>
</Box>
))}
</SimpleGrid>
)}
<Box mt={4}>
<Heading size="sm" mb={2}>Jak získat body</Heading>
<VStack align="start" spacing={1} fontSize="sm" color={useColorModeValue('gray.700','gray.300')}>
<Text> Napište smysluplný komentář (+5)</Text>
<Text> Hlasujte v anketě (+3, 1× denně)</Text>
<Text> Přihlaste se k newsletteru (+12)</Text>
</VStack>
</Box>
</Box>
<Tabs colorScheme="blue" isFitted variant="enclosed">
<TabList>
<Tab>Osobní údaje</Tab>
<Tab>Newsletter</Tab>
<Tab>Žebříčky</Tab>
<Tab>Historie bodů</Tab>
<Tab>Úspěchy</Tab>
<Tab>Výhry</Tab>
</TabList>
<TabPanels>
<TabPanel>
@@ -108,6 +356,108 @@ const SemiAdminPage: React.FC = () => {
)}
</VStack>
</TabPanel>
<TabPanel>
<VStack align="stretch" spacing={4}>
<HStack justify="space-between">
<Heading size="md">Žebříčky</Heading>
<HStack>
<Select size="sm" value={metric} onChange={(e)=>setMetric(e.target.value as any)} maxW="180px">
<option value="points">Body</option>
<option value="level">Úroveň</option>
<option value="xp">XP</option>
</Select>
</HStack>
</HStack>
<Box borderWidth="1px" borderColor={border} borderRadius="md" bg={cardBg} p={3}>
<VStack align="stretch" spacing={2}>
{loadingLb && <Text>Načítám</Text>}
{!loadingLb && leaders.length === 0 && (
<Text>Žádná data k zobrazení.</Text>
)}
{!loadingLb && leaders.map((it) => {
const value = metric === 'points' ? it.points : (metric === 'level' ? it.level : it.xp);
const max = Math.max(...leaders.map(l => metric === 'points' ? l.points : (metric === 'level' ? l.level : l.xp)), 1);
const pct = Math.max(2, Math.floor((Number(value) / Number(max)) * 100));
const name = (it.username || '').trim() || `${it.first_name || ''} ${it.last_name || ''}`.trim() || `#${it.user_id}`;
return (
<HStack key={`${metric}-${it.user_id}`} spacing={3}>
<Badge colorScheme="blue">{it.rank}</Badge>
<Avatar size="sm" name={name} src={it.animated_avatar_url || it.avatar_url || undefined} />
<Box flex={1}>
<HStack justify="space-between">
<Text fontWeight="600" noOfLines={1}>{name}</Text>
<Text fontSize="sm">{value}</Text>
</HStack>
<Progress value={pct} size="sm" colorScheme="blue" borderRadius="full" mt={1} />
</Box>
</HStack>
);
})}
</VStack>
</Box>
</VStack>
</TabPanel>
<TabPanel>
<VStack align="stretch" spacing={3}>
{txLoading ? (
<Text>Načítám</Text>
) : (
<Box borderWidth="1px" borderColor={border} borderRadius="md" overflowX="auto">
<Box as="table" w="100%" style={{ borderCollapse: 'collapse' }}>
<Box as="thead" bg={useColorModeValue('gray.50','gray.700')}>
<Box as="tr">
<Box as="th" p={2} textAlign="left">Čas</Box>
<Box as="th" p={2} textAlign="left">Delta</Box>
<Box as="th" p={2} textAlign="left">Důvod</Box>
<Box as="th" p={2} textAlign="left">Meta</Box>
</Box>
</Box>
<Box as="tbody">
{txItems.map((t) => (
<Box as="tr" key={t.id} borderTopWidth="1px" borderColor={border}>
<Box as="td" p={2}>{t.created_at ? new Date(t.created_at).toLocaleString() : '-'}</Box>
<Box as="td" p={2}><Badge colorScheme={t.delta >= 0 ? 'green' : 'red'}>{t.delta >= 0 ? `+${t.delta}` : t.delta}</Badge></Box>
<Box as="td" p={2}><Badge>{t.reason}</Badge></Box>
<Box as="td" p={2}><Text fontSize="xs" noOfLines={1}>{t.meta ? JSON.stringify(t.meta) : '-'}</Text></Box>
</Box>
))}
{txItems.length === 0 && (
<Box as="tr"><Box as="td" p={3} colSpan={4}><Text color={useColorModeValue('gray.600','gray.400')}>Žádné transakce.</Text></Box></Box>
)}
</Box>
</Box>
</Box>
)}
</VStack>
</TabPanel>
<TabPanel>
<VStack align="stretch" spacing={3}>
{achLoading ? (
<Text>Načítám</Text>
) : (
<SimpleGrid minChildWidth="220px" spacing={4}>
{achItems.map((a: any) => (
<Box key={a.id} borderWidth="1px" borderColor={border} borderRadius="md" p={3} bg={cardBg}>
<VStack align="stretch" spacing={1}>
<HStack justify="space-between">
<Text fontWeight="600">{a.title}</Text>
{a.achieved ? <Badge colorScheme="green">Splněno</Badge> : <Badge colorScheme="gray">Nesplněno</Badge>}
</HStack>
<Text fontSize="sm" color={useColorModeValue('gray.600','gray.400')}>{a.description}</Text>
<HStack>
<Badge>{a.points} bodů</Badge>
{a.achieved_at && <Text fontSize="xs" color={useColorModeValue('gray.500','gray.400')}>{new Date(a.achieved_at).toLocaleString()}</Text>}
</HStack>
</VStack>
</Box>
))}
{achItems.length === 0 && (
<Text color={useColorModeValue('gray.600','gray.400')}>Žádné úspěchy k zobrazení.</Text>
)}
</SimpleGrid>
)}
</VStack>
</TabPanel>
</TabPanels>
</Tabs>
</Container>