mirror of
https://github.com/Dvorinka/MyClubServer.git
synced 2026-06-05 03:02:56 +00:00
dev day #80
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user