Files
MyClub/frontend/src/pages/SemiAdminPage.tsx
T
Tomas Dvorak 087f30e82c dev day #80
2025-11-02 21:31:00 +01:00

468 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useEffect, useMemo, useRef, useState } from 'react';
import {
Box,
Button,
Container,
FormControl,
FormLabel,
Input,
Tab,
TabList,
TabPanel,
TabPanels,
Tabs,
Heading,
Text,
useToast,
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();
const splitName = (full?: string) => {
const v = String(full || '').trim();
if (!v) return { fn: '', ln: '' };
const parts = v.split(/\s+/);
if (parts.length === 1) return { fn: parts[0], ln: '' };
return { fn: parts[0], ln: parts.slice(1).join(' ') };
};
const init = splitName(user?.name);
const [firstName, setFirstName] = useState(init.fn);
const [lastName, setLastName] = useState(init.ln);
const [isSaving, setIsSaving] = useState(false);
const [prefsToken, setPrefsToken] = useState<string>('');
const toast = useToast();
useEffect(() => {
const s = splitName(user?.name);
setFirstName(s.fn);
setLastName(s.ln);
}, [user?.name]);
useEffect(() => {
(async () => {
try {
const res = await api.get('/newsletter/token/me');
setPrefsToken(res.data?.token || '');
} catch {}
})();
}, []);
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);
try {
const res = await api.put('/me', { first_name: firstName, last_name: lastName });
const updated = res.data?.user;
if (updated) {
const n = `${updated.first_name || firstName} ${updated.last_name || lastName}`.trim();
updateUser({ name: n });
}
toast({ title: 'Uloženo', description: 'Osobní údaje byly aktualizovány.', status: 'success', duration: 3000 });
} catch (err: any) {
toast({ title: 'Chyba', description: err?.response?.data?.error || 'Nelze uložit změny', status: 'error' });
} finally {
setIsSaving(false);
}
};
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>
<Box as="form" onSubmit={handleSave} maxW="lg">
<VStack align="stretch" spacing={4}>
<FormControl>
<FormLabel>Jméno</FormLabel>
<Input value={firstName} onChange={(e) => setFirstName(e.target.value)} placeholder="Jméno" />
</FormControl>
<FormControl>
<FormLabel>Příjmení</FormLabel>
<Input value={lastName} onChange={(e) => setLastName(e.target.value)} placeholder="Příjmení" />
</FormControl>
<HStack>
<Button type="submit" colorScheme="blue" isLoading={isSaving}>Uložit</Button>
</HStack>
</VStack>
</Box>
</TabPanel>
<TabPanel>
<VStack align="start" spacing={4}>
<Text>Spravujte předvolby newsletteru nebo se odhlaste.</Text>
{prefsUrl ? (
<Button as={ChakraLink} href={prefsUrl} colorScheme="blue">Otevřít nastavení newsletteru</Button>
) : (
<Text>Načítám odkaz na nastavení</Text>
)}
</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>
);
};
export default SemiAdminPage;