feat(hackathon): refine winner certificate
This commit is contained in:
@@ -1,15 +1,16 @@
|
|||||||
import { FC, ReactElement, useEffect, useMemo, useRef, useState } from 'react';
|
import { FC, ReactElement, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { useNavigate, useParams } from 'react-router';
|
import { useNavigate, useParams } from 'react-router';
|
||||||
import { Button } from '@imphnen-frontend-service/ui/atoms';
|
import { Button } from '@imphnen-frontend-service/ui/atoms';
|
||||||
|
import { Icon } from '@iconify/react';
|
||||||
import { decodeWinnerCertificateId } from '../../../../utils/certificate';
|
import { decodeWinnerCertificateId } from '../../../../utils/certificate';
|
||||||
import {
|
import {
|
||||||
useAuthStore,
|
useAuthStore,
|
||||||
useMyTeams,
|
useMyTeams,
|
||||||
useTeamById,
|
useTeamById,
|
||||||
|
useWinners,
|
||||||
} from '@imphnen-frontend-service/service';
|
} from '@imphnen-frontend-service/service';
|
||||||
import QRCode from 'qrcode';
|
import QRCode from 'qrcode';
|
||||||
import html2canvas from 'html2canvas';
|
import html2canvas from 'html2canvas';
|
||||||
import winnerData from '../../winner-data.json';
|
|
||||||
|
|
||||||
type WinnerEntry = {
|
type WinnerEntry = {
|
||||||
team_id: string;
|
team_id: string;
|
||||||
@@ -20,9 +21,20 @@ type WinnerEntry = {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
type WinnerDataFile = {
|
const formatOrdinalRank = (rank: number): string => {
|
||||||
message: string;
|
const mod100 = rank % 100;
|
||||||
data: WinnerEntry[];
|
if (mod100 >= 11 && mod100 <= 13) return `${rank}th`;
|
||||||
|
|
||||||
|
switch (rank % 10) {
|
||||||
|
case 1:
|
||||||
|
return `${rank}st`;
|
||||||
|
case 2:
|
||||||
|
return `${rank}nd`;
|
||||||
|
case 3:
|
||||||
|
return `${rank}rd`;
|
||||||
|
default:
|
||||||
|
return `${rank}th`;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const CERT_WIDTH = 1000;
|
const CERT_WIDTH = 1000;
|
||||||
@@ -33,6 +45,11 @@ const CertificateWinnerPage: FC = (): ReactElement => {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { session } = useAuthStore();
|
const { session } = useAuthStore();
|
||||||
const { data: myTeamsData } = useMyTeams();
|
const { data: myTeamsData } = useMyTeams();
|
||||||
|
const {
|
||||||
|
data: winnersResponse,
|
||||||
|
isLoading: isLoadingWinners,
|
||||||
|
isError: isWinnersError,
|
||||||
|
} = useWinners();
|
||||||
|
|
||||||
const [decodedTeamId, setDecodedTeamId] = useState<string>('');
|
const [decodedTeamId, setDecodedTeamId] = useState<string>('');
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@@ -51,12 +68,20 @@ const CertificateWinnerPage: FC = (): ReactElement => {
|
|||||||
.catch(() => setError('Invalid certificate ID'));
|
.catch(() => setError('Invalid certificate ID'));
|
||||||
}, [certId]);
|
}, [certId]);
|
||||||
|
|
||||||
const winners = (winnerData as unknown as WinnerDataFile).data;
|
const winners = useMemo(
|
||||||
|
() => (winnersResponse?.data || []) as WinnerEntry[],
|
||||||
|
[winnersResponse?.data]
|
||||||
|
);
|
||||||
const winnerEntry = useMemo(() => {
|
const winnerEntry = useMemo(() => {
|
||||||
if (!decodedTeamId) return undefined;
|
if (!decodedTeamId) return undefined;
|
||||||
return winners.find((w) => w.team_id === decodedTeamId);
|
return winners.find((w) => w.team_id === decodedTeamId);
|
||||||
}, [decodedTeamId, winners]);
|
}, [decodedTeamId, winners]);
|
||||||
|
|
||||||
|
const rankLabel = useMemo(() => {
|
||||||
|
if (!winnerEntry?.rank) return '';
|
||||||
|
return formatOrdinalRank(winnerEntry.rank);
|
||||||
|
}, [winnerEntry?.rank]);
|
||||||
|
|
||||||
const { data: teamData, isLoading: isLoadingTeam } = useTeamById(
|
const { data: teamData, isLoading: isLoadingTeam } = useTeamById(
|
||||||
decodedTeamId,
|
decodedTeamId,
|
||||||
!!decodedTeamId
|
!!decodedTeamId
|
||||||
@@ -202,6 +227,33 @@ const CertificateWinnerPage: FC = (): ReactElement => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isLoadingWinners) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center min-h-screen bg-gray-50 dark:bg-gray-950">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4"></div>
|
||||||
|
<div className="text-gray-600 dark:text-gray-400">
|
||||||
|
Loading winners...
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isWinnersError) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center min-h-screen bg-gray-50 dark:bg-gray-950">
|
||||||
|
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-4">
|
||||||
|
Unable to Load Winners
|
||||||
|
</h2>
|
||||||
|
<p className="text-gray-600 dark:text-gray-400 mb-6">
|
||||||
|
Please try again later.
|
||||||
|
</p>
|
||||||
|
<Button onClick={() => navigate('/')}>Back to Home</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (!isWinnerTeam) {
|
if (!isWinnerTeam) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center min-h-screen bg-gray-50 dark:bg-gray-950">
|
<div className="flex flex-col items-center justify-center min-h-screen bg-gray-50 dark:bg-gray-950">
|
||||||
@@ -245,7 +297,10 @@ const CertificateWinnerPage: FC = (): ReactElement => {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{isTeamMember && (
|
{isTeamMember && (
|
||||||
<Button variant="secondary" onClick={() => navigate('/dashboard')}>
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => navigate('/dashboard')}
|
||||||
|
>
|
||||||
Back to Dashboard
|
Back to Dashboard
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
@@ -253,7 +308,10 @@ const CertificateWinnerPage: FC = (): ReactElement => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-12" id="certificate-wrapper">
|
<div
|
||||||
|
className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-12"
|
||||||
|
id="certificate-wrapper"
|
||||||
|
>
|
||||||
{/* Hidden Template for Canvas Generation */}
|
{/* Hidden Template for Canvas Generation */}
|
||||||
<div
|
<div
|
||||||
className={showTemplate ? 'block' : 'hidden'}
|
className={showTemplate ? 'block' : 'hidden'}
|
||||||
@@ -277,11 +335,11 @@ const CertificateWinnerPage: FC = (): ReactElement => {
|
|||||||
}
|
}
|
||||||
`}</style>
|
`}</style>
|
||||||
|
|
||||||
{/* Team Name (below "Diberikan Kepada") */}
|
{/* Team Name */}
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
top: '40%',
|
top: '37%',
|
||||||
left: '3.5%',
|
left: '3.5%',
|
||||||
width: '55%',
|
width: '55%',
|
||||||
}}
|
}}
|
||||||
@@ -306,7 +364,7 @@ const CertificateWinnerPage: FC = (): ReactElement => {
|
|||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
top: '45%',
|
top: '42.5%',
|
||||||
left: '3.5%',
|
left: '3.5%',
|
||||||
width: '55%',
|
width: '55%',
|
||||||
}}
|
}}
|
||||||
@@ -315,15 +373,17 @@ const CertificateWinnerPage: FC = (): ReactElement => {
|
|||||||
id="winner-members"
|
id="winner-members"
|
||||||
style={{
|
style={{
|
||||||
margin: 0,
|
margin: 0,
|
||||||
paddingLeft: '18px',
|
|
||||||
fontFamily: 'Poppins, sans-serif',
|
fontFamily: 'Poppins, sans-serif',
|
||||||
fontSize: '18px',
|
fontSize: '18px',
|
||||||
lineHeight: '1.35',
|
lineHeight: '1.35',
|
||||||
color: '#323232',
|
color: '#59bef5',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{(memberNames.length ? memberNames : ['(Members unavailable)']).map((name) => (
|
{(memberNames.length
|
||||||
<li key={name}>{name}</li>
|
? memberNames
|
||||||
|
: ['(Members unavailable)']
|
||||||
|
).map((name) => (
|
||||||
|
<li key={name}>• {name}</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
@@ -348,10 +408,42 @@ const CertificateWinnerPage: FC = (): ReactElement => {
|
|||||||
>
|
>
|
||||||
Diberikan sebagai penghargaan atas pencapaian meraih
|
Diberikan sebagai penghargaan atas pencapaian meraih
|
||||||
<br />
|
<br />
|
||||||
JUARA {winnerEntry.rank} pada Hackathon IMPHNEN x Kolosal.ai
|
<b>JUARA {winnerEntry.rank}</b> pada Hackathon IMPHNEN x
|
||||||
|
Kolosal.ai
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Rank badge */}
|
||||||
|
{!!rankLabel && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: '8%',
|
||||||
|
right: '8.5%',
|
||||||
|
width: '190px',
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
padding: '6px 16px',
|
||||||
|
textAlign: 'center',
|
||||||
|
fontFamily: 'Poppins, sans-serif',
|
||||||
|
fontWeight: 700,
|
||||||
|
color: '#78350F',
|
||||||
|
fontSize: '24px',
|
||||||
|
lineHeight: '1',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{rankLabel}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* QR Code (same placement as existing certificate page) */}
|
{/* QR Code (same placement as existing certificate page) */}
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
@@ -400,14 +492,32 @@ const CertificateWinnerPage: FC = (): ReactElement => {
|
|||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
{isTeamMember && (
|
{isTeamMember && (
|
||||||
<div className="bg-gray-50 dark:bg-gray-800 p-6 grid grid-cols-2 xl:grid-cols-3 gap-3 justify-center no-print">
|
<div className="bg-gray-50 dark:bg-gray-900 p-6 grid grid-cols-2 xl:grid-cols-3 gap-3 justify-center no-print">
|
||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
onClick={handleDownloadCertificate}
|
onClick={handleDownloadCertificate}
|
||||||
className="flex items-center gap-2"
|
className="flex items-center gap-2"
|
||||||
disabled={isGenerating}
|
disabled={isGenerating}
|
||||||
>
|
>
|
||||||
{isGenerating ? '⏳ Generating...' : '📥 Download'}
|
{isGenerating ? (
|
||||||
|
<>
|
||||||
|
<Icon
|
||||||
|
icon="svg-spinners:ring-resize"
|
||||||
|
width="18"
|
||||||
|
height="18"
|
||||||
|
/>
|
||||||
|
Generating...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Icon
|
||||||
|
icon="heroicons:arrow-down-tray"
|
||||||
|
width="18"
|
||||||
|
height="18"
|
||||||
|
/>
|
||||||
|
Download
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
@@ -415,7 +525,21 @@ const CertificateWinnerPage: FC = (): ReactElement => {
|
|||||||
className="flex items-center gap-2"
|
className="flex items-center gap-2"
|
||||||
disabled={isGenerating}
|
disabled={isGenerating}
|
||||||
>
|
>
|
||||||
{isGenerating ? '⏳ Generating...' : '🖨️ Print'}
|
{isGenerating ? (
|
||||||
|
<>
|
||||||
|
<Icon
|
||||||
|
icon="svg-spinners:ring-resize"
|
||||||
|
width="18"
|
||||||
|
height="18"
|
||||||
|
/>
|
||||||
|
Generating...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Icon icon="mdi:printer" width="18" height="18" />
|
||||||
|
Print
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={() => navigate('/dashboard')}
|
onClick={() => navigate('/dashboard')}
|
||||||
|
|||||||
@@ -5,12 +5,12 @@ import {
|
|||||||
useMyInvitations,
|
useMyInvitations,
|
||||||
useRespondToInvitation,
|
useRespondToInvitation,
|
||||||
useAuthStore,
|
useAuthStore,
|
||||||
|
useWinners,
|
||||||
} from '@imphnen-frontend-service/service';
|
} from '@imphnen-frontend-service/service';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { Button } from '@imphnen-frontend-service/ui/atoms';
|
import { Button } from '@imphnen-frontend-service/ui/atoms';
|
||||||
import { Icon } from '@iconify/react';
|
import { Icon } from '@iconify/react';
|
||||||
import ProfilePage from '../profile/page';
|
import ProfilePage from '../profile/page';
|
||||||
import winnerData from '../certificate/winner-data.json';
|
|
||||||
import { encodeWinnerCertificateId } from '../../utils/certificate';
|
import { encodeWinnerCertificateId } from '../../utils/certificate';
|
||||||
|
|
||||||
// Team features deadline: 2025-11-30 23:59:00 WIB (UTC+7)
|
// Team features deadline: 2025-11-30 23:59:00 WIB (UTC+7)
|
||||||
@@ -94,6 +94,7 @@ const DashboardPage: FC = (): ReactElement => {
|
|||||||
}
|
}
|
||||||
}, [showProfileModal]);
|
}, [showProfileModal]);
|
||||||
const { data: teamsData } = useMyTeams();
|
const { data: teamsData } = useMyTeams();
|
||||||
|
const { data: winnersResponse } = useWinners();
|
||||||
const { data: invitationsData } = useMyInvitations();
|
const { data: invitationsData } = useMyInvitations();
|
||||||
const { mutateAsync: respondToInvitation } = useRespondToInvitation();
|
const { mutateAsync: respondToInvitation } = useRespondToInvitation();
|
||||||
|
|
||||||
@@ -103,11 +104,11 @@ const DashboardPage: FC = (): ReactElement => {
|
|||||||
[]) as Invitation[];
|
[]) as Invitation[];
|
||||||
|
|
||||||
const winnerEntry = useMemo(() => {
|
const winnerEntry = useMemo(() => {
|
||||||
const team = (myTeams[0] as any) || null;
|
const team = (myTeams[0] as { id?: string } | null | undefined) || null;
|
||||||
const winners = (winnerData as any)?.data || [];
|
const winners = winnersResponse?.data || [];
|
||||||
if (!team?.id || !Array.isArray(winners)) return null;
|
if (!team?.id) return null;
|
||||||
return winners.find((w: any) => w?.team_id === team.id) || null;
|
return winners.find((w) => w.team_id === team.id) || null;
|
||||||
}, [myTeams]);
|
}, [myTeams, winnersResponse?.data]);
|
||||||
|
|
||||||
const handleAcceptInvitation = async (invitationId: string) => {
|
const handleAcceptInvitation = async (invitationId: string) => {
|
||||||
try {
|
try {
|
||||||
@@ -155,17 +156,19 @@ const DashboardPage: FC = (): ReactElement => {
|
|||||||
Selamat! Tim Anda meraih JUARA {winnerEntry.rank}
|
Selamat! Tim Anda meraih JUARA {winnerEntry.rank}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-amber-700 dark:text-amber-300 text-sm">
|
<p className="text-amber-700 dark:text-amber-300 text-sm">
|
||||||
Anda dapat generate sertifikat penghargaan dan membagikannya.
|
Anda dapat generate sertifikat penghargaan dan
|
||||||
|
membagikannya.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
const team = myTeams[0] as any;
|
const team = myTeams[0] as { id?: string } | null | undefined;
|
||||||
|
if (!team?.id) return;
|
||||||
const certId = await encodeWinnerCertificateId(team.id);
|
const certId = await encodeWinnerCertificateId(team.id);
|
||||||
navigate(`/certificate/winner/${encodeURIComponent(certId)}`);
|
navigate(`/certificate/winner/${encodeURIComponent(certId)}`);
|
||||||
}}
|
}}
|
||||||
className="shrink-0 px-6 py-2 bg-amber-600 hover:bg-amber-700 dark:bg-amber-600 dark:hover:bg-amber-700 text-white font-medium rounded-lg transition-colors"
|
className="shrink-0 px-6 py-2 bg-amber-600 hover:bg-amber-700 dark:bg-amber-600 dark:hover:bg-amber-700 text-white font-medium rounded-lg transition-colors cursor-pointer"
|
||||||
>
|
>
|
||||||
Generate Sertifikat Juara
|
Generate Sertifikat Juara
|
||||||
</button>
|
</button>
|
||||||
@@ -233,7 +236,8 @@ const DashboardPage: FC = (): ReactElement => {
|
|||||||
<div className="mb-4 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg p-3">
|
<div className="mb-4 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg p-3">
|
||||||
<p className="text-sm text-amber-700 dark:text-amber-300 flex items-center gap-2">
|
<p className="text-sm text-amber-700 dark:text-amber-300 flex items-center gap-2">
|
||||||
<Icon icon="mdi:clock-alert" className="text-lg shrink-0" />
|
<Icon icon="mdi:clock-alert" className="text-lg shrink-0" />
|
||||||
Team features are closed. You can no longer accept invitations.
|
Team features are closed. You can no longer accept
|
||||||
|
invitations.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -302,7 +306,10 @@ const DashboardPage: FC = (): ReactElement => {
|
|||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="w-16 h-16 rounded-full bg-gray-200 dark:bg-gray-700 flex items-center justify-center shrink-0">
|
<div className="w-16 h-16 rounded-full bg-gray-200 dark:bg-gray-700 flex items-center justify-center shrink-0">
|
||||||
<Icon icon="mdi:account-group" className="text-gray-500 dark:text-gray-400 text-2xl" />
|
<Icon
|
||||||
|
icon="mdi:account-group"
|
||||||
|
className="text-gray-500 dark:text-gray-400 text-2xl"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
@@ -312,19 +319,33 @@ const DashboardPage: FC = (): ReactElement => {
|
|||||||
<div className="text-sm text-gray-600 dark:text-gray-400 flex items-center gap-3 font-sans mt-2">
|
<div className="text-sm text-gray-600 dark:text-gray-400 flex items-center gap-3 font-sans mt-2">
|
||||||
{team.has_submission && (
|
{team.has_submission && (
|
||||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs font-medium bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400 shrink-0">
|
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs font-medium bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400 shrink-0">
|
||||||
<Icon icon="mdi:check-circle" className="text-sm" />
|
<Icon
|
||||||
|
icon="mdi:check-circle"
|
||||||
|
className="text-sm"
|
||||||
|
/>
|
||||||
Submitted
|
Submitted
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{team.city && (
|
{team.city && (
|
||||||
<span className="flex items-center gap-1 truncate">
|
<span className="flex items-center gap-1 truncate">
|
||||||
<Icon icon="mdi:map-marker" className="shrink-0" />
|
<Icon
|
||||||
|
icon="mdi:map-marker"
|
||||||
|
className="shrink-0"
|
||||||
|
/>
|
||||||
<span className="truncate">{team.city}</span>
|
<span className="truncate">{team.city}</span>
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<span className="flex items-center gap-1 shrink-0">
|
<span className="flex items-center gap-1 shrink-0">
|
||||||
<Icon icon="mdi:account-group" />
|
<Icon icon="mdi:account-group" />
|
||||||
{team.member_count || team.members?.length || 0} member{(team.member_count || team.members?.length || 0) !== 1 ? 's' : ''}
|
{team.member_count ||
|
||||||
|
team.members?.length ||
|
||||||
|
0}{' '}
|
||||||
|
member
|
||||||
|
{(team.member_count ||
|
||||||
|
team.members?.length ||
|
||||||
|
0) !== 1
|
||||||
|
? 's'
|
||||||
|
: ''}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user