diff --git a/.env.local b/.env.local new file mode 100644 index 0000000..ff2e041 --- /dev/null +++ b/.env.local @@ -0,0 +1 @@ +NEXT_PUBLIC_API_URL=http://65.109.100.181:8080 diff --git a/.env.production b/.env.production new file mode 100644 index 0000000..fcc76b9 --- /dev/null +++ b/.env.production @@ -0,0 +1 @@ +NEXT_PUBLIC_BASE_URL=https://smithyhelen.github.io/woth-toolbox diff --git a/.github/workflows/nextjs.yml b/.github/workflows/nextjs.yml index b79b29e..9118fdc 100644 --- a/.github/workflows/nextjs.yml +++ b/.github/workflows/nextjs.yml @@ -1,10 +1,10 @@ name: Deploy site to Github Pages -# Runs when pushing new tags +# Runs when pushing to main branch on: push: - tags: - - 'v[0-9]+.[0-9]+.[0-9]+' + branches: + - main # Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages permissions: @@ -48,43 +48,13 @@ jobs: if: steps.cache.outputs.cache-hit != 'true' run: pnpm install - lint: - name: Lint code - runs-on: ubuntu-latest - needs: deps - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Restore application dependencies - uses: actions/cache/restore@v4 - with: - key: ${{ runner.os }}-node-modules-${{ hashFiles('pnpm-lock.yaml') }} - path: | - node_modules - fail-on-cache-miss: true - - name: Install pnpm - uses: pnpm/action-setup@v3 - with: - version: 9 - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: 20 - cache: pnpm - - - name: Perform code linting - run: pnpm lint # Build job build: name: Build runs-on: ubuntu-latest environment: production - needs: - - lint steps: - name: Checkout uses: actions/checkout@v4 @@ -111,12 +81,8 @@ jobs: - name: Configure GitHub Pages uses: actions/configure-pages@v4 - with: - # Automatically inject basePath in your Next.js configuration file and disable - # server side image optimization (https://nextjs.org/docs/api-reference/next/image#unoptimized). - # - # You may remove this line if you want to manage the configuration yourself. - static_site_generator: next + + - name: Configure output cache uses: actions/cache@v4 @@ -131,15 +97,9 @@ jobs: - name: Export static application assets env: - NEXT_PUBLIC_BASE_PATH: ${{ vars.NEXT_PUBLIC_BASE_PATH }} - NEXT_PUBLIC_BASE_URL: ${{ vars.NEXT_PUBLIC_BASE_URL }} - NEXT_PUBLIC_FIREBASE_API_KEY: ${{ vars.NEXT_PUBLIC_FIREBASE_API_KEY }} - NEXT_PUBLIC_FIREBASE_APP_ID: ${{ vars.NEXT_PUBLIC_FIREBASE_APP_ID }} - NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN: ${{ vars.NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN }} - NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID: ${{ vars.NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID }} - NEXT_PUBLIC_FIREBASE_PROJECT_ID: ${{ vars.NEXT_PUBLIC_FIREBASE_PROJECT_ID }} - NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET: ${{ vars.NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET }} - NEXT_PUBLIC_GOOGLE_ANALYTICS: ${{ vars.NEXT_PUBLIC_GOOGLE_ANALYTICS }} + NEXT_PUBLIC_BASE_PATH: "/woth-toolbox" + NEXT_PUBLIC_BASE_URL: "https://smithyhelen.github.io/woth-toolbox" + NEXT_PUBLIC_API_URL: "http://65.109.100.181:8080" run: pnpm build - name: Upload assets as artifacts diff --git a/README.md b/README.md index 01ddc87..701ee93 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,19 @@ +# WOTH Toolbox - Discord Enhanced 🦌 + +> This is a fork of [codeaid/woth-toolbox](https://github.com/codeaid/woth-toolbox) with Discord integration for personal herd tracking. + +## Discord Integration Features +- Login with Discord to see your personally tracked herds on the maps +- Automatic map filtering - only shows herds from the currently selected map +- Color-coded markers: 🏆 Trophy, ✅ Leave, ⏳ Monitor, ❌ Cull +- Real-time sync with your Discord bot's database + +## Credits +- **Original WOTH Toolbox** by [codeaid](https://github.com/codeaid) +- **Map Data** courtesy of Nine Rocks Games and THQ Nordic +- **Discord Integration** for "Antlers in the Mist" community + +--- ![Way Of The Hunter Logo](docs/woth-logo.png) This interactive web application contains a suite of tools that can be used diff --git a/next.config.js b/next.config.js index cf1a011..a0393ee 100644 --- a/next.config.js +++ b/next.config.js @@ -21,6 +21,8 @@ const getLocalIdentHash = (context, localIdentName, localName) => /** @type {import('next').NextConfig} */ const config = { basePath: process.env.NEXT_PUBLIC_BASE_PATH, + + // Don't run linter during production builds eslint: { ignoreDuringBuilds: true, }, diff --git a/src/app/layout.tsx b/src/app/layout.tsx index d60f129..edfc816 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,5 +1,6 @@ import { GoogleAnalytics } from '@next/third-parties/google'; import clsx from 'clsx'; +import ClientDiscordAuth from 'components/ClientDiscordAuth'; import type { Metadata, Viewport } from 'next'; import type { PropsWithChildren } from 'react'; import { @@ -45,6 +46,7 @@ const RootLayout = (props: PropsWithChildren) => ( > + {props.children} diff --git a/src/app/nez-perce-valley/page.tsx b/src/app/nez-perce-valley/page.tsx index 9fc27a0..48260ab 100644 --- a/src/app/nez-perce-valley/page.tsx +++ b/src/app/nez-perce-valley/page.tsx @@ -1,15 +1,32 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { HerdMapOverlay } from 'components/HerdMapOverlay'; import { HuntingMapPage } from 'components/HuntingMapPage'; import { animalMarkers, genericMarkers, mapLabels } from 'config/idaho'; +import { isUserLoggedIn } from 'services/discordApiService'; + +const NezPerceValleyPage = () => { + const [isLoggedIn, setIsLoggedIn] = useState(false); + + useEffect(() => { + // Only check login status on client side + setIsLoggedIn(isUserLoggedIn()); + }, []); -const NezPerceValleyPage = () => ( - -); + return ( + <> + + {isLoggedIn && } + + ); +}; export default NezPerceValleyPage; diff --git a/src/app/not-found.tsx b/src/app/not-found.tsx index 37fba02..7477221 100644 --- a/src/app/not-found.tsx +++ b/src/app/not-found.tsx @@ -1,12 +1,37 @@ -import { Error } from 'components/Error'; -import { PageContent } from 'components/PageContent'; +'use client'; -const NotFoundPage = () => ( - <> - - - - -); - -export default NotFoundPage; +export default function NotFoundPage() { + return ( +
+

404

+

Page Not Found

+

+ The page you're looking for doesn't exist or has been moved. +

+ + Go Back Home + +
+ ); +} diff --git a/src/components/ClientDiscordAuth.tsx b/src/components/ClientDiscordAuth.tsx new file mode 100644 index 0000000..0b95d5f --- /dev/null +++ b/src/components/ClientDiscordAuth.tsx @@ -0,0 +1,167 @@ +'use client'; + +import { useEffect, useState, Suspense } from 'react'; +import { useSearchParams } from 'next/navigation'; +import { exchangeCodeForToken, fetchUserData, fetchUserHerds } from '@/services/discordApiService'; + +function DiscordAuthContent() { + const searchParams = useSearchParams(); + const [user, setUser] = useState(null); + const [herds, setHerds] = useState([]); + const [loading, setLoading] = useState(false); + + useEffect(() => { + const code = searchParams.get('code'); + if (code && !user) { + handleDiscordCallback(code); + } + + if (typeof window !== 'undefined') { + const storedToken = localStorage.getItem('discord_token'); + const storedUser = localStorage.getItem('discord_user'); + if (storedToken && storedUser && !user) { + setUser(JSON.parse(storedUser)); + loadUserHerds(); + } + } + }, [searchParams]); + + const handleDiscordCallback = async (code: string) => { + setLoading(true); + try { + const tokenData = await exchangeCodeForToken(code); + if (tokenData && tokenData.access_token) { + const userData = await fetchUserData(tokenData.access_token); + if (userData) { + localStorage.setItem('discord_token', tokenData.access_token); + localStorage.setItem('discord_user', JSON.stringify(userData)); + setUser(userData); + await loadUserHerds(); + } + } + } catch (error) { + console.error('Discord auth error:', error); + } finally { + setLoading(false); + } + }; + + const loadUserHerds = async () => { + try { + const response = await fetchUserHerds(); + const animals = response?.herds || response?.habitats?.flatMap(h => h.animals) || []; + setHerds(animals); // ✅ Correct - extract the array from the response + } catch (error) { + console.error('Error loading herds:', error); + } +}; + + const handleLogin = () => { + const clientId = process.env.NEXT_PUBLIC_DISCORD_CLIENT_ID; + const redirectUri = `${process.env.NEXT_PUBLIC_BASE_URL}/`; + const discordAuthUrl = `https://discord.com/api/oauth2/authorize?client_id=${clientId}&redirect_uri=${encodeURIComponent(redirectUri)}&response_type=code&scope=identify`; + window.location.href = discordAuthUrl; + }; + + const handleLogout = () => { + localStorage.removeItem('discord_token'); + localStorage.removeItem('discord_user'); + setUser(null); + setHerds([]); + }; + + return ( +
+ {!user ? ( + + ) : ( +
+
+ avatar + {user.username} +
+ {herds.length > 0 && ( +
+ {herds.length} tracked animal{herds.length !== 1 ? 's' : ''} +
+ )} + +
+ )} +
+ ); +} + +export default function ClientDiscordAuth() { + return ( + + + + ); +} diff --git a/src/components/HerdMapOverlay.tsx b/src/components/HerdMapOverlay.tsx new file mode 100644 index 0000000..ca44b6c --- /dev/null +++ b/src/components/HerdMapOverlay.tsx @@ -0,0 +1,291 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { + fetchUserHerds, + fetchHerdAnimals, + calculateCullingRecommendation, + type HerdsResponse, + type TrackedAnimal, + type Herd +} from '../services/discordApiService'; +interface HerdMapOverlayProps { + currentMap?: string; + onHerdsLoaded?: (herds: Herd[]) => void; +} + +const MARKER_COLORS = { + CULL: '#ff4444', + LEAVE: '#44ff44', + MONITOR: '#ffaa44', + TROPHY: '#ffd700' +}; + +const MARKER_ICONS = { + CULL: '❌', + LEAVE: '✅', + MONITOR: '⏳', + TROPHY: '🏆' +}; + +export function HerdMapOverlay({ currentMap, onHerdsLoaded }: HerdMapOverlayProps) { + const [herdsData, setHerdsData] = useState(null); + const [selectedHerds, setSelectedHerds] = useState>(new Set()); + const [animals, setAnimals] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + loadHerds(); + }, []); + + useEffect(() => { + const saved = localStorage.getItem('selected_herds'); + if (saved) { + try { + setSelectedHerds(new Set(JSON.parse(saved))); + } catch (e) { + console.error('Failed to load selected herds', e); + } + } + }, []); + + useEffect(() => { + if (selectedHerds.size > 0) { + localStorage.setItem('selected_herds', JSON.stringify([...selectedHerds])); + } + }, [selectedHerds]); + + async function loadHerds() { + try { + setLoading(true); + setError(null); + const data = await fetchUserHerds(); + setHerdsData(data); + + if (data.herds && data.herds.length > 0 && onHerdsLoaded) { + onHerdsLoaded(data.herds); + } + + if (data.tracking_mode === 'habitat_wide' && data.habitats) { + const allAnimals = data.habitats.flatMap(h => h.animals); + setAnimals(allAnimals.map(a => ({ + ...a, + culling_recommendation: calculateCullingRecommendation( + a.age_class, + a.star_rating, + a.responds_to_caller + ) + }))); + } + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to load herds'); + console.error('Error loading herds:', err); + } finally { + setLoading(false); + } + } + + async function toggleHerd(herdId: number) { + const newSelected = new Set(selectedHerds); + + if (newSelected.has(herdId)) { + newSelected.delete(herdId); + setAnimals(prev => prev.filter(a => a.herd_id !== herdId)); + } else { + newSelected.add(herdId); + + try { + const { animals: herdAnimals } = await fetchHerdAnimals(herdId); + const enriched = herdAnimals.map(a => ({ + ...a, + culling_recommendation: calculateCullingRecommendation( + a.age_class, + a.star_rating, + a.responds_to_caller + ) + })); + setAnimals(prev => [...prev, ...enriched]); + } catch (err) { + console.error('Failed to load herd animals:', err); + } + } + + setSelectedHerds(newSelected); + } + + if (loading) { + return ( +
+ Loading your herds... +
+ ); + } + + if (error) { + return ( +
+ {error} +
+ ); + } + + if (!herdsData || (herdsData.tracking_mode === 'individual' && !herdsData.herds?.length)) { + return ( +
+ No herds tracked yet. Use /herd_manager in Discord! +
+ ); + } + + const filteredHerds = herdsData.tracking_mode === 'individual' && currentMap + ? herdsData.herds?.filter(h => h.map_name === currentMap) + : herdsData.herds; + + const filteredAnimals = currentMap + ? animals.filter(a => { + if (herdsData.tracking_mode === 'individual' && a.herd_id) { + const herd = herdsData.herds?.find(h => h.id === a.herd_id); + return herd?.map_name === currentMap; + } + return true; + }) + : animals; + + return ( + <> +
+

+ 🦌 Your Herds {currentMap && `(${currentMap})`} +

+ + {herdsData.tracking_mode === 'individual' && filteredHerds && ( +
+ {filteredHerds.map(herd => ( + + ))} +
+ )} + + {herdsData.tracking_mode === 'habitat_wide' && ( +
+

Habitat-Wide tracking active

+

+ {filteredAnimals.length} animals tracked +

+
+ )} + +
+
Legend:
+ {Object.entries(MARKER_ICONS).map(([key, icon]) => ( +
+ {icon} + + {key} + +
+ ))} +
+
+ +
+ {filteredAnimals.map((animal, index) => ( +
+ {MARKER_ICONS[animal.culling_recommendation || 'MONITOR']} +
+ ))} +
+ + ); +} diff --git a/src/components/HuntingMapPage/HuntingMapPage.tsx b/src/components/HuntingMapPage/HuntingMapPage.tsx index dd27af6..f872847 100644 --- a/src/components/HuntingMapPage/HuntingMapPage.tsx +++ b/src/components/HuntingMapPage/HuntingMapPage.tsx @@ -1,5 +1,6 @@ 'use client'; + import { useCallback, useEffect, useMemo, useState } from 'react'; import { createPortal } from 'react-dom'; import { AnimalEditor } from 'components/AnimalEditor'; @@ -13,7 +14,6 @@ import { useHuntingMapType, useSettings, useTranslator, - useTutorial, } from 'hooks'; import type { AnimalMarker } from 'types/markers'; import type { HuntingMapPageProps } from './types'; @@ -52,7 +52,7 @@ export const HuntingMapPage = (props: HuntingMapPageProps) => { // Retrieve map dependencies const { onSettingsRead } = useSettings(); const translate = useTranslator(); - const { component: tutorial } = useTutorial(true); + // Animal marker that is currently being edited const [pendingMarker, setPendingMarker] = useState(); @@ -97,9 +97,7 @@ export const HuntingMapPage = (props: HuntingMapPageProps) => { }, [mapId, onSetMapType]); return ( - <> - {`${translate(titleKey)} - ${translate('UI:GAME_TITLE')}`} - + <> { onUpdateRecordAsync={onUpdateAnimalMarkerRecord} /> - {createPortal(tutorial, document.body)} + ); }; diff --git a/src/components/Toolbar/Toolbar.tsx b/src/components/Toolbar/Toolbar.tsx index 16bb795..ace8fb2 100644 --- a/src/components/Toolbar/Toolbar.tsx +++ b/src/components/Toolbar/Toolbar.tsx @@ -27,7 +27,7 @@ import { baseUrlNewZealand, baseUrlTransylvania, } from 'config/routing'; -import { useTranslator, useTutorial } from 'hooks'; +import { useTranslator } from 'hooks'; import { isMapUrl } from 'lib/routing'; import type { ToolbarProps } from './types'; import styles from './Toolbar.module.css'; @@ -48,7 +48,6 @@ export const Toolbar = (props: ToolbarProps) => { const pathname = usePathname(); // Retrieve map tutorial state and open functionality - const { enabled: tutorialEnabled, onTutorialOpen } = useTutorial(); // Retrieve application translator const translate = useTranslator(); @@ -104,10 +103,6 @@ export const Toolbar = (props: ToolbarProps) => { /** * Handle opening tutorial */ - const handleOpenTutorial = useCallback( - () => onTutorialOpen(1), - [onTutorialOpen], - ); /** * Handle hiding map menu @@ -269,14 +264,6 @@ export const Toolbar = (props: ToolbarProps) => {
- {tutorialEnabled && ( - - - - )} diff --git a/src/config/app.ts b/src/config/app.ts index be984cd..e97c30e 100644 --- a/src/config/app.ts +++ b/src/config/app.ts @@ -38,7 +38,7 @@ export const firaSansExtraCondensedFont = createFiraSansExtraCondensedFont({ }); // Detect base URL and path to use when loading assets -export const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || ''; +const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || 'https://smithyhelen.github.io/woth-toolbox'; export const basePath = process.env.NEXT_PUBLIC_BASE_PATH || ''; export const metadataBase = baseUrl ? new URL(baseUrl) : undefined; @@ -49,3 +49,4 @@ export const googleAnalyticsId = process.env.NEXT_PUBLIC_GOOGLE_ANALYTICS ?? ''; export const urlDiscord = 'https://discord.gg/wayofthehunter'; export const urlSteam = 'https://steamcommunity.com/sharedfiles/filedetails/?id=2882064749'; + diff --git a/src/contexts/ApplicationProvider/ApplicationProvider.tsx b/src/contexts/ApplicationProvider/ApplicationProvider.tsx index 8776e84..4cc4268 100644 --- a/src/contexts/ApplicationProvider/ApplicationProvider.tsx +++ b/src/contexts/ApplicationProvider/ApplicationProvider.tsx @@ -8,7 +8,6 @@ import { Notifications } from 'components/Notifications'; import { HuntingMapTypeProvider, SettingsProvider, - TutorialProvider, } from 'contexts'; import { useHuntingMapTypeManager } from 'hooks'; import { queryClient } from 'lib/services'; @@ -23,12 +22,10 @@ export const ApplicationProvider = (props: PropsWithChildren) => { - - + {children} - diff --git a/src/contexts/SettingsContext/SettingsProvider.tsx b/src/contexts/SettingsContext/SettingsProvider.tsx index e5510f0..cb51914 100644 --- a/src/contexts/SettingsContext/SettingsProvider.tsx +++ b/src/contexts/SettingsContext/SettingsProvider.tsx @@ -56,9 +56,9 @@ export const SettingsProvider = (props: PropsWithChildren) => { [onSettingsRead], ); - if (!initialized) { - return ; - } + if (!initialized || typeof window === 'undefined') { + return ; +} return ( ({ onTutorialEnable: () => undefined, onTutorialOpen: () => undefined, }); + +export const { Provider: TutorialProvider } = TutorialContext; diff --git a/src/contexts/TutorialContext/TutorialProvider.tsx b/src/contexts/TutorialContext/TutorialProvider.tsx deleted file mode 100644 index 74bd30f..0000000 --- a/src/contexts/TutorialContext/TutorialProvider.tsx +++ /dev/null @@ -1,93 +0,0 @@ -'use client'; - -import type { PropsWithChildren } from 'react'; -import { useCallback, useEffect, useState } from 'react'; -import { useSettings } from 'hooks'; -import { sendGoogleEvent } from 'lib/tracking'; -import { TutorialContext } from './TutorialContext'; - -export const TutorialProvider = (props: PropsWithChildren) => { - const { children } = props; - - // Flag indicating whether tutorial has been previously completed - const [completed, setCompleted] = useState(); - - // Index of the page that is activated by default - const [defaultPageIndex, setDefaultPageIndex] = useState(0); - - // Flag indicating whether tutorial functionality is enabled - const [enabled, setEnabled] = useState(false); - - // Flag indicating whether tutorial is currently open - const [visible, setVisible] = useState(false); - - // Get settings settings and accessors - const { onSettingsRead, onSettingsUpdateAsync } = useSettings(); - - /** - * Handle closing tutorial halfway through - */ - const handleTutorialClose = useCallback(() => { - setDefaultPageIndex(0); - setVisible(false); - - // Send custom Google Analytics event - sendGoogleEvent('help_close'); - }, []); - - /** - * Handle completing tutorial - */ - const handleTutorialComplete = useCallback(async () => { - setCompleted(true); - setVisible(false); - - // Send custom Google Analytics event - sendGoogleEvent('help_complete'); - await onSettingsUpdateAsync('tutorial:completed', true); - }, [onSettingsUpdateAsync]); - - /** - * Handle showing tutorial - * - * @param defaultPageIndex Default page index to activate - */ - const handleTutorialOpen = useCallback( - (defaultPageIndex = 0) => { - // Ignore request if tutorial functionality is disabled - if (!enabled) { - return; - } - - setVisible(true); - setDefaultPageIndex(defaultPageIndex); - - // Send custom Google Analytics event - sendGoogleEvent('help_open'); - }, - [enabled], - ); - - // Set tutorial completion state - useEffect(() => { - const completed = onSettingsRead('tutorial:completed', false); - setCompleted(completed); - }, [onSettingsRead]); - - return ( - - {children} - - ); -}; diff --git a/src/contexts/TutorialContext/index.ts b/src/contexts/TutorialContext/index.ts index b0cc82a..b99f072 100644 --- a/src/contexts/TutorialContext/index.ts +++ b/src/contexts/TutorialContext/index.ts @@ -1,3 +1,5 @@ export type { TutorialContextValue } from './types'; -export { TutorialContext } from './TutorialContext'; -export { TutorialProvider } from './TutorialProvider'; +export { + TutorialContext, + TutorialProvider, +} from 'contexts/TutorialContext/TutorialContext'; diff --git a/src/contexts/TutorialContext/types.ts b/src/contexts/TutorialContext/types.ts index 86b762a..654cd0d 100644 --- a/src/contexts/TutorialContext/types.ts +++ b/src/contexts/TutorialContext/types.ts @@ -3,7 +3,7 @@ type HuntingMapTutorialContextOpenHandler = (defaultPageIndex?: number) => void; type HuntingMapTutorialContextVoidHandler = () => void; export interface TutorialContextValue { - completed?: boolean; + completed: boolean; defaultPageIndex: number; enabled: boolean; visible: boolean; diff --git a/src/contexts/index.ts b/src/contexts/index.ts index 0881192..8e3f9df 100644 --- a/src/contexts/index.ts +++ b/src/contexts/index.ts @@ -2,4 +2,3 @@ export * from './ApplicationProvider'; export * from './CustomMarkerContext'; export * from './HuntingMapTypeContext'; export * from './SettingsContext'; -export * from './TutorialContext'; diff --git a/src/hooks/index.ts b/src/hooks/index.ts index 90f575b..9fc570d 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -11,4 +11,3 @@ export * from './useRefCallback'; export * from './useSettings'; export * from './useStorage'; export * from './useTranslator'; -export * from './useTutorial'; diff --git a/src/hooks/useStorage.ts b/src/hooks/useStorage.ts index 30a5006..54d9fc5 100644 --- a/src/hooks/useStorage.ts +++ b/src/hooks/useStorage.ts @@ -8,13 +8,19 @@ import { getStorage } from 'lib/storage'; */ export const useStorage = () => { // Browser storage manager - const [storage, setStorage] = useState(); + const [storage, setStorage] = useState( + typeof window !== 'undefined' ? getStorage() : undefined + ); // Create storage manager on load useEffect(() => { - try { - setStorage(getStorage()); - } catch (e) {} + if (typeof window !== 'undefined') { + try { + setStorage(getStorage()); + } catch (e) { + console.error('Failed to initialize storage:', e); + } + } }, []); return storage; diff --git a/src/hooks/useTutorial.tsx b/src/hooks/useTutorial.tsx index 34b6a5f..2b87fc3 100644 --- a/src/hooks/useTutorial.tsx +++ b/src/hooks/useTutorial.tsx @@ -32,7 +32,7 @@ export const useTutorial = (enable = false) => { // Show tutorial if it has previously not been completed useEffect(() => { - completed === false && onTutorialOpen(); + !completed && onTutorialOpen(); }, [completed, onTutorialOpen]); return { diff --git a/src/hooks/useTutorialManager.ts b/src/hooks/useTutorialManager.ts index d288236..b46da49 100644 --- a/src/hooks/useTutorialManager.ts +++ b/src/hooks/useTutorialManager.ts @@ -2,10 +2,7 @@ import { useCallback, useEffect, useState } from 'react'; import type { TutorialContextValue } from 'contexts'; -import { - storageReadTutorialFlagAsync, - storageWriteTutorialFlagAsync, -} from 'lib/storage'; +import { isMapTutorialCompleted, writeMapTutorialCompleted } from 'lib/storage'; import { sendGoogleEvent } from 'lib/tracking'; import { useStorage } from './useStorage'; @@ -42,7 +39,7 @@ export const useTutorialManager = (): TutorialContextValue => { /** * Handle completing tutorial */ - const handleTutorialComplete = useCallback(async () => { + const handleTutorialComplete = useCallback(() => { setCompleted(true); setVisible(false); @@ -50,7 +47,7 @@ export const useTutorialManager = (): TutorialContextValue => { sendGoogleEvent('help_complete'); if (storage) { - await storageWriteTutorialFlagAsync(storage); + writeMapTutorialCompleted(storage); } }, [storage]); @@ -82,7 +79,7 @@ export const useTutorialManager = (): TutorialContextValue => { return; } - storageReadTutorialFlagAsync(storage).then(setCompleted); + setCompleted(isMapTutorialCompleted(storage)); }, [storage]); return { diff --git a/src/services/discordApiService.ts b/src/services/discordApiService.ts new file mode 100644 index 0000000..9c3cbfc --- /dev/null +++ b/src/services/discordApiService.ts @@ -0,0 +1,177 @@ +const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://65.109.100.181:8080'; +export async function exchangeCodeForToken(code: string): Promise { + const clientId = process.env.NEXT_PUBLIC_DISCORD_CLIENT_ID; + const clientSecret = process.env.NEXT_PUBLIC_DISCORD_CLIENT_SECRET; + const redirectUri = `${process.env.NEXT_PUBLIC_BASE_URL}/`; + + const response = await fetch('https://discord.com/api/oauth2/token', { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: new URLSearchParams({ + client_id: clientId!, + client_secret: clientSecret!, + grant_type: 'authorization_code', + code: code, + redirect_uri: redirectUri, + }), + }); + + if (!response.ok) { + throw new Error('Failed to exchange code for token'); + } + + return await response.json(); +} + +export async function fetchUserData(accessToken: string): Promise { + const response = await fetch('https://discord.com/api/users/@me', { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }); + + if (!response.ok) { + throw new Error('Failed to fetch user data'); + } + + return await response.json(); +} +export interface Herd { + id: number; + herd_name: string; + map_name: string; + species_name: string; + animal_count: number; + tracking_mode: 'individual' | 'habitat_wide'; +} + +export interface HabitatGroup { + habitat_name: string; + animals: TrackedAnimal[]; +} + +export interface HerdsResponse { + tracking_mode: 'individual' | 'habitat_wide'; + herds?: Herd[]; + habitats?: HabitatGroup[]; +} + +export interface TrackedAnimal { + id: number; + herd_id?: number; + habitat_name?: string; + species_name: string; + age_class: 'Young' | 'Adult' | 'Mature'; + star_rating: number; + responds_to_caller: boolean; + location_notes: string; + last_seen: string; + culling_recommendation?: 'CULL' | 'LEAVE' | 'MONITOR' | 'TROPHY'; +} + +export interface AnimalsResponse { + herd: Herd; + animals: TrackedAnimal[]; +} + +export interface GameMap { + map_name: string; +} + +export interface Species { + species_name: string; +} + +function getAuthHeaders(): HeadersInit { + const token = localStorage.getItem('jwt_token'); + + if (!token) { + throw new Error('Not authenticated'); + } + + return { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + }; +} + +export async function fetchUserHerds(): Promise { + const response = await fetch(`${API_BASE_URL}/api/user/herds`, { + headers: getAuthHeaders(), + credentials: 'include' + }); + + if (!response.ok) { + if (response.status === 401) { + localStorage.removeItem('jwt_token'); + throw new Error('Session expired. Please login again.'); + } + throw new Error('Failed to fetch herds'); + } + + return await response.json(); +} + +export async function fetchHerdAnimals(herdId: number): Promise { + const response = await fetch(`${API_BASE_URL}/api/user/animals/${herdId}`, { + headers: getAuthHeaders(), + credentials: 'include' + }); + + if (!response.ok) { + if (response.status === 401) { + localStorage.removeItem('jwt_token'); + throw new Error('Session expired. Please login again.'); + } + throw new Error('Failed to fetch animals'); + } + + return await response.json(); +} + +export async function fetchMaps(): Promise { + const response = await fetch(`${API_BASE_URL}/api/maps`); + + if (!response.ok) { + throw new Error('Failed to fetch maps'); + } + + return await response.json(); +} + +export async function fetchSpecies(): Promise { + const response = await fetch(`${API_BASE_URL}/api/species`); + + if (!response.ok) { + throw new Error('Failed to fetch species'); + } + + return await response.json(); +} + +export function calculateCullingRecommendation( + ageClass: string, + starRating: number, + respondsToCaller: boolean +): 'CULL' | 'LEAVE' | 'MONITOR' | 'TROPHY' { + if (ageClass === 'Young') { + if (starRating === 1 && respondsToCaller) return 'CULL'; + return 'LEAVE'; + } else if (ageClass === 'Adult') { + if (starRating <= 2) return 'MONITOR'; + if (starRating === 5) return 'TROPHY'; + return 'LEAVE'; + } else if (ageClass === 'Mature') { + if (starRating <= 2) return 'CULL'; + if (starRating === 3) return 'MONITOR'; + return 'TROPHY'; + } + return 'MONITOR'; +} + +export function isUserLoggedIn(): boolean { + if (typeof window === 'undefined') return false; + return !!localStorage.getItem('jwt_token'); +} diff --git a/tsconfig.json b/tsconfig.json index fc7c1b3..d6c73d0 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -2,6 +2,9 @@ "compilerOptions": { "allowJs": true, "baseUrl": "src", + "paths": { + "@/*": ["./*"] + }, "downlevelIteration": true, "esModuleInterop": true, "forceConsistentCasingInFileNames": true,