mirror of
https://github.com/Dvorinka/MyClubServer.git
synced 2026-06-04 02:32:57 +00:00
468 lines
20 KiB
TypeScript
468 lines
20 KiB
TypeScript
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;
|