diff --git a/README.md b/README.md index d7376a310..fb20f6371 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,9 @@ +## Visit the website + +### https://blitzkit.app/ + +![BlitzKit website](https://i.imgur.com/m8e39Rv.png) + # BlitzKit [![built with Codeium](https://codeium.com/badges/main)](https://codeium.com) @@ -10,12 +16,6 @@ Go to [application directory for BlitzKit](https://discord.com/application-direc ![BlitzKit application directory](https://i.imgur.com/YWCtOuJ.png) -## Visit the website - -### https://blitzkit.app/ - -![BlitzKit website](https://i.imgur.com/bdYMUCL.png) - ## Visit the documentation website ### https://tresabhi.github.io/blitzkit/ diff --git a/scripts/discoverIds.ts b/scripts/discoverIds.ts index a3ac1643c..320e1cc9d 100644 --- a/scripts/discoverIds.ts +++ b/scripts/discoverIds.ts @@ -58,9 +58,9 @@ while (chunkIndex < preDiscoveredManifest.chunks) { } const regionalIdIndex: Record = { - asia: ids.find((id) => idToRegion(id) === 'asia') ?? MIN_IDS.asia, - com: ids.find((id) => idToRegion(id) === 'com') ?? MIN_IDS.com, - eu: ids.find((id) => idToRegion(id) === 'eu') ?? MIN_IDS.eu, + asia: ids.findLast((id) => idToRegion(id) === 'asia') ?? MIN_IDS.asia, + com: ids.findLast((id) => idToRegion(id) === 'com') ?? MIN_IDS.com, + eu: ids.findLast((id) => idToRegion(id) === 'eu') ?? MIN_IDS.eu, }; const zeroStreak: Record = { asia: 0, com: 0, eu: 0 }; let regionIndex = 0; diff --git a/src/app/api/patreon/auth/[code]/route.ts b/src/app/api/patreon/auth/[code]/route.ts new file mode 100644 index 000000000..9230c4b55 --- /dev/null +++ b/src/app/api/patreon/auth/[code]/route.ts @@ -0,0 +1,17 @@ +import { NextResponse } from 'next/server'; + +export async function GET( + request: Request, + { params }: { params: { code: string } }, +) { + const response = await fetch( + `https://www.patreon.com/api/oauth2/token?code=${params.code}&grant_type=authorization_code&client_id=${process.env.NEXT_PUBLIC_PATREON_CLIENT_ID}&client_secret=${process.env.PATREON_CLIENT_SECRET}&redirect_uri=${process.env.NEXT_PUBLIC_PATREON_REDIRECT_URI}`, + { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + }, + ); + const json = await response.json(); + + return NextResponse.json(json); +} diff --git a/src/app/api/patreon/membership/[token]/route.ts b/src/app/api/patreon/membership/[token]/route.ts new file mode 100644 index 000000000..8c1a42e15 --- /dev/null +++ b/src/app/api/patreon/membership/[token]/route.ts @@ -0,0 +1,75 @@ +import { NextResponse } from 'next/server'; + +interface PatreonMembershipResponse { + data: { + attributes: unknown; + id: `${number}`; + relationships: { + memberships: { + data: { + id: string; // uuid + type: 'member'; + }[]; + }; + }; + type: 'user'; + }; + included: ( + | { + attributes: { + patron_status: + | 'active_patron' + | 'declined_patron' + | 'former_patron' + | null; // null = never a paid member + }; + id: string; // uuid + relationships: { + campaign: { + data: { + id: `${number}`; // id of my campaign to check against + type: 'campaign'; + }; + links: { + related: string; // url + }; + }; + }; + type: 'member'; + } + | { + attributes: {}; + id: `${number}`; + type: 'campaign'; // ignore this discriminator + } + )[]; + links: { + self: 'https://www.patreon.com/api/oauth2/v2/user/124201389'; + }; +} + +export async function GET( + request: Request, + { params }: { params: { token: string } }, +) { + const response = await fetch( + encodeURI( + 'https://www.patreon.com/api/oauth2/v2/identity?include=memberships.campaign&fields[member]=patron_status', + ), + { headers: { Authorization: `Bearer ${params.token}` } }, + ); + + if (!response.ok) return NextResponse.json(false); + + const json = (await response.json()) as PatreonMembershipResponse; + + return NextResponse.json( + json.included.some( + (item) => + item.type === 'member' && + item.relationships.campaign.data.id === + process.env.PATREON_CAMPAIGN_ID && + item.attributes.patron_status === 'active_patron', + ), + ); +} diff --git a/src/app/api/patreon/refresh/[token]/route.ts b/src/app/api/patreon/refresh/[token]/route.ts new file mode 100644 index 000000000..a34e8e376 --- /dev/null +++ b/src/app/api/patreon/refresh/[token]/route.ts @@ -0,0 +1,14 @@ +import { NextResponse } from 'next/server'; + +export async function GET( + request: Request, + { params }: { params: { token: string } }, +) { + const response = await fetch( + `https://www.patreon.com/api/oauth2/token?grant_type=refresh_token&refresh_token=${params.token}&client_id=${process.env.NEXT_PUBLIC_PATREON_CLIENT_ID}&client_secret=${process.env.PATREON_CLIENT_SECRET}`, + { method: 'POST' }, + ); + const json = await response.json(); + + return NextResponse.json(json); +} diff --git a/src/app/callback/[provider]/page.tsx b/src/app/auth/[provider]/page.tsx similarity index 58% rename from src/app/callback/[provider]/page.tsx rename to src/app/auth/[provider]/page.tsx index 1c21aac6c..46cfbf8ca 100644 --- a/src/app/callback/[provider]/page.tsx +++ b/src/app/auth/[provider]/page.tsx @@ -6,6 +6,15 @@ import { useEffect } from 'react'; import PageWrapper from '../../../components/PageWrapper'; import { mutateApp } from '../../../stores/app'; +export interface PatreonAuthResponse { + access_token: string; + expires_in: number; + token_type: 'Bearer'; + scope: string; + refresh_token: string; + version: string; +} + export default function Page({ params, searchParams, @@ -27,22 +36,41 @@ export default function Page({ } mutateApp((draft) => { - draft.login = { - expiresAt: Number(searchParams.expires_at), + draft.logins.wargaming = { id: Number(searchParams.account_id), token: searchParams.access_token, + expires: Number(searchParams.expires_at) * 1000, }; }); break; } + case 'patreon': { + if (!searchParams.code) break; + + // cannot await here because async breaks router.push :( + fetch(`/api/patreon/auth/${searchParams.code}`) + .then((response) => response.json() as Promise) + .then((data) => { + mutateApp((draft) => { + draft.logins.patreon = { + token: data.access_token, + refreshToken: data.refresh_token, + expires: Date.now() + data.expires_in * 1000, + }; + }); + }); + + break; + } + default: { console.error(`Unknown provider: ${params.provider}`); } } - router.push(searchParams.return ? searchParams.return : '/'); + router.push(searchParams.return ?? '/'); }); return ( diff --git a/src/app/components/Checks.tsx b/src/app/components/Checks.tsx index a935d348d..719907fcb 100644 --- a/src/app/components/Checks.tsx +++ b/src/app/components/Checks.tsx @@ -10,17 +10,24 @@ import { } from '@radix-ui/themes'; import { usePathname } from 'next/navigation'; import { useEffect, useState } from 'react'; -import { extendAuth } from '../../core/blitz/extendAuth'; -import { logout } from '../../core/blitz/logout'; +import { extendAuthPatreon } from '../../core/blitz/extendAuthPatreon'; +import { extendAuthWargaming } from '../../core/blitz/extendAuthWargaming'; +import { logoutPatreon } from '../../core/blitz/logoutPatreon'; +import { logoutWargaming } from '../../core/blitz/logoutWargaming'; import isDev from '../../core/blitzkit/isDev'; import { isLocalhost } from '../../core/blitzkit/isLocalhost'; import { CURRENT_POLICIES_AGREEMENT_INDEX, useApp } from '../../stores/app'; const DEV_BUILD_AGREEMENT_COOLDOWN = 8 * 24 * 60 * 60 * 1000; +/** + * Wargaming: 14 (refresh: 7) + * Patreon: 31 (refresh: 15) + */ + export function Checks() { const [showDevBuildAlert, setShowDevBuildAlert] = useState(false); - const login = useApp((state) => state.login); + const logins = useApp((state) => state.logins); const policiesAgreementIndex = useApp( (state) => state.policiesAgreementIndex, ); @@ -43,15 +50,26 @@ export function Checks() { }, [policiesAgreementIndex, isLegal]); useEffect(() => { - if (!login) return; + if (logins.wargaming) { + const expiresIn = logins.wargaming.expires - Date.now(); + const expiresInDays = expiresIn / 1000 / 60 / 60 / 24; - const expiresIn = login.expiresAt - Date.now() / 1000; - const expiresInDays = expiresIn / 60 / 60 / 24; + if (expiresInDays < 0) { + logoutWargaming(); + } else if (expiresInDays < 7) { + extendAuthWargaming(); + } + } - if (expiresInDays < 0) { - logout(); - } else if (expiresInDays < 7) { - extendAuth(); + if (logins.patreon) { + const expiresIn = logins.patreon.expires - Date.now(); + const expiresInDays = expiresIn / 1000 / 60 / 60 / 24; + + if (expiresInDays < 0) { + logoutPatreon(); + } else if (expiresInDays < 15) { + extendAuthPatreon(); + } } }); diff --git a/src/app/components/PatreonPlug.tsx b/src/app/components/PatreonPlug.tsx new file mode 100644 index 000000000..c4b20b900 --- /dev/null +++ b/src/app/components/PatreonPlug.tsx @@ -0,0 +1,47 @@ +'use client'; + +import { CheckIcon } from '@radix-ui/react-icons'; +import { Button, Flex, Link, Text } from '@radix-ui/themes'; +import { patreonLoginUrl } from '../../core/blitzkit/patreonLoginUrl'; +import { useAdExempt } from '../../hooks/useAdExempt'; +import { PatreonIcon } from '../../icons/Patreon'; +import { useApp } from '../../stores/app'; + +export function PatreonPlug() { + const patreon = useApp((state) => state.logins.patreon); + const exempt = useAdExempt(); + + return ( + + + + {exempt + ? 'You are supporting BlitzKit, thank you!' + : 'Active Patreon supports go ad-free'} + + {!exempt && } + + + + + + + {!patreon && !exempt && ( + + + + )} + + + ); +} diff --git a/src/app/components/ToolCard.tsx b/src/app/components/ToolCard.tsx new file mode 100644 index 000000000..5991c70b7 --- /dev/null +++ b/src/app/components/ToolCard.tsx @@ -0,0 +1,142 @@ +import { CaretRightIcon } from '@radix-ui/react-icons'; +import { + Box, + Button, + Flex, + FlexProps, + Heading, + Link, + Text, +} from '@radix-ui/themes'; +import { Tool } from '../../constants/tools'; +import { ImgurSize, imgur } from '../../core/blitzkit/imgur'; + +type ToolCardProps = FlexProps & { + tool: Tool; +}; + +export function ToolCard({ tool, style, ...props }: ToolCardProps) { + const size = tool.significant + ? ImgurSize.HugeThumbnail + : ImgurSize.LargeThumbnail; + + return ( + + + + + + + + {tool.title} + + + {tool.description} + + + + + + + + ); +} diff --git a/src/app/page.tsx b/src/app/page.tsx index cf548f4f4..e30978171 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,146 +1,107 @@ -import { CaretRightIcon } from '@radix-ui/react-icons'; -import { Box, Button, Flex, Grid, Heading, Text } from '@radix-ui/themes'; -import Link from 'next/link'; -import PageWrapper from '../components/PageWrapper'; -import { TOOLS } from '../constants/tools'; -import { ImgurSize, imgur } from '../core/blitzkit/imgur'; +'use client'; + +import { Box, Flex } from '@radix-ui/themes'; +import { Ad, AdType } from '../components/Ad'; +import { AdMidSectionResponsive } from '../components/AdMidSectionResponsive'; +import { + compareTool, + discordTool, + moreTool, + ratingTool, + sessionTool, + tankopediaTool, +} from '../constants/tools'; +import { useAdExempt } from '../hooks/useAdExempt'; import { Hero } from './components/Hero'; +import { PatreonPlug } from './components/PatreonPlug'; +import { ToolCard } from './components/ToolCard'; export default function Page() { + const exempt = useAdExempt(); + return ( <> - - - {TOOLS.map((tool) => { - const size = tool.significant - ? ImgurSize.HugeThumbnail - : ImgurSize.LargeThumbnail; + - return ( - - - + + {!exempt && ( + + )} - - - - {tool.title} - - - {tool.description} - - + + - - - - - ); - })} - - + {!exempt && ( + + + + )} + + + + + + + + + + + + {!exempt && ( + + + + )} + + + + + + + + {!exempt && ( + + )} + + + {!exempt && }
diff --git a/src/app/tools/tankopedia/[id]/components/Characteristics/index.tsx b/src/app/tools/tankopedia/[id]/components/Characteristics/index.tsx index e47201692..be696b587 100644 --- a/src/app/tools/tankopedia/[id]/components/Characteristics/index.tsx +++ b/src/app/tools/tankopedia/[id]/components/Characteristics/index.tsx @@ -11,6 +11,7 @@ import { import { debounce } from 'lodash'; import { Fragment, use, useEffect, useRef, useState } from 'react'; import { lerp } from 'three/src/math/MathUtils'; +import { Ad, AdType } from '../../../../../../components/Ad'; import { Link } from '../../../../../../components/Link'; import { applyPitchYawLimits } from '../../../../../../core/blitz/applyPitchYawLimits'; import { isExplosive } from '../../../../../../core/blitz/isExplosive'; @@ -25,15 +26,15 @@ import { CREW_MEMBER_NAMES, GUN_TYPE_NAMES, } from '../../../../../../core/blitzkit/tankDefinitions/constants'; +import { useAdExempt } from '../../../../../../hooks/useAdExempt'; import { useEquipment } from '../../../../../../hooks/useEquipment'; -import { useWideFormat } from '../../../../../../hooks/useWideFormat'; import { mutateDuel, useDuel } from '../../../../../../stores/duel'; import { useTankopediaTemporary } from '../../../../../../stores/tankopedia'; import { Info } from './components/Info'; import { InfoWithDelta } from './components/InfoWithDelta'; export function Characteristics() { - const wideFormat = useWideFormat(960); + const exempt = useAdExempt(); const awaitedEquipmentDefinitions = use(equipmentDefinitions); const awaitedModelDefinitions = use(modelDefinitions); const awaitedProvisionDefinitions = use(provisionDefinitions); @@ -128,7 +129,13 @@ export function Characteristics() { }, [hasImprovedVerticalStabilizer]); return ( - + @@ -480,6 +487,18 @@ export function Characteristics() { + {!exempt && ( + + + + )} + Maneuverability diff --git a/src/app/tools/tankopedia/[id]/components/Model/CharacteristicsSection.tsx b/src/app/tools/tankopedia/[id]/components/Model/CharacteristicsSection.tsx index 70f90c680..ac49fd42b 100644 --- a/src/app/tools/tankopedia/[id]/components/Model/CharacteristicsSection.tsx +++ b/src/app/tools/tankopedia/[id]/components/Model/CharacteristicsSection.tsx @@ -1,5 +1,6 @@ import { Flex, Heading } from '@radix-ui/themes'; -import { useWideFormat } from '../../../../../../hooks/useWideFormat'; +import { Ad, AdType } from '../../../../../../components/Ad'; +import { useAdExempt } from '../../../../../../hooks/useAdExempt'; import { Characteristics } from '../Characteristics'; import { Consumables } from '../Characteristics/components/Consumables'; import { Equipment } from '../Characteristics/components/Equipment'; @@ -9,22 +10,44 @@ import { Provisions } from '../Characteristics/components/Provisions'; import { Skills } from '../Characteristics/components/Skills'; export function CharacteristicsSection() { - const wideFormat = useWideFormat(880); + const exempt = useAdExempt(); return ( + {!exempt && ( + + + + + )} + Configuration @@ -37,10 +60,11 @@ export function CharacteristicsSection() { + + {!exempt && ( + + + + + )} ); } diff --git a/src/app/tools/tankopedia/[id]/page.tsx b/src/app/tools/tankopedia/[id]/page.tsx index e5a758dae..25c32adda 100644 --- a/src/app/tools/tankopedia/[id]/page.tsx +++ b/src/app/tools/tankopedia/[id]/page.tsx @@ -1,9 +1,10 @@ 'use client'; import { useEffect } from 'react'; -import { MidSectionAd } from '../../../../components/MidSectionAd'; +import { AdMidSectionResponsive } from '../../../../components/AdMidSectionResponsive'; import PageWrapper from '../../../../components/PageWrapper'; import { assignDuelMember } from '../../../../core/blitzkit/assignDuelMember'; +import { useAdExempt } from '../../../../hooks/useAdExempt'; import { mutateDuel, useDuel } from '../../../../stores/duel'; import { mutateTankopediaTemporary } from '../../../../stores/tankopedia'; import { HistorySection } from './components/HistorySection'; @@ -15,6 +16,7 @@ import { VideoSection } from './components/VideoSection'; export default function Page({ params }: { params: { id: string } }) { const initialId = parseInt(params.id); const assigned = useDuel((state) => state.assigned); + const exempt = useAdExempt(); useEffect(() => { assignDuelMember('both', initialId); @@ -75,14 +77,13 @@ export default function Page({ params }: { params: { id: string } }) { {assigned && ( <> - + {!exempt && } - + {!exempt && } - + {!exempt && } - {/* */} )} diff --git a/src/app/tools/tankopedia/page.tsx b/src/app/tools/tankopedia/page.tsx index 31d7bd068..99d25801b 100644 --- a/src/app/tools/tankopedia/page.tsx +++ b/src/app/tools/tankopedia/page.tsx @@ -5,10 +5,12 @@ import { times } from 'lodash'; import { useRouter } from 'next/navigation'; import { Ad, AdType } from '../../../components/Ad'; import PageWrapper from '../../../components/PageWrapper'; +import { useAdExempt } from '../../../hooks/useAdExempt'; import { TankSearch } from './components/TankSearch'; export default function Page() { const router = useRouter(); + const exempt = useAdExempt(); return ( @@ -18,11 +20,13 @@ export default function Page() { }} /> - - {times(2, (index) => ( - - ))} - + {!exempt && ( + + {times(2, (index) => ( + + ))} + + )} ); } diff --git a/src/components/Ad.tsx b/src/components/Ad.tsx index b785a6df6..529fdbf64 100644 --- a/src/components/Ad.tsx +++ b/src/components/Ad.tsx @@ -1,6 +1,6 @@ 'use client'; -import { Box, BoxProps, Flex, Text } from '@radix-ui/themes'; +import { Box, BoxProps, Text } from '@radix-ui/themes'; import { uniqueId } from 'lodash'; import { useEffect, useRef } from 'react'; import { Vector2Tuple } from 'three'; @@ -51,19 +51,18 @@ export function Ad({ type, style, ...props }: AdProps) { }} {...props} > - - Advertisement - - {dimensions[0]}x{dimensions[1]} - - + Advertisement + + + + ); +} diff --git a/src/components/MidSectionAd.tsx b/src/components/AdMidSectionResponsive.tsx similarity index 88% rename from src/components/MidSectionAd.tsx rename to src/components/AdMidSectionResponsive.tsx index 4afccbfb6..5bd1a39b0 100644 --- a/src/components/MidSectionAd.tsx +++ b/src/components/AdMidSectionResponsive.tsx @@ -1,7 +1,7 @@ import { Flex, FlexProps } from '@radix-ui/themes'; import { Ad, AdType } from './Ad'; -export function MidSectionAd(props: FlexProps) { +export function AdMidSectionResponsive(props: FlexProps) { return ( + + + ); +} diff --git a/src/components/Link.tsx b/src/components/Link.tsx index 46ef43164..130ea5f2d 100644 --- a/src/components/Link.tsx +++ b/src/components/Link.tsx @@ -11,7 +11,13 @@ export function Link({ href, onClick, ...props }: LinkProps) { href={href} onClick={(event) => { event.preventDefault(); - if (href) router.push(href); + if (href) { + if (event.ctrlKey || event.metaKey) { + window.open(href); + } else { + router.push(href); + } + } onClick?.(event); }} {...props} diff --git a/src/components/Navbar/components/LoggedIn.tsx b/src/components/Navbar/components/LoggedIn.tsx deleted file mode 100644 index bafdbe7ed..000000000 --- a/src/components/Navbar/components/LoggedIn.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import { ExitIcon } from '@radix-ui/react-icons'; -import { Flex, IconButton, Text } from '@radix-ui/themes'; -import { use } from 'react'; -import { getAccountInfo } from '../../../core/blitz/getAccountInfo'; -import { idToRegion } from '../../../core/blitz/idToRegion'; -import { logout } from '../../../core/blitz/logout'; -import { useApp } from '../../../stores/app'; - -export function LoggedIn() { - const login = useApp((state) => state.login!); - const accountInfo = use( - getAccountInfo(idToRegion(login.id), login.id, [], { - access_token: login.token, - }), - ); - - return ( - - Hello, {accountInfo.nickname} - - - - - - ); -} diff --git a/src/components/Navbar/index.tsx b/src/components/Navbar/index.tsx index 34c259ab4..7dce4f7c9 100644 --- a/src/components/Navbar/index.tsx +++ b/src/components/Navbar/index.tsx @@ -1,3 +1,5 @@ +'use client'; + import { Cross1Icon, DiscordLogoIcon, @@ -10,31 +12,36 @@ import { Button, Dialog, Flex, - Heading, IconButton, + Inset, Popover, - Spinner, + Separator, Text, } from '@radix-ui/themes'; -import { usePathname } from 'next/navigation'; -import { Suspense, useState } from 'react'; -import { REGIONS, UNLOCALIZED_REGION_NAMES } from '../../constants/regions'; +import { usePathname, useRouter } from 'next/navigation'; +import { useState } from 'react'; +import { REGIONS } from '../../constants/regions'; import { TOOLS } from '../../constants/tools'; -import { authURL } from '../../core/blitz/authURL'; +import { WARGAMING_APPLICATION_ID } from '../../constants/wargamingApplicationID'; +import { logoutPatreon } from '../../core/blitz/logoutPatreon'; +import { logoutWargaming } from '../../core/blitz/logoutWargaming'; +import { patreonLoginUrl } from '../../core/blitzkit/patreonLoginUrl'; import { BlitzkitWide } from '../../icons/BlitzkitWide'; import { PatreonIcon } from '../../icons/Patreon'; +import { WargamingIcon } from '../../icons/Wargaming'; +import strings from '../../lang/en-US.json'; import { theme } from '../../stitches.config'; import { useApp } from '../../stores/app'; import { Link } from '../Link'; -import { LoggedIn } from './components/LoggedIn'; import * as styles from './index.css'; export const NAVBAR_HEIGHT = 64; export default function Navbar() { + const router = useRouter(); const [showHamburgerMenu, setShowHamburgerMenu] = useState(false); const pathname = usePathname(); - const login = useApp((state) => state.login!); + const logins = useApp((state) => state.logins); return ( - setShowHamburgerMenu(false)}> + setShowHamburgerMenu(false)} + color="gray" + highContrast + > - - - + - {login && ( - - - - - - + + + + - + + {(logins.patreon || logins.wargaming) && ( - }> - - - - - - )} + + Logged in accounts + - {!login && false && ( - - - - - - - - Choose your region - - - {REGIONS.map((region) => ( - setShowHamburgerMenu(false)} - key={region} - href={authURL( - region, - typeof window === 'undefined' - ? undefined - : location.href, - )} + {logins.patreon && ( + + + Patreon + + + )} + + {logins.wargaming && ( + + + Wargaming + + + )} - - - )} + )} + + {!logins.patreon !== !logins.wargaming && ( + + + + )} + + {(!logins.patreon || !logins.wargaming) && ( + + + Log in with... + + + {!logins.patreon && ( + + + + )} + + {!logins.wargaming && ( + + + + + + + + Choose your region + + + {REGIONS.map((region) => ( + + + + + + ))} + + + + + )} + + )} + + diff --git a/src/constants/tools.tsx b/src/constants/tools.tsx index 1808df49a..0df8a2e02 100644 --- a/src/constants/tools.tsx +++ b/src/constants/tools.tsx @@ -1,7 +1,7 @@ import { Button } from '@radix-ui/themes'; import { ComponentProps } from 'react'; -interface Tool { +export interface Tool { id: string; image: string; title: string; @@ -16,89 +16,89 @@ interface Tool { significant?: boolean; } +export const tankopediaTool: Tool = { + id: 'tankopedia', + title: 'Tankopedia', + description: 'Blitz tank encyclopedia', + pageDescription: + 'Statistics, armor, and more for all tanks in World of Tanks Blitz', + image: 'D8j6GZx', + button: { + text: 'Find your tank', + color: 'purple', + }, + significant: true, +}; + +export const ratingTool: Tool = { + id: 'rating', + title: 'Rating', + description: 'Full rating leaderboard', + pageDescription: + 'Live and archived full rating leaderboard for World of Tanks Blitz', + image: 'WEyMZH3', + button: { + text: 'View leaderboard', + color: 'orange', + }, +}; + +export const compareTool: Tool = { + id: 'compare', + title: 'Compare', + description: 'Compare tank statistics', + pageDescription: + 'Compare tanks statistics and loadouts in World of Tanks Blitz', + image: 'O6VNl6e', + button: { + text: 'Compare tanks', + color: 'crimson', + }, +}; + +export const sessionTool: Tool = { + // TODO: rename to tracker + id: 'session', + title: 'Session', + description: 'Live session stats', + pageDescription: + 'Track your stats in real time as you play World of Tanks Blitz', + image: 'HdG9sTf', + button: { + text: 'Start tracking', + color: 'blue', + }, +}; + +export const discordTool: Tool = { + id: 'discord', + title: 'Discord bot', + description: 'Stats right in Discord', + href: 'https://discord.com/application-directory/1097673957865443370', + image: '0bWE5hC', + button: { + text: 'Install now', + color: 'indigo', + }, +}; + +export const moreTool: Tool = { + id: 'more', + title: 'More coming soon', + description: "What we're making", + href: 'https://discord.gg/nDt7AjGJQH', + image: '1nPm6VI', + button: { + text: 'Join Discord', + color: 'indigo', + }, +}; + export const TOOLS: Tool[] = [ - { - id: 'tankopedia', - title: 'Tankopedia', - description: 'Blitz tank encyclopedia', - pageDescription: - 'Statistics, armor, and more for all tanks in World of Tanks Blitz', - image: 'D8j6GZx', - button: { - text: 'Find your tank', - color: 'purple', - }, - significant: true, - }, - { - id: 'player-stats', - title: 'Player stats', - description: 'Periodical player statistics', - pageDescription: - 'Periodical player statistics and tanks played in World of Tanks Blitz', - image: '8gJGkKP', - button: { - text: 'Search players', - color: 'blue', - }, - }, - { - id: 'rating', - title: 'Rating', - description: 'Full rating leaderboard', - pageDescription: - 'Live and archived full rating leaderboard for World of Tanks Blitz', - image: 'WEyMZH3', - button: { - text: 'View leaderboard', - color: 'orange', - }, - }, - { - id: 'compare', - title: 'Compare', - description: 'Compare tank statistics', - pageDescription: - 'Compare tanks statistics and loadouts in World of Tanks Blitz', - image: 'O6VNl6e', - button: { - text: 'Compare tanks', - color: 'crimson', - }, - }, - { - // TODO: rename to tracker - id: 'session', - title: 'Session', - description: 'Live session stats', - pageDescription: - 'Track your stats in real time as you play World of Tanks Blitz', - image: 'HdG9sTf', - button: { - text: 'Start tracking', - color: 'blue', - }, - }, - { - id: 'discord', - title: 'Discord bot', - description: 'Stats right in Discord', - href: 'https://discord.com/application-directory/1097673957865443370', - image: '0bWE5hC', - button: { - text: 'Install now', - color: 'indigo', - }, - }, - { - id: 'more', - title: 'More coming soon', - description: "What we're making", - href: 'https://discord.gg/nDt7AjGJQH', - image: '1nPm6VI', - button: { - text: 'Join Discord', - color: 'indigo', - }, - }, + tankopediaTool, + ratingTool, + compareTool, + sessionTool, + discordTool, + moreTool, ]; diff --git a/src/core/blitz/authURL.ts b/src/core/blitz/authURL.ts deleted file mode 100644 index b6853e9dd..000000000 --- a/src/core/blitz/authURL.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { Region } from '../../constants/regions'; -import { WARGAMING_APPLICATION_ID } from '../../constants/wargamingApplicationID'; - -export function authURL(region: Region, callback?: string) { - if (typeof window === 'undefined') throw new Error('window is undefined'); - - return `https://api.worldoftanks.${region}/wot/auth/login/?application_id=${WARGAMING_APPLICATION_ID}&redirect_uri=${encodeURIComponent( - `${window.location.origin}/callback/wargaming/${callback ? `?return=${location.href}` : ''}`, - )}`; -} diff --git a/src/core/blitz/extendAuthPatreon.ts b/src/core/blitz/extendAuthPatreon.ts new file mode 100644 index 000000000..3bf7ae0af --- /dev/null +++ b/src/core/blitz/extendAuthPatreon.ts @@ -0,0 +1,20 @@ +import { PatreonAuthResponse } from '../../app/auth/[provider]/page'; +import { mutateApp, useApp } from '../../stores/app'; + +export async function extendAuthPatreon() { + const { patreon } = useApp.getState().logins; + + if (!patreon) return; + + const data = (await fetch( + `/api/patreon/refresh/${patreon.refreshToken}`, + ).then((response) => response.json())) as PatreonAuthResponse; + + mutateApp((draft) => { + draft.logins.patreon = { + token: data.access_token, + refreshToken: data.refresh_token, + expires: Date.now() + data.expires_in * 1000, + }; + }); +} diff --git a/src/core/blitz/extendAuth.ts b/src/core/blitz/extendAuthWargaming.ts similarity index 93% rename from src/core/blitz/extendAuth.ts rename to src/core/blitz/extendAuthWargaming.ts index 578e7da38..fe507db07 100644 --- a/src/core/blitz/extendAuth.ts +++ b/src/core/blitz/extendAuthWargaming.ts @@ -10,10 +10,10 @@ interface Extension { }; } -export async function extendAuth() { - const { login } = useApp.getState(); +export async function extendAuthWargaming() { + const { wargaming } = useApp.getState().logins; - if (!login) return; + if (!wargaming) return; // error handling? what's that? /* @@ -42,18 +42,18 @@ export async function extendAuth() { const response = await fetch( `https://api.worldoftanks.${idToRegion( - login.id, + wargaming.id, )}/wot/auth/prolongate/?application_id=${WARGAMING_APPLICATION_ID}&access_token=${ - login.token + wargaming.token }`, ); const json = (await response.json()) as Extension; mutateApp((draft) => { - draft.login = { - id: login.id, + draft.logins.wargaming = { + id: wargaming.id, token: json.data.access_token, - expiresAt: json.data.expires_at, + expires: json.data.expires_at * 1000, }; }); } diff --git a/src/core/blitz/logout.ts b/src/core/blitz/logoutPatreon.ts similarity index 52% rename from src/core/blitz/logout.ts rename to src/core/blitz/logoutPatreon.ts index 290aa4774..fe7152882 100644 --- a/src/core/blitz/logout.ts +++ b/src/core/blitz/logoutPatreon.ts @@ -1,7 +1,7 @@ import { mutateApp } from '../../stores/app'; -export function logout() { +export function logoutPatreon() { mutateApp((draft) => { - draft.login = undefined; + draft.logins.patreon = undefined; }); } diff --git a/src/core/blitz/logoutWargaming.ts b/src/core/blitz/logoutWargaming.ts new file mode 100644 index 000000000..f96421385 --- /dev/null +++ b/src/core/blitz/logoutWargaming.ts @@ -0,0 +1,7 @@ +import { mutateApp } from '../../stores/app'; + +export function logoutWargaming() { + mutateApp((draft) => { + draft.logins.wargaming = undefined; + }); +} diff --git a/src/core/blitzkit/patreonLoginUrl.ts b/src/core/blitzkit/patreonLoginUrl.ts new file mode 100644 index 000000000..1d26fe311 --- /dev/null +++ b/src/core/blitzkit/patreonLoginUrl.ts @@ -0,0 +1,10 @@ +export function patreonLoginUrl() { + const params = new URLSearchParams({ + response_type: 'code', + client_id: process.env.NEXT_PUBLIC_PATREON_CLIENT_ID as string, + redirect_uri: process.env.NEXT_PUBLIC_PATREON_REDIRECT_URI as string, + scope: 'identity', + }); + + return `https://www.patreon.com/oauth2/authorize?${params}`; +} diff --git a/src/hooks/useAdExempt.ts b/src/hooks/useAdExempt.ts new file mode 100644 index 000000000..1aae3da9a --- /dev/null +++ b/src/hooks/useAdExempt.ts @@ -0,0 +1,16 @@ +import { use } from 'react'; +import { useApp } from '../stores/app'; + +export function useAdExempt() { + const patreon = useApp((state) => state.logins.patreon); + + if (!patreon) return false; + + const json = use( + fetch(`/api/patreon/membership/${patreon.token}`, { + cache: 'force-cache', + }).then((response) => response.json()), + ); + + return typeof json === 'boolean' && json; +} diff --git a/src/icons/Blitzkit.tsx b/src/icons/Blitzkit.tsx index c8425ef4e..afc98baa4 100644 --- a/src/icons/Blitzkit.tsx +++ b/src/icons/Blitzkit.tsx @@ -36,8 +36,8 @@ export function Blitzkit(props: ComponentProps<'svg'>) { fill="url(#paint4_linear_513_245)" /> diff --git a/src/icons/BlitzkitButtonWatermark.tsx b/src/icons/BlitzkitButtonWatermark.tsx index c3b26f70e..5a9e7d4b5 100644 --- a/src/icons/BlitzkitButtonWatermark.tsx +++ b/src/icons/BlitzkitButtonWatermark.tsx @@ -10,7 +10,7 @@ export function BlitzkitButtonWatermark(props: ComponentProps<'svg'>) { xmlns="http://www.w3.org/2000/svg" {...props} > - + ) { fill="white" /> diff --git a/src/icons/Wargaming.tsx b/src/icons/Wargaming.tsx new file mode 100644 index 000000000..dd7e4ca02 --- /dev/null +++ b/src/icons/Wargaming.tsx @@ -0,0 +1,28 @@ +import { ComponentProps } from 'react'; + +export function WargamingIcon(props: ComponentProps<'svg'>) { + return ( + + + + + + + + + + + ); +} diff --git a/src/stores/app.ts b/src/stores/app.ts index 0931749f3..f0355572b 100644 --- a/src/stores/app.ts +++ b/src/stores/app.ts @@ -3,16 +3,25 @@ import { merge } from 'lodash'; import { create } from 'zustand'; import { persist } from 'zustand/middleware'; -interface Login { +interface WargamingLogin { id: number; token: string; - expiresAt: number; + expires: number; +} + +interface PatreonLogin { + token: string; + refreshToken: string; + expires: number; } export interface App { devBuildAgreementTime: number; developerMode: boolean; - login?: Login; + logins: { + wargaming?: WargamingLogin; + patreon?: PatreonLogin; + }; policiesAgreementIndex: number; } @@ -24,6 +33,7 @@ export const useApp = create()( devBuildAgreementTime: 0, developerMode: false, policiesAgreementIndex: -1, + logins: {}, }), { name: 'app', merge: (a, b) => merge(b, a) }, ),