feat: cc account tracking

This commit is contained in:
TrèsAbhi committed 2024-04-28 18:43:12 -05:00
1 parent 6ae86a7a70
commit d2b18360ed
5 files changed
+260 -282

No files matched your search

+62
View File
@@ -0,0 +1,62 @@
'use client';
import { Flex, Heading, Text } from '@radix-ui/themes';
import { useRouter } from 'next/navigation';
import { useEffect } from 'react';
import PageWrapper from '../../../components/PageWrapper';
import { mutateApp } from '../../../stores/app';
export default function Page({
params,
searchParams,
}: {
params: { provider: string };
searchParams: { [key: string]: string };
}) {
const router = useRouter();
useEffect(() => {
switch (params.provider) {
case 'wargaming': {
if (
!searchParams.expires_at ||
!searchParams.access_token ||
!searchParams.account_id
) {
break;
}
mutateApp((draft) => {
draft.login = {
expiresAt: Number(searchParams.expires_at),
id: Number(searchParams.account_id),
token: searchParams.access_token,
};
});
break;
}
default: {
console.error(`Unknown provider: ${params.provider}`);
}
}
router.push(searchParams.return ? searchParams.return : '/');
});
return (
<PageWrapper>
<Flex
style={{ flex: 1 }}
align="center"
justify="center"
direction="column"
gap="2"
>
<Heading>Verifying you...</Heading>
<Text color="gray">You'll be on your way shortly.</Text>
</Flex>
</PageWrapper>
);
}
+161 -240
View File
@@ -1,269 +1,190 @@
'use client';
import {
CaretDownIcon,
CopyIcon,
PersonIcon,
ReloadIcon,
} from '@radix-ui/react-icons';
import { MagnifyingGlassIcon } from '@radix-ui/react-icons';
import {
AlertDialog,
Button,
Checkbox,
DropdownMenu,
Card,
Flex,
Heading,
Link,
Text,
TextField,
} from '@radix-ui/themes';
import { debounce } from 'lodash';
import { ChangeEvent, useEffect, useRef, useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import PageWrapper from '../../../components/PageWrapper';
import { UNLOCALIZED_REGION_NAMES } from '../../../constants/regions';
import { UNLOCALIZED_REGION_NAMES_SHORT } from '../../../constants/regions';
import { WARGAMING_APPLICATION_ID } from '../../../constants/wargamingApplicationID';
import { getAccountInfo } from '../../../core/blitz/getAccountInfo';
import { idToRegion } from '../../../core/blitz/idToRegion';
import searchPlayersAcrossRegions, {
AccountListWithServer,
} from '../../../core/blitz/searchPlayersAcrossRegions';
import { resetSession } from '../../../core/blitzkit/resetSession';
import { setSession } from '../../../core/blitzkit/setSession';
import { useSession } from '../../../stores/session';
import SessionPage from '../../embeds/session/page';
import { Menu } from './components/Menu';
import * as styles from './page.css';
import { useApp } from '../../../stores/app';
import mutateSession, {
SessionTracking,
useSession,
} from '../../../stores/session';
export default function Page() {
const [showSearch, setShowSearch] = useState(false);
const [searching, setSearching] = useState(false);
const [searchResults, setSearchResults] = useState<AccountListWithServer>([]);
const [showFurtherVerification, setShowFurtherVerification] = useState(false);
const input = useRef<HTMLInputElement>(null);
const [searchResults, setSearchResults] = useState<
AccountListWithServer | undefined
>(undefined);
const [showSearchResults, setShowSearchResults] = useState(false);
const handleChange = debounce(
async (event: ChangeEvent<HTMLInputElement>) => {
if (event.target.value) {
setSearchResults(await searchPlayersAcrossRegions(event.target.value));
} else {
setSearchResults(undefined);
setShowSearchResults(false);
}
},
500,
);
const session = useSession();
const login = useApp((state) => state.login);
const search = debounce(async () => {
if (!input.current) return;
setSearchResults(await searchPlayersAcrossRegions(input.current.value, 9));
setSearching(false);
}, 500);
useEffect(() => {
if (session.isTracking) input.current!.value = session.nickname;
}, [session]);
(async () => {
if (!session.tracking) return;
const accountInfo = await getAccountInfo(
idToRegion(session.player.id),
session.player.id,
);
if (accountInfo === null) {
if (login?.id === session.player.id) {
} else {
setShowFurtherVerification(true);
}
}
})();
}, [session.tracking && session.player.id]);
return (
<PageWrapper color="blue">
<div className={styles.toolBar}>
<div style={{ flex: 1, boxSizing: 'border-box', position: 'relative' }}>
<TextField.Root
defaultValue={session.isTracking ? session.nickname : undefined}
ref={input}
onChange={(event) => {
event.stopPropagation();
<PageWrapper>
{/* omit totally to avoid accessing undefined player */}
{showFurtherVerification && (
<AlertDialog.Root open>
<AlertDialog.Content>
<AlertDialog.Title>We need further verification</AlertDialog.Title>
<AlertDialog.Description>
You're trying to track a CC account with private statistics.
Please sign in to verify the ownership of this account.
</AlertDialog.Description>
if (event.target.value) {
setShowSearchResults(true);
setSearchResults(undefined);
} else {
setShowSearchResults(false);
}
handleChange(event);
}}
onBlur={(event) => {
// TODO: remove this hack when https://github.com/radix-ui/primitives/issues/2193 (https://github.com/radix-ui/primitives/issues/1969) is fixed
if (showSearchResults) event.target.focus();
}}
onKeyDown={(event) => {
if (event.key === 'Escape') {
setShowSearchResults(false);
input.current?.blur();
}
}}
placeholder="Search for a player..."
>
<TextField.Slot>
<PersonIcon height="16" width="16" />
</TextField.Slot>
</TextField.Root>
<DropdownMenu.Root open={showSearchResults} modal={false}>
<DropdownMenu.Trigger>
<div />
</DropdownMenu.Trigger>
<DropdownMenu.Content>
{searchResults === undefined ? (
<DropdownMenu.Item disabled>Searching...</DropdownMenu.Item>
) : searchResults.length === 0 ? (
<DropdownMenu.Item disabled>No results</DropdownMenu.Item>
) : (
searchResults?.map(
({ account_id: id, nickname, region }, index) => (
<DropdownMenu.Item
key={id}
onClick={() => {
setShowSearchResults(false);
setSession(region, id, nickname);
}}
shortcut={UNLOCALIZED_REGION_NAMES[region]}
>
{nickname}
</DropdownMenu.Item>
),
)
)}
</DropdownMenu.Content>
</DropdownMenu.Root>
</div>
<div style={{ display: 'flex', gap: 8 }}>
<AlertDialog.Root>
<AlertDialog.Trigger>
<Button
variant="soft"
onClick={(event) => {
if (!session.resetPrompt) {
event.preventDefault();
resetSession();
}
}}
className={styles.toolbarButton}
color="red"
>
<ReloadIcon width="16" height="16" /> Reset
</Button>
</AlertDialog.Trigger>
<AlertDialog.Content>
<Flex direction="column" gap="4">
<Flex direction="column" gap="0">
<AlertDialog.Title>
Are you sure you want to reset?
</AlertDialog.Title>
<AlertDialog.Description>
This'll discard your current session and start tracking from
scratch again.
</AlertDialog.Description>
</Flex>
<Text size="2">
<Checkbox
mr="1"
onCheckedChange={(checked) =>
useSession.setState({
resetPrompt: !checked,
})
}
/>
Don't ask me again
</Text>
</Flex>
<Flex gap="3" mt="4">
<AlertDialog.Cancel>
<Button variant="soft" color="gray">
Cancel
</Button>
</AlertDialog.Cancel>
<AlertDialog.Action>
<Button variant="solid" color="red" onClick={resetSession}>
Reset
</Button>
</AlertDialog.Action>
</Flex>
</AlertDialog.Content>
</AlertDialog.Root>
<AlertDialog.Root>
<AlertDialog.Trigger>
<Button
variant="soft"
className={styles.toolbarButton}
onClick={(event) => {
if (!session.embedPrompt) {
event.preventDefault();
resetSession();
}
navigator.clipboard.writeText(
`${location.origin}/embeds/session`,
);
}}
>
<CopyIcon width="16" height="16" /> Embed
</Button>
</AlertDialog.Trigger>
<AlertDialog.Content>
<AlertDialog.Title>
Copied link to your clipboard
</AlertDialog.Title>
<AlertDialog.Description>
Instructions for streamers:
<iframe
style={{
width: '100%',
aspectRatio: '16 / 9',
border: 'none',
}}
src="https://www.youtube-nocookie.com/embed/e--GwjyrJAE?si=ftEiCmpbMI2FUY5N"
title="YouTube video player"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
></iframe>
{login && (
<AlertDialog.Description mt="2" color="red">
You are currently signed with a different account.
</AlertDialog.Description>
)}
<Text size="2">
<Checkbox
mr="1"
onCheckedChange={(checked) =>
useSession.setState({
embedPrompt: !checked,
})
}
/>
Don't show this again
</Text>
<Flex gap="3" mt="4">
<AlertDialog.Cancel>
<Button variant="soft">Got it!</Button>
</AlertDialog.Cancel>
</Flex>
</AlertDialog.Content>
</AlertDialog.Root>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<Button variant="soft" className={styles.toolbarButton}>
Options
<CaretDownIcon />
</Button>
</DropdownMenu.Trigger>
<Menu Builder={DropdownMenu} reset={resetSession} />
</DropdownMenu.Root>
</div>
</div>
<SessionPage naked={false} />
{!session.isTracking && (
<Flex
align="center"
justify="center"
direction="column"
style={{ flex: 1 }}
>
<Heading color="gray">No player selected</Heading>
<Text color="gray">
Search for a <PersonIcon /> Player to get started
</Text>
</Flex>
<Flex gap="3" mt="4" justify="end">
<AlertDialog.Cancel>
<Button
variant="soft"
color="gray"
onClick={() => {
mutateSession((draft) => {
draft.tracking = false;
});
setShowFurtherVerification(false);
}}
>
Cancel
</Button>
</AlertDialog.Cancel>
<AlertDialog.Action>
<Link
href={`https://api.worldoftanks.${
(session as SessionTracking).player.region
}/wot/auth/login/?application_id=${WARGAMING_APPLICATION_ID}&redirect_uri=${encodeURIComponent(
`${location.origin}/callback/wargaming/?return=${location.href}`,
)}`}
>
<Button variant="solid">Sign in</Button>
</Link>
</AlertDialog.Action>
</Flex>
</AlertDialog.Content>
</AlertDialog.Root>
)}
<TextField.Root
style={{
position: 'relative',
}}
ref={input}
placeholder="Search players..."
onChange={() => {
if (!input.current) return;
const sanitized = input.current.value.trim();
if (sanitized.length > 0) {
setShowSearch(true);
setSearching(true);
search();
} else {
setShowSearch(false);
}
}}
>
<TextField.Slot>
<MagnifyingGlassIcon />
</TextField.Slot>
{showSearch && (
<Card
mt="2"
style={{
position: 'absolute',
top: '100%',
left: 0,
width: '100%',
}}
>
{searching && (
<Flex justify="center">
<Text color="gray">Searching...</Text>
</Flex>
)}
{!searching && searchResults.length === 0 && (
<Flex justify="center">
<Text color="gray">No players found</Text>
</Flex>
)}
{!searching && searchResults.length > 0 && (
<Flex direction="column" gap="2">
{searchResults.map((player) => (
<Button
key={player.account_id}
variant="ghost"
onClick={() => {
setShowSearch(false);
mutateSession((draft) => {
if (!input.current) return;
draft.tracking = true;
(draft as SessionTracking).player = {
id: player.account_id,
region: player.region,
};
input.current.value = player.nickname;
});
}}
>
{player.nickname} (
{UNLOCALIZED_REGION_NAMES_SHORT[player.region]})
</Button>
))}
</Flex>
)}
</Card>
)}
</TextField.Root>
</PageWrapper>
);
}
+9
View File
@@ -11,3 +11,12 @@ export const UNLOCALIZED_REGION_NAMES: Record<Region, string> = {
asia: 'Asia',
eu: 'Europe',
};
/**
* @deprecated
*/
export const UNLOCALIZED_REGION_NAMES_SHORT: Record<Region, string> = {
com: 'NA',
asia: 'APAC',
eu: 'EU',
};
+12
View File
@@ -1,11 +1,19 @@
import { produce } from 'immer';
import { merge } from 'lodash';
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
interface Login {
id: number;
token: string;
expiresAt: number;
}
export interface App {
devBuildAgreementTime: number;
developerMode: boolean;
darkMode: boolean;
login?: Login;
}
export const useApp = create<App>()(
@@ -18,3 +26,7 @@ export const useApp = create<App>()(
{ name: 'app', merge: (a, b) => merge(b, a) },
),
);
export function mutateApp(recipe: (draft: App) => void) {
useApp.setState(produce(recipe));
}
+16 -42
View File
@@ -2,56 +2,30 @@ import { produce } from 'immer';
import { merge } from 'lodash';
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import { CustomColumnDisplay } from '../app/tools/session/components/CustomColumn';
import { AccentColor, GrayColor } from '../constants/radixColors';
import { Region } from '../constants/regions';
import { NormalizedTankStats } from '../types/tanksStats';
type Session = (
| {
isTracking: false;
}
| {
isTracking: true;
interface SessionBase {}
region: Region;
id: number;
nickname: string;
tankStats: NormalizedTankStats;
time: number;
}
) & {
title: string;
resetPrompt: boolean;
embedPrompt: boolean;
customColumns: {
display: CustomColumnDisplay;
showDelta: boolean;
}[] & { length: 4 };
showTotal: boolean;
showCareer: boolean;
color: AccentColor | GrayColor;
};
export interface SessionTracking extends SessionBase {
tracking: true;
player: {
id: number;
region: Region;
};
}
interface SessionNotTracking extends SessionBase {
tracking: false;
}
type Session = SessionTracking | SessionNotTracking;
export const useSession = create<Session>()(
persist(
(set) => ({
title: 'Untitled session',
isTracking: false,
resetPrompt: true,
showTotal: true,
showCareer: true,
embedPrompt: true,
customColumns: [
{ display: 'battles', showDelta: false },
{ display: 'winrate', showDelta: true },
{ display: 'wn8', showDelta: false },
{ display: 'damage', showDelta: true },
],
color: 'slate',
tracking: false,
}),
{ name: 'session', merge: (a, b) => merge(b, a) },
{ name: 'session-2', merge: (a, b) => merge(b, a) },
),
);