diff --git a/app/(legal)/expired/page.tsx b/app/(others)/expired/page.tsx
similarity index 100%
rename from app/(legal)/expired/page.tsx
rename to app/(others)/expired/page.tsx
diff --git a/app/(legal)/privacy/page.tsx b/app/(others)/privacy/page.tsx
similarity index 100%
rename from app/(legal)/privacy/page.tsx
rename to app/(others)/privacy/page.tsx
diff --git a/app/(legal)/terms/page.tsx b/app/(others)/terms/page.tsx
similarity index 100%
rename from app/(legal)/terms/page.tsx
rename to app/(others)/terms/page.tsx
diff --git a/app/globals.css b/app/globals.css
index 47fc103..dea98ff 100644
--- a/app/globals.css
+++ b/app/globals.css
@@ -6,7 +6,7 @@
}
body {
- background: #23272A;
+ background-color: #23272A;
padding: 0;
margin: 0;
}
diff --git a/app/links/components/LinksPage.tsx b/app/links/components/LinksPage.tsx
index 7f9e168..8028546 100644
--- a/app/links/components/LinksPage.tsx
+++ b/app/links/components/LinksPage.tsx
@@ -1,8 +1,5 @@
'use client';
-import { useRouter, useSearchParams } from 'next/navigation';
-import { Fragment, useEffect, useState } from 'react';
-
import AddLinkIcon from '@mui/icons-material/AddLink';
import LinkIcon from '@mui/icons-material/Link';
import LinkOffIcon from '@mui/icons-material/LinkOff';
@@ -28,6 +25,8 @@ import TableCell from '@mui/material/TableCell';
import TableHead from '@mui/material/TableHead';
import TableRow from '@mui/material/TableRow';
import Typography from '@mui/material/Typography';
+import { useRouter, useParams, useSearchParams } from 'next/navigation';
+import { Fragment, useEffect, useState } from 'react';
import { ActionModal } from '@/components/ActionModal';
import { authCookieKey } from '@/lib/constants';
diff --git a/app/links/page.tsx b/app/links/page.tsx
index a8654cd..ef1fccd 100644
--- a/app/links/page.tsx
+++ b/app/links/page.tsx
@@ -1,49 +1,9 @@
-import jwt from 'jsonwebtoken';
-import { headers } from 'next/headers';
-import { redirect } from 'next/navigation';
+import { getAccessToken } from '@/util/access-token.helper';
+import { NextPage } from 'next';
import { LinksPage } from './components/LinksPage';
-const getServerSideProps = async () => {
- const headersList = headers();
- const query = new URLSearchParams(headersList.get('x-search') as string);
- const authToken = query.get('token');
-
- try {
- const decoded = jwt.verify(
- authToken as string,
- process.env.JWT_DECODE_SECRET as string
- ) as {
- guild_id: string;
- user_id: string;
- };
-
- const token = jwt.sign(
- {
- jti: 'vercel-uid',
- sub: decoded.user_id,
- roles: ['viewer', 'user'],
- version: 'v1'
- },
- process.env.JWT_SECRET_V2 as string,
- {
- expiresIn: '100m'
- }
- );
-
- return {
- token,
- userId: decoded.user_id,
- guildId: decoded.guild_id,
- isPublicBot: query.get('bot') !== 'custom'
- };
- } catch (e) {
- console.error(e);
- return redirect('/expired');
- }
-};
-
-export default async function Links() {
- const { guildId, userId, token, isPublicBot } = await getServerSideProps();
+const Page: NextPage = async () => {
+ const { guildId, userId, token, isPublicBot } = await getAccessToken();
return (
);
-}
+};
+
+export default Page;
diff --git a/app/web/charts/[id]/page.tsx b/app/web/charts/[id]/page.tsx
new file mode 100644
index 0000000..9ce51cd
--- /dev/null
+++ b/app/web/charts/[id]/page.tsx
@@ -0,0 +1,34 @@
+import { NextPage } from 'next';
+import { headers } from 'next/headers';
+import { notFound } from 'next/navigation';
+import { ChartPage } from '../../components/ChartPage';
+
+const Page: NextPage = async () => {
+ const headersList = headers();
+ const query = new URLSearchParams(headersList.get('x-search') as string);
+ const chartId = (headersList.get('x-pathname') as string)
+ .split('/')
+ .at(-1) as string;
+
+ const res = await fetch(`https://chart.clashperk.com/${chartId}/json`);
+ const data = await res.json();
+
+ if (!res.ok) {
+ return notFound();
+ }
+
+ return (
+ <>
+
+ >
+ );
+};
+
+export default Page;
diff --git a/app/web/clans/[tag]/capital-contribution/page.tsx b/app/web/clans/[tag]/capital-contribution/page.tsx
new file mode 100644
index 0000000..d059f09
--- /dev/null
+++ b/app/web/clans/[tag]/capital-contribution/page.tsx
@@ -0,0 +1,15 @@
+import { CapitalContributionPage } from '@/app/web/components/CapitalContributionPage';
+import { getSignedToken } from '@/util/access-token.helper';
+import { NextPage } from 'next';
+
+const Page: NextPage = async () => {
+ const result = await getSignedToken();
+
+ return (
+ <>
+
+ >
+ );
+};
+
+export default Page;
diff --git a/app/web/clans/[tag]/page.tsx b/app/web/clans/[tag]/page.tsx
new file mode 100644
index 0000000..f6f5400
--- /dev/null
+++ b/app/web/clans/[tag]/page.tsx
@@ -0,0 +1,7 @@
+import { NextPage } from 'next';
+
+const Page: NextPage = () => {
+ return <>>;
+};
+
+export default Page;
diff --git a/app/web/clans/[tag]/wars/[id]/page.tsx b/app/web/clans/[tag]/wars/[id]/page.tsx
new file mode 100644
index 0000000..8a44b5b
--- /dev/null
+++ b/app/web/clans/[tag]/wars/[id]/page.tsx
@@ -0,0 +1,15 @@
+import { AttackLogPage } from '@/app/web/components/AttackLogPage';
+import { getSignedToken } from '@/util/access-token.helper';
+import { NextPage } from 'next';
+
+const Page: NextPage = async () => {
+ const result = await getSignedToken();
+
+ return (
+ <>
+
+ >
+ );
+};
+
+export default Page;
diff --git a/app/web/components/AttackLog.tsx b/app/web/components/AttackLog.tsx
new file mode 100644
index 0000000..64611f3
--- /dev/null
+++ b/app/web/components/AttackLog.tsx
@@ -0,0 +1,222 @@
+import Avatar from '@mui/material/Avatar';
+import Paper from '@mui/material/Paper';
+import Stack from '@mui/material/Stack';
+import Table from '@mui/material/Table';
+import TableBody from '@mui/material/TableBody';
+import TableCell from '@mui/material/TableCell';
+import TableRow from '@mui/material/TableRow';
+import Typography from '@mui/material/Typography';
+import moment from 'moment';
+import { getStars } from './Stars';
+
+interface ClanWar {
+ endTime: string;
+ result: string;
+ clan: {
+ stars: number;
+ destructionPercentage: number;
+ name: string;
+ tag: string;
+ members: {
+ name: string;
+ tag: string;
+ mapPosition: number;
+ townhallLevel: number;
+ attacks: {
+ stars: number;
+ oldStars: number;
+ destructionPercentage: number;
+ order: number;
+ defender: {
+ name: string;
+ tag: string;
+ mapPosition: number;
+ townhallLevel: number;
+ };
+ }[];
+ defenses: {
+ stars: number;
+ destructionPercentage: number;
+ order: number;
+ defender: {
+ name: string;
+ tag: string;
+ mapPosition: number;
+ townhallLevel: number;
+ };
+ }[];
+ }[];
+ };
+ opponent: {
+ name: string;
+ tag: string;
+ };
+}
+
+export default function AttackLog({ data }: { data: ClanWar }) {
+ return (
+
+
+
+
+ {data.clan.name} vs {data.opponent.name}
+
+
+
+ {data.clan.stars} stars,{' '}
+ {data.clan.destructionPercentage.toFixed(2)}% destruction (
+ {data.result}) | {moment(data.endTime).format('D MMM YYYY')}
+
+
+
+
+
+
+
+
+ {data.clan.members.map((row, index) => (
+
+
+
+ {row.mapPosition}
+
+
+
+
+
+ {row.townhallLevel}
+
+
+
+
+
+ {row.name}
+
+
+
+ {row.tag}
+
+
+
+
+ {(row.attacks ?? []).map((atk, n) => (
+
+ {getStars(atk.oldStars, atk.stars)}
+
+ ))}
+
+
+
+ {row.attacks!.map((atk, n) => (
+
+ {atk.destructionPercentage.toFixed(0)}%
+
+ ))}
+
+
+
+
+ {row.attacks.length > 0 ? 'v' : ''}
+
+
+
+
+ {row.attacks!.map((atk, n) => (
+
+ {atk.defender.mapPosition}
+
+ ))}
+
+
+
+ {row.attacks!.map((atk, n) => (
+
+ {atk.defender.townhallLevel}
+
+ ))}
+
+
+ ))}
+
+
+
+ );
+}
diff --git a/app/web/components/AttackLogPage.tsx b/app/web/components/AttackLogPage.tsx
new file mode 100644
index 0000000..9e2646a
--- /dev/null
+++ b/app/web/components/AttackLogPage.tsx
@@ -0,0 +1,44 @@
+'use client';
+
+import AttackLog from '@/app/web/components/AttackLog';
+import Loader from '@/app/web/components/Loader';
+import Container from '@mui/material/Container';
+import { useParams } from 'next/navigation';
+import * as React from 'react';
+
+export function AttackLogPage({ token }: { token: string }) {
+ const [data, setData] = React.useState();
+ const [loading, setLoading] = React.useState(true);
+ const params = useParams();
+
+ const getWar = async (id: string, clanTag: string) => {
+ const res = await fetch(
+ `${process.env.NEXT_PUBLIC_BACKEND_URL}/clans/${clanTag}/wars/${id}`,
+ {
+ method: 'GET',
+ headers: {
+ 'Content-Type': 'application/json',
+ Authorization: `Bearer ${token}`
+ }
+ }
+ );
+ const data = await res.json();
+ setData(data);
+ setLoading(false);
+ };
+
+ React.useEffect(() => {
+ if (params.id) {
+ getWar(params.id as string, params.tag as string);
+ }
+ }, [params]); // eslint-disable-line react-hooks/exhaustive-deps
+
+ if (!data)
+ return ;
+
+ return (
+
+
+
+ );
+}
diff --git a/app/web/components/CapitalContributionPage.tsx b/app/web/components/CapitalContributionPage.tsx
new file mode 100644
index 0000000..2a0e6cf
--- /dev/null
+++ b/app/web/components/CapitalContributionPage.tsx
@@ -0,0 +1,46 @@
+'use client';
+
+import CapitalDonation from '@/app/web/components/CapitalDonation';
+import Loader from '@/app/web/components/Loader';
+import Container from '@mui/material/Container';
+import { useParams } from 'next/navigation';
+import { useEffect, useState } from 'react';
+
+export function CapitalContributionPage({ token }: { token: string }) {
+ const [loading, setLoading] = useState(true);
+ const [logs, setLogs] = useState([]);
+ const params = useParams();
+
+ const getHistory = async (tag: string) => {
+ const res = await fetch(
+ `${process.env.NEXT_PUBLIC_BACKEND_URL}/clans/${tag}/capital-contribution`,
+ {
+ method: 'GET',
+ headers: {
+ 'Content-Type': 'application/json',
+ Authorization: `Bearer ${token}`
+ }
+ }
+ );
+ const data = await res.json();
+ setLogs(data);
+ setLoading(false);
+ };
+
+ useEffect(() => {
+ if (params.tag) {
+ getHistory(params.tag as string);
+ }
+ }, [params]);
+
+ if (!logs.length)
+ return ;
+
+ return (
+ <>
+
+
+
+ >
+ );
+}
diff --git a/app/web/components/CapitalDonation.tsx b/app/web/components/CapitalDonation.tsx
new file mode 100644
index 0000000..5e4a034
--- /dev/null
+++ b/app/web/components/CapitalDonation.tsx
@@ -0,0 +1,116 @@
+import Paper from '@mui/material/Paper';
+import Stack from '@mui/material/Stack';
+import Table from '@mui/material/Table';
+import TableBody from '@mui/material/TableBody';
+import TableCell from '@mui/material/TableCell';
+import TableRow from '@mui/material/TableRow';
+import Typography from '@mui/material/Typography';
+import moment from 'moment';
+import 'moment-duration-format';
+
+interface Logs {
+ name: string;
+ tag: string;
+ season: string;
+ initial: number;
+ current: number;
+ clan: { name: string; tag: string };
+ createdAt: string;
+}
+
+export default function CapitalDonation({ logs }: { logs: Logs[] }) {
+ const clan = logs[0]?.clan;
+ return (
+
+
+
+
+ {`${clan.name} (${clan.tag})`} | Capital Contribution Logs (last 10
+ days)
+
+
+
+
+
+ {logs.map((row, index) => (
+
+
+
+ {row.name}
+
+
+ {/*
+ {row.warType === 3 ? "CWL" : row.warType === 2 ? "Friendly" : null}
+ */}
+
+ {' '}
+ {moment
+ .duration(Date.now() - new Date(row.createdAt).getTime())
+ .format('d[d] h[h] m[m]', { trim: 'both mid' })}{' '}
+ ago
+
+
+
+
+
+
+ {row.current - row.initial}
+
+
+
+ ))}
+
+
+
+ );
+}
diff --git a/app/web/components/ChartPage.tsx b/app/web/components/ChartPage.tsx
new file mode 100644
index 0000000..39cc02f
--- /dev/null
+++ b/app/web/components/ChartPage.tsx
@@ -0,0 +1,219 @@
+'use client';
+
+import {
+ ArcElement,
+ BarController,
+ BarElement,
+ CategoryScale,
+ Chart as ChartJS,
+ Legend,
+ LinearScale,
+ LineElement,
+ PointElement,
+ TimeScale,
+ Title,
+ Tooltip
+} from 'chart.js';
+import 'chartjs-adapter-moment';
+import Head from 'next/head';
+import { Line } from 'react-chartjs-2';
+
+ChartJS.register(
+ ArcElement,
+ Title,
+ Tooltip,
+ Legend,
+ CategoryScale,
+ LinearScale,
+ BarController,
+ BarElement,
+ PointElement,
+ LineElement,
+ TimeScale
+);
+
+const colors = [
+ '#266ef7',
+ '#c63304',
+ '#ffc107',
+ '#50c878',
+ '#ffac75',
+ '#4dc1fa',
+ '#cb5aff',
+ '#808000',
+ '#C9CBCF',
+ '#FF6384',
+ //
+ '#3A87AD',
+ '#F79256',
+ '#8DC2E9',
+ '#D1AED2',
+ '#62C370',
+ '#E36F8A',
+ '#A4BF96',
+ '#F0D96B',
+ '#4F576C',
+ '#B94C4C',
+ '#5E7D7D',
+ '#9A59B5',
+ '#7EC08C',
+ '#E39C4A',
+ '#31859B',
+ '#C58A6D',
+ '#6D4D9E',
+ '#92A1CF',
+ '#C1B07E',
+ '#486E76'
+];
+
+const randomNum = () => Math.floor(Math.random() * (235 - 52 + 1) + 52);
+const randomRGB = () => `rgb(${randomNum()}, ${randomNum()}, ${randomNum()})`;
+
+export const ChartPage = ({
+ labels,
+ datasets,
+ unit,
+ offset = 0,
+ title,
+ desktopOnly
+}: {
+ id: string;
+ unit: string;
+ offset: number;
+ title: string;
+ labels: string[];
+ desktopOnly: boolean;
+ datasets: { name: string; data: number[] }[];
+}) => {
+ return (
+ <>
+ {desktopOnly && (
+
+
+
+ )}
+
+ = 24 ? labels.length / 2 : labels.length,
+ font: function (context) {
+ if (context.tick && context.tick.major)
+ return { weight: 'bold' };
+ }
+ },
+ grid: {
+ display: true
+ },
+ type: 'time',
+ time: {
+ unit: (unit || 'hour') as 'hour' | 'day',
+ parser: function (ts) {
+ return new Date(ts as string).getTime() - offset;
+ }
+ }
+ },
+ y: {
+ display: true,
+ ticks: {
+ // count: 10,
+ color: '#000000',
+ precision: 0
+ },
+ border: {
+ display: false
+ },
+ grid: {
+ display: true
+ }
+ },
+ y1: {
+ display: true,
+ position: 'right',
+ ticks: {
+ callback: function (value) {
+ return `${value} ${unit}`;
+ }
+ },
+ afterDataLimits: function (axis) {
+ const y = axis.chart.scales.y;
+ y.determineDataLimits(); // update y.min/max
+ axis.min = y.min;
+ axis.max = y.max;
+ }
+ }
+ },
+ plugins: {
+ legend: {
+ display: true,
+ position: 'top',
+ labels: {
+ font: {
+ size: 14
+ },
+ boxWidth: 14,
+ padding: 4
+ },
+ fullSize: true
+ },
+ title: {
+ position: 'top',
+ display: true,
+ padding: 3,
+ text: title
+ },
+ tooltip: {
+ displayColors: false
+ }
+ }
+ }}
+ title={title}
+ data={{
+ labels: labels,
+ datasets: datasets.slice(0, 20).map((data, idx) => {
+ const color = colors[idx] || randomRGB();
+ return {
+ ...data,
+ label: data.name,
+ tension: 0.1,
+ cubicInterpolationMode: 'monotone',
+ backgroundColor: color,
+ borderColor: color,
+ borderWidth: 4,
+ pointBorderColor: color,
+ pointBackgroundColor: color,
+ pointRadius: 2
+ };
+ })
+ }}
+ />
+ >
+ );
+};
diff --git a/app/web/components/History.tsx b/app/web/components/History.tsx
new file mode 100644
index 0000000..31787ba
--- /dev/null
+++ b/app/web/components/History.tsx
@@ -0,0 +1,434 @@
+import StarIcon from '@mui/icons-material/Star';
+import Avatar from '@mui/material/Avatar';
+import Paper from '@mui/material/Paper';
+import Stack from '@mui/material/Stack';
+import Table from '@mui/material/Table';
+import TableBody from '@mui/material/TableBody';
+import TableCell from '@mui/material/TableCell';
+import TableHead from '@mui/material/TableHead';
+import TableRow from '@mui/material/TableRow';
+import Typography from '@mui/material/Typography';
+import moment from 'moment';
+import { getStars } from './Stars';
+
+interface WarHistory {
+ id: number;
+ warType: number;
+ startTime: string;
+ endTime: string;
+ clan: {
+ name: string;
+ tag: string;
+ };
+ opponent: {
+ name: string;
+ tag: string;
+ };
+ attacker: {
+ name: string;
+ tag: string;
+ townhallLevel: number;
+ mapPosition: number;
+ };
+ attacks: {
+ stars: number;
+ oldStars: number;
+ defenderTag: string;
+ destructionPercentage: number;
+ defender: {
+ tag: string;
+ townhallLevel: number;
+ mapPosition: number;
+ };
+ }[];
+}
+
+interface Summary {
+ season: string;
+ wars: number;
+ rounds: number;
+ stars: number;
+ attacks: number;
+ missed: number;
+ destruction: number;
+}
+
+export default function History({
+ wars,
+ attacker,
+ summary
+}: {
+ wars: WarHistory[];
+ attacker: WarHistory['attacker'];
+ summary: Summary[];
+}) {
+ return (
+
+
+
+
+ {`${attacker.name} (${attacker.tag})`}
+
+
+ 12 ? `-5` : ''
+ }.png`}
+ variant="square"
+ />
+
+
+
+ {wars.map((row, index) => (
+
+
+
+ {row.clan.name} vs {row.opponent.name}
+
+
+
+ {row.warType === 3
+ ? 'CWL'
+ : row.warType === 2
+ ? 'Friendly'
+ : null}
+
+
+ {' '}
+ {moment(new Date(row.startTime)).format('D MMM YYYY')}
+
+
+
+
+
+ {row.attacks.map((atk, i) => (
+
+ {row.attacker.mapPosition}
+
+ ))}
+
+
+
+ {row.attacks.map((atk, i) => (
+
+ {row.attacker.townhallLevel}
+
+ ))}
+
+
+
+ {row.attacks!.map((atk) => (
+
+ {getStars(atk.oldStars, atk.stars)}
+
+ ))}
+
+
+
+ {row.attacks!.map((atk, i) => (
+
+ {atk.destructionPercentage.toFixed(0)}%
+
+ ))}
+
+
+
+
+ {row.attacks.length > 0 ? 'v' : null}
+
+
+
+
+ {row.attacks!.map((atk, i) => (
+
+ {atk.defender.mapPosition}
+
+ ))}
+
+
+
+ {row.attacks!.map((atk, i) => (
+
+ {atk.defender.townhallLevel}
+
+ ))}
+
+
+ ))}
+
+
+
+ {summary.length > 0 && (
+ <>
+
+
+ CWL Summary
+
+
+
+
+
+
+
+ Season
+
+
+
+
+ Star
+
+
+
+
+ Destruction
+
+
+
+
+ Missed
+
+
+
+
+ Wars
+
+
+ {/*
+
+ Wars
+
+ */}
+
+
+
+ {summary.map((row, n) => (
+
+
+
+ {moment(row.season).format('MMMM YYYY')}
+
+
+
+
+
+
+
+ {row.stars}
+
+
+
+
+
+
+ {row.destruction.toFixed(0)}%
+
+
+
+
+ 0 ? 'red' : '#dcddde',
+ fontSize: '12px'
+ }}
+ >
+ {row.missed}
+
+
+
+
+
+ {row.wars}
+
+
+
+ {/*
+
+ {`${row.wars}`}
+
+ */}
+
+ ))}
+
+
+ >
+ )}
+
+ );
+}
+
+export const fakeWar = Array.from({ length: 50 }).map((_, index) => {
+ const oldStars = Math.floor(Math.random() * 3);
+ const newStars = Math.floor(Math.random() * 3);
+ const percentage = Math.floor(Math.random() * 100);
+
+ return {
+ oldStars,
+ newStars,
+ percentage,
+ townHall: Math.floor(Math.random() * 5) + 10,
+ opponent: {
+ townHall: Math.floor(Math.random() * 5) + 10,
+ index: Math.floor(Math.random() * 50)
+ }
+ };
+});
+
+// export const fakeRounds = Array.from({ length: 10 }).map((_, index) => {
+// // generate random name
+// const name = Math.random().toString(36).substring(7);
+// return {
+// name: "Air Hounds vs War Snippers",
+// date: "10/10/2022",
+// index: Math.floor(Math.random() * 50),
+// war: fakeWar,
+// };
+// });
diff --git a/app/web/components/Loader.tsx b/app/web/components/Loader.tsx
new file mode 100644
index 0000000..05863f0
--- /dev/null
+++ b/app/web/components/Loader.tsx
@@ -0,0 +1,60 @@
+import Paper from '@mui/material/Paper';
+import Skeleton from '@mui/material/Skeleton';
+import Typography from '@mui/material/Typography';
+import { Container, Stack } from '@mui/system';
+
+const Loader = ({
+ loading,
+ message
+}: {
+ loading: boolean;
+ message?: string;
+}) => {
+ return (
+
+
+
+
+
+
+
+ {!loading && message && (
+ {message}
+ )}
+
+
+ {Array(20)
+ .fill(0)
+ .map((_, i) => (
+
+
+
+
+
+ ))}
+
+
+
+
+
+
+
+ );
+};
+export default Loader;
diff --git a/app/web/components/PlayerPage.tsx b/app/web/components/PlayerPage.tsx
new file mode 100644
index 0000000..82404fc
--- /dev/null
+++ b/app/web/components/PlayerPage.tsx
@@ -0,0 +1,71 @@
+'use client';
+
+import History from '@/app/web/components/History';
+import Loader from '@/app/web/components/Loader';
+import Container from '@mui/material/Container';
+import { useParams } from 'next/navigation';
+import { useEffect, useState } from 'react';
+
+export function PlayerPage({ token }: { token: string }) {
+ const [loading, setLoading] = useState(true);
+ const [wars, setWars] = useState([]);
+ const [summary, setSummary] = useState([]);
+ const params = useParams();
+
+ const getHistory = async (tag: string) => {
+ const res = await fetch(
+ `${process.env.NEXT_PUBLIC_BACKEND_URL}/players/${tag}/wars?months=6`,
+ {
+ method: 'GET',
+ headers: {
+ 'Content-Type': 'application/json',
+ Authorization: `Bearer ${token}`
+ }
+ }
+ );
+ const wars = await res.json();
+ setWars(wars);
+ };
+
+ const getCwlSummary = async (tag: string) => {
+ const res = await fetch(
+ `${process.env.NEXT_PUBLIC_BACKEND_URL}/players/${tag}/cwl-stats`,
+ {
+ method: 'GET',
+ headers: {
+ 'Content-Type': 'application/json',
+ Authorization: `Bearer ${token}`
+ }
+ }
+ );
+ const summary = await res.json();
+ setSummary(summary);
+ };
+
+ const fetchData = async (playerTag: string) => {
+ try {
+ await Promise.all([getHistory(playerTag), getCwlSummary(playerTag)]);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ useEffect(() => {
+ if (params.tag) {
+ fetchData(params.tag as string);
+ }
+ }, [params]); // eslint-disable-line react-hooks/exhaustive-deps
+
+ if (!wars.length)
+ return ;
+
+ const attacker = wars?.[0]?.attacker;
+
+ return (
+ <>
+
+
+
+ >
+ );
+}
diff --git a/app/web/components/Stars.tsx b/app/web/components/Stars.tsx
new file mode 100644
index 0000000..5bcef1b
--- /dev/null
+++ b/app/web/components/Stars.tsx
@@ -0,0 +1,48 @@
+import StarIcon from '@mui/icons-material/Star';
+import * as React from 'react';
+
+export const STARS: Record = {
+ NEW: ,
+ OLD: (
+
+ ),
+ EMPTY: (
+
+ )
+};
+
+export const WAR_STARS = {
+ OLD: 'OLD-',
+ NEW: 'NEW-',
+ EMPTY: 'EMPTY-'
+};
+
+export function getStars(oldStars: number, newStars: number) {
+ if (oldStars > newStars) {
+ return [
+ WAR_STARS.OLD.repeat(newStars),
+ WAR_STARS.EMPTY.repeat(3 - newStars)
+ ]
+ .join('')
+ .split('-')
+ .filter((_) => _.length)
+ .map((star, i) => (
+
+ {STARS[star as keyof typeof STARS]}
+
+ ));
+ }
+ return [
+ WAR_STARS.OLD.repeat(oldStars),
+ WAR_STARS.NEW.repeat(newStars - oldStars),
+ WAR_STARS.EMPTY.repeat(3 - newStars)
+ ]
+ .join('')
+ .split('-')
+ .filter((_) => _.length)
+ .map((star, i) => (
+
+ {STARS[star as keyof typeof STARS]}
+
+ ));
+}
diff --git a/app/web/players/[tag]/page.tsx b/app/web/players/[tag]/page.tsx
new file mode 100644
index 0000000..f6f5400
--- /dev/null
+++ b/app/web/players/[tag]/page.tsx
@@ -0,0 +1,7 @@
+import { NextPage } from 'next';
+
+const Page: NextPage = () => {
+ return <>>;
+};
+
+export default Page;
diff --git a/app/web/players/[tag]/wars/page.tsx b/app/web/players/[tag]/wars/page.tsx
new file mode 100644
index 0000000..fd0fbaa
--- /dev/null
+++ b/app/web/players/[tag]/wars/page.tsx
@@ -0,0 +1,15 @@
+import { PlayerPage } from '@/app/web/components/PlayerPage';
+import { getSignedToken } from '@/util/access-token.helper';
+import { NextPage } from 'next';
+
+const Page: NextPage = async () => {
+ const result = await getSignedToken();
+
+ return (
+ <>
+
+ >
+ );
+};
+
+export default Page;
diff --git a/next.config.mjs b/next.config.mjs
index 7f51c06..52d0b45 100644
--- a/next.config.mjs
+++ b/next.config.mjs
@@ -23,7 +23,32 @@ const nextConfig = {
source: '/guide',
destination: 'https://docs.clashperk.com/overview/getting-set-up',
permanent: true
- }
+ },
+ {
+ source: '/clans/:path*',
+ destination: '/web/clans/:path*',
+ permanent: true
+ },
+ {
+ source: '/charts/:path*',
+ destination: '/web/charts/:path*',
+ permanent: true
+ },
+ {
+ source: '/players/:path*',
+ destination: '/web/players/:path*',
+ permanent: true
+ },
+ {
+ source: '/members/:tag',
+ destination: '/web/players/:tag/wars',
+ permanent: true
+ },
+ {
+ source: '/capital/:tag',
+ destination: '/web/clans/:tag/capital-contribution',
+ permanent: true
+ },
];
}
};
diff --git a/package-lock.json b/package-lock.json
index 761b162..7244a86 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -17,14 +17,20 @@
"@mui/material-nextjs": "^5.16.6",
"@mui/x-charts": "^7.12.1",
"@mui/x-date-pickers": "^7.12.0",
+ "@types/moment-duration-format": "^2.2.6",
+ "chart.js": "^4.4.5",
+ "chartjs-adapter-moment": "^1.0.1",
"cookies-next": "^4.2.1",
"dayjs": "^1.11.12",
"discord-api-types": "^0.37.101",
"framer-motion": "^11.3.24",
"jsonwebtoken": "^9.0.2",
+ "moment": "^2.30.1",
+ "moment-duration-format": "^2.3.2",
"next": "14.2.5",
"radash": "^12.1.0",
"react": "^18.3.1",
+ "react-chartjs-2": "^5.2.0",
"react-dom": "^18.3.1",
"react-hot-toast": "^2.4.1",
"react-icons": "^5.3.0"
@@ -602,6 +608,12 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
+ "node_modules/@kurkle/color": {
+ "version": "0.3.2",
+ "resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.2.tgz",
+ "integrity": "sha512-fuscdXJ9G1qb7W8VdHi+IwRqij3lBkosAm4ydQtEmbY58OzHXqQhvlxqEkoz0yssNVn38bcpRWgA9PP+OGoisw==",
+ "license": "MIT"
+ },
"node_modules/@mui/base": {
"version": "5.0.0-beta.40",
"resolved": "https://registry.npmjs.org/@mui/base/-/base-5.0.0-beta.40.tgz",
@@ -1417,6 +1429,15 @@
"@types/node": "*"
}
},
+ "node_modules/@types/moment-duration-format": {
+ "version": "2.2.6",
+ "resolved": "https://registry.npmjs.org/@types/moment-duration-format/-/moment-duration-format-2.2.6.tgz",
+ "integrity": "sha512-Qw+6ys3sQCatqukjoIBsL1UJq6S7ep4ixrNvDkdViSgzw2ZG3neArXNu3ww7mEG8kwP32ZDoON/esWb7OUckRQ==",
+ "license": "MIT",
+ "dependencies": {
+ "moment": ">=2.14.0"
+ }
+ },
"node_modules/@types/node": {
"version": "20.14.14",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.14.14.tgz",
@@ -2091,6 +2112,28 @@
"url": "https://github.com/chalk/chalk?sponsor=1"
}
},
+ "node_modules/chart.js": {
+ "version": "4.4.5",
+ "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.4.5.tgz",
+ "integrity": "sha512-CVVjg1RYTJV9OCC8WeJPMx8gsV8K6WIyIEQUE3ui4AR9Hfgls9URri6Ja3hyMVBbTF8Q2KFa19PE815gWcWhng==",
+ "license": "MIT",
+ "dependencies": {
+ "@kurkle/color": "^0.3.0"
+ },
+ "engines": {
+ "pnpm": ">=8"
+ }
+ },
+ "node_modules/chartjs-adapter-moment": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/chartjs-adapter-moment/-/chartjs-adapter-moment-1.0.1.tgz",
+ "integrity": "sha512-Uz+nTX/GxocuqXpGylxK19YG4R3OSVf8326D+HwSTsNw1LgzyIGRo+Qujwro1wy6X+soNSnfj5t2vZ+r6EaDmA==",
+ "license": "MIT",
+ "peerDependencies": {
+ "chart.js": ">=3.0.0",
+ "moment": "^2.10.2"
+ }
+ },
"node_modules/client-only": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz",
@@ -4549,6 +4592,21 @@
"node": ">=16 || 14 >=14.17"
}
},
+ "node_modules/moment": {
+ "version": "2.30.1",
+ "resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz",
+ "integrity": "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==",
+ "license": "MIT",
+ "engines": {
+ "node": "*"
+ }
+ },
+ "node_modules/moment-duration-format": {
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/moment-duration-format/-/moment-duration-format-2.3.2.tgz",
+ "integrity": "sha512-cBMXjSW+fjOb4tyaVHuaVE/A5TqkukDWiOfxxAjY+PEqmmBQlLwn+8OzwPiG3brouXKY5Un4pBjAeB6UToXHaQ==",
+ "license": "MIT"
+ },
"node_modules/ms": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
@@ -5039,6 +5097,16 @@
"node": ">=0.10.0"
}
},
+ "node_modules/react-chartjs-2": {
+ "version": "5.2.0",
+ "resolved": "https://registry.npmjs.org/react-chartjs-2/-/react-chartjs-2-5.2.0.tgz",
+ "integrity": "sha512-98iN5aguJyVSxp5U3CblRLH67J8gkfyGNbiK3c+l1QI/G4irHMPQw44aEPmjVag+YKTyQ260NcF82GTQ3bdscA==",
+ "license": "MIT",
+ "peerDependencies": {
+ "chart.js": "^4.1.1",
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0"
+ }
+ },
"node_modules/react-dom": {
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
diff --git a/package.json b/package.json
index 9fbe5dc..73b3838 100644
--- a/package.json
+++ b/package.json
@@ -6,8 +6,7 @@
"dev": "next dev",
"build": "next build",
"start": "next start",
- "lint": "next lint",
- "generate": "graphql-codegen"
+ "lint": "next lint --fix"
},
"dependencies": {
"@emotion/cache": "^11.13.1",
@@ -19,14 +18,20 @@
"@mui/material-nextjs": "^5.16.6",
"@mui/x-charts": "^7.12.1",
"@mui/x-date-pickers": "^7.12.0",
+ "@types/moment-duration-format": "^2.2.6",
+ "chart.js": "^4.4.5",
+ "chartjs-adapter-moment": "^1.0.1",
"cookies-next": "^4.2.1",
"dayjs": "^1.11.12",
"discord-api-types": "^0.37.101",
"framer-motion": "^11.3.24",
"jsonwebtoken": "^9.0.2",
+ "moment": "^2.30.1",
+ "moment-duration-format": "^2.3.2",
"next": "14.2.5",
"radash": "^12.1.0",
"react": "^18.3.1",
+ "react-chartjs-2": "^5.2.0",
"react-dom": "^18.3.1",
"react-hot-toast": "^2.4.1",
"react-icons": "^5.3.0"
@@ -45,4 +50,4 @@
"prettier": "^3.3.3",
"typescript": "^5.5.4"
}
-}
\ No newline at end of file
+}
diff --git a/util/access-token.helper.ts b/util/access-token.helper.ts
new file mode 100644
index 0000000..877c566
--- /dev/null
+++ b/util/access-token.helper.ts
@@ -0,0 +1,52 @@
+'use server';
+
+import jwt from 'jsonwebtoken';
+import { headers } from 'next/headers';
+
+export const getAccessToken = async () => {
+ const headersList = headers();
+ const query = new URLSearchParams(headersList.get('x-search') as string);
+ const authToken = query.get('token');
+
+ const decoded = jwt.verify(
+ authToken as string,
+ process.env.JWT_DECODE_SECRET as string
+ ) as {
+ guild_id: string;
+ user_id: string;
+ };
+
+ const token = jwt.sign(
+ {
+ jti: 'vercel-uid',
+ sub: decoded.user_id,
+ roles: ['viewer', 'user'],
+ version: 'v1'
+ },
+ process.env.JWT_SECRET_V2 as string,
+ {
+ expiresIn: '100m'
+ }
+ );
+
+ return {
+ token,
+ userId: decoded.user_id,
+ guildId: decoded.guild_id,
+ isPublicBot: query.get('bot') !== 'custom'
+ };
+};
+
+export const getSignedToken = async () => {
+ const token = jwt.sign(
+ { jti: 'vercel-uid', sub: 'vercel-user', roles: ['viewer'], version: 'v1' },
+ process.env.JWT_SECRET_V2 as string,
+ {
+ expiresIn: '1m'
+ }
+ );
+
+ return {
+ token
+ };
+};