From 170a46dedfa4a78eaef957e3315559ff198017e0 Mon Sep 17 00:00:00 2001 From: OotTheMonk Date: Sun, 22 Mar 2026 13:18:51 -0500 Subject: [PATCH 01/54] FaB Bazaar single source decks --- src/appConstants.ts | 4 + src/features/api/apiSlice.ts | 36 ++++- src/features/auth/authSlice.ts | 5 +- src/features/auth/constants.ts | 3 +- src/hooks/useAuth.tsx | 12 +- src/interface/API/GetBazaarDecks.ts | 15 ++ src/routes/game/create/CreateGame.module.css | 52 +++++++ src/routes/game/create/CreateGame.tsx | 130 +++++++++++++++--- .../components/quickJoin/QuickJoinContext.tsx | 113 ++++++++++++++- .../quickJoin/QuickJoinPanel.module.css | 64 +++++++++ .../components/quickJoin/QuickJoinPanel.tsx | 86 +++++++++--- src/utils/TestUtils.tsx | 4 +- 12 files changed, 471 insertions(+), 53 deletions(-) create mode 100644 src/interface/API/GetBazaarDecks.ts diff --git a/src/appConstants.ts b/src/appConstants.ts index ec93306ea..5df9e14ba 100644 --- a/src/appConstants.ts +++ b/src/appConstants.ts @@ -20,6 +20,10 @@ export const DATADOLL_URL = import.meta.env.DEV ? '/datadoll/' : `https://${import.meta.env.VITE_DATADOLL_URL}/`; +export const FAB_BAZAAR_DECKS_API_URL = + 'https://fabbazaar.app/api/talishar/decks'; +export const FAB_BAZAAR_DECK_URL_BASE = 'https://fabbazaar.app/decks/'; + // what playmat is the default export const DEFAULT_PLAYMAT = `Default`; diff --git a/src/features/api/apiSlice.ts b/src/features/api/apiSlice.ts index bb0275197..17f8063e0 100644 --- a/src/features/api/apiSlice.ts +++ b/src/features/api/apiSlice.ts @@ -7,7 +7,12 @@ import { } from '@reduxjs/toolkit/query/react'; import { isRejectedWithValue } from '@reduxjs/toolkit'; import type { MiddlewareAPI, Middleware } from '@reduxjs/toolkit'; -import { BACKEND_URL, ROGUELIKE_URL, URL_END_POINT } from 'appConstants'; +import { + BACKEND_URL, + FAB_BAZAAR_DECKS_API_URL, + ROGUELIKE_URL, + URL_END_POINT +} from 'appConstants'; import { detectVpnBlock, logVpnBlock } from 'utils/VpnDetection'; import { CreateGameAPI, @@ -28,6 +33,10 @@ import { SubmitLobbyInput } from 'interface/API/SubmitLobbyInput.php'; import { ChooseFirstPlayer } from 'interface/API/ChooseFirstPlayer.php'; import { SubmitSideboardAPI } from 'interface/API/SubmitSideboard.php'; import { GetFavoriteDecksResponse } from 'interface/API/GetFavoriteDecks.php'; +import { + BazaarDecksResponse, + GetBazaarDecksRequest +} from 'interface/API/GetBazaarDecks'; import { GameListResponse } from 'routes/index/components/gameList/GameList'; import { GetCosmeticsResponse } from 'interface/API/GetCosmeticsResponse.php'; import { @@ -331,6 +340,28 @@ export const apiSlice = createApi({ }; } }), + getBazaarDecks: builder.query({ + queryFn: async ({ metafyId, metafyHash }) => { + const timestamp = Math.floor(Date.now() / 1000); + const url = new URL(FAB_BAZAAR_DECKS_API_URL); + url.searchParams.set('metafyId', String(metafyId)); + url.searchParams.set('metafyHash', metafyHash); + url.searchParams.set('timestamp', String(timestamp)); + try { + const response = await fetch(url.toString()); + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + return { error: { status: response.status, data: errorData } }; + } + const data: BazaarDecksResponse = await response.json(); + return { data }; + } catch (error) { + return { + error: { status: 'FETCH_ERROR' as const, error: String(error) } + }; + } + } + }), getFavoriteDecks: builder.query({ query: () => { return { @@ -1186,5 +1217,6 @@ export const { useReportTypingMutation, useCheckOpponentTypingQuery, useGetAppInfoQuery, - useGenerateAuthTokenMutation + useGenerateAuthTokenMutation, + useGetBazaarDecksQuery } = apiSlice; diff --git a/src/features/auth/authSlice.ts b/src/features/auth/authSlice.ts index 6e4eb4aed..c6faa9869 100644 --- a/src/features/auth/authSlice.ts +++ b/src/features/auth/authSlice.ts @@ -7,12 +7,13 @@ const authSlice = createSlice({ initialState: defaultAuth, reducers: { setCredentialsReducer: (state, action) => { - const { user, accessToken, userName, isPatron, isMod } = action.payload; + const { user, accessToken, userName, isPatron, isMod, metafyHash } = action.payload; (state.user = user), (state.token = accessToken), (state.userName = userName); state.isPatron = isPatron; state.isMod = isMod || false; + state.metafyHash = metafyHash ?? null; }, logOutReducer: (state) => { state.user = null; @@ -20,6 +21,7 @@ const authSlice = createSlice({ state.token = null; state.isPatron = null; state.isMod = false; + state.metafyHash = null; } } }); @@ -32,3 +34,4 @@ export const selectCurrentUser = (state: RootState) => state.auth.user; export const selectCurrentUserName = (state: RootState) => state.auth.userName; export const selectIsPatron = (state: RootState) => state.auth.isPatron; export const selectIsMod = (state: RootState) => state.auth.isMod; +export const selectMetafyHash = (state: RootState) => state.auth.metafyHash; diff --git a/src/features/auth/constants.ts b/src/features/auth/constants.ts index 75daaa261..acbdad35b 100644 --- a/src/features/auth/constants.ts +++ b/src/features/auth/constants.ts @@ -3,5 +3,6 @@ export const defaultAuth = { userName: null, token: null, isPatron: null, - isMod: false + isMod: false, + metafyHash: null as string | null }; diff --git a/src/hooks/useAuth.tsx b/src/hooks/useAuth.tsx index 86383be6b..6fbe059dc 100644 --- a/src/hooks/useAuth.tsx +++ b/src/hooks/useAuth.tsx @@ -9,6 +9,7 @@ import { selectCurrentUserName, selectIsPatron, selectIsMod, + selectMetafyHash, setCredentialsReducer, logOutReducer } from 'features/auth/authSlice'; @@ -30,6 +31,7 @@ export default function useAuth() { const currentUserName = useAppSelector(selectCurrentUserName); const reduxIsPatron = useAppSelector(selectIsPatron); const isMod = useAppSelector(selectIsMod); + const metafyHash = useAppSelector(selectMetafyHash); // const { refetch } = useGetFavoriteDecksQuery(undefined); const [logOutAPI, logOutData] = useLogOutMutation(); const { @@ -48,7 +50,8 @@ export default function useAuth() { userName: string, token: string, patron: string, - isMod?: boolean + isMod?: boolean, + metafyHash?: string | null ) => { dispatch( setCredentialsReducer({ @@ -56,7 +59,8 @@ export default function useAuth() { userName: userName, accessToken: token, isPatron: patron, - isMod: isMod || false + isMod: isMod || false, + metafyHash: metafyHash ?? null }) ); }, @@ -123,7 +127,8 @@ export default function useAuth() { data.loggedInUserName, '', data.isPatron, - userIsMod + userIsMod, + data.metafyHash ?? null ); } else { // User is not logged in, clear any stale auth state @@ -148,6 +153,7 @@ export default function useAuth() { error, isPatron, isMod, + metafyHash, setLoggedIn, logOut }; diff --git a/src/interface/API/GetBazaarDecks.ts b/src/interface/API/GetBazaarDecks.ts new file mode 100644 index 000000000..87e5539a0 --- /dev/null +++ b/src/interface/API/GetBazaarDecks.ts @@ -0,0 +1,15 @@ +export interface BazaarDeck { + name: string; + deckId: string; +} + +export interface BazaarDecksResponse { + success: boolean; + decks?: BazaarDeck[]; + error?: string; +} + +export interface GetBazaarDecksRequest { + metafyId: number | string; + metafyHash: string; +} diff --git a/src/routes/game/create/CreateGame.module.css b/src/routes/game/create/CreateGame.module.css index 608e45e20..554ceb893 100644 --- a/src/routes/game/create/CreateGame.module.css +++ b/src/routes/game/create/CreateGame.module.css @@ -220,3 +220,55 @@ font-weight: bold; color: var(--theme-text-muted, #999); } + +/* ── Deck source tabs (standalone Create Game form) ─────────────── */ + +.deckTabBar { + display: flex; + border: 1px solid var(--theme-border, rgba(255, 255, 255, 0.15)); + border-radius: 8px; + overflow: hidden; + background: rgba(0, 0, 0, 0.15); +} + +.deckTab { + flex: 1; + padding: 0.4rem 0.75rem; + border: none; + background: transparent; + color: var(--theme-text-muted, rgba(237, 237, 237, 0.55)); + cursor: pointer; + font-size: 0.85rem; + font-weight: 500; + transition: background 0.15s ease, color 0.15s ease; + text-align: center; + width: auto !important; + margin-bottom: 0 !important; +} + +.deckTab:hover { + background: rgba(255, 255, 255, 0.06); + color: var(--theme-text, #ededed); +} + +.deckTab + .deckTab { + border-left: 1px solid var(--theme-border, rgba(255, 255, 255, 0.15)); +} + +.deckTabActive { + background: var(--theme-primary-muted, rgba(212, 175, 55, 0.2)); + color: var(--theme-primary, #d4af37); + font-weight: 600; +} + +.bazaarMessage { + font-size: 0.875rem; + color: var(--theme-text-muted, rgba(237, 237, 237, 0.6)); + margin: 0.25rem 0; + text-align: center; +} + +.bazaarMessage a { + color: var(--theme-primary, #d4af37); + text-decoration: underline; +} diff --git a/src/routes/game/create/CreateGame.tsx b/src/routes/game/create/CreateGame.tsx index 8c477ef6f..31ee30f37 100644 --- a/src/routes/game/create/CreateGame.tsx +++ b/src/routes/game/create/CreateGame.tsx @@ -1,17 +1,20 @@ import React, { useEffect, useMemo, useState } from 'react'; -import { useAppDispatch } from 'app/Hooks'; +import { useAppDispatch, useAppSelector } from 'app/Hooks'; import classNames from 'classnames'; import { GAME_FORMAT, GAME_VISIBILITY, AI_DECK, isPreconFormat, - PRECON_DECKS + PRECON_DECKS, + FAB_BAZAAR_DECK_URL_BASE } from 'appConstants'; import { useCreateGameMutation, - useGetFavoriteDecksQuery + useGetFavoriteDecksQuery, + useGetBazaarDecksQuery } from 'features/api/apiSlice'; +import { selectCurrentUser, selectMetafyHash } from 'features/auth/authSlice'; import { setGameStart } from 'features/game/GameSlice'; import useAuth from 'hooks/useAuth'; import { CreateGameAPI } from 'interface/API/CreateGame.php'; @@ -84,6 +87,44 @@ const CreateGame = () => { const [searchParams, setSearchParams] = useSearchParams(); const [createGame, createGameResult] = useCreateGameMutation(); + // FaB Bazaar — standalone mode only (embedded mode uses QuickJoinContext) + const metafyHash = useAppSelector(selectMetafyHash); + const metafyId = useAppSelector(selectCurrentUser); + const [standaloneDeckSource, setStandaloneDeckSourceState] = useState<'talishar' | 'bazaar'>( + () => + (localStorage.getItem('quickJoin_deckSource') as 'talishar' | 'bazaar') ?? 'talishar' + ); + const [standaloneSelectedBazaarDeck, setStandaloneSelectedBazaarDeck] = useState( + () => localStorage.getItem('quickJoin_bazaarDeck') ?? '' + ); + const canFetchBazaarStandalone = + !isEmbedded && + standaloneDeckSource === 'bazaar' && + !!metafyId && + !!metafyHash; + const { data: bazaarData, isLoading: isBazaarLoading } = useGetBazaarDecksQuery( + { metafyId: metafyId!, metafyHash: metafyHash! }, + { skip: !canFetchBazaarStandalone } + ); + const standaloneBazaarDeckOptions = useMemo(() => { + if (!bazaarData?.decks) return []; + return bazaarData.decks.map((deck) => ({ + value: deck.deckId, + label: deck.name + })); + }, [bazaarData?.decks]); + + const setStandaloneDeckSource = (src: 'talishar' | 'bazaar') => { + setStandaloneDeckSourceState(src); + localStorage.setItem('quickJoin_deckSource', src); + }; + const handleStandaloneSelectBazaarDeck = (deckId: string) => { + setStandaloneSelectedBazaarDeck(deckId); + localStorage.setItem('quickJoin_bazaarDeck', deckId); + setValue('fabdb', deckId ? `${FAB_BAZAAR_DECK_URL_BASE}${deckId}` : ''); + setValue('favoriteDecks', ''); + }; + // Initial stuff to allow the lang to change const { t, i18n, ready } = useTranslation(); @@ -180,10 +221,10 @@ const CreateGame = () => { // When inside QuickJoinProvider (main menu), sync deck values from the shared context React.useEffect(() => { if (isEmbedded) { - setValue('favoriteDecks', quickJoinCtx!.selectedFavoriteDeck); - setValue('fabdb', quickJoinCtx!.importDeckUrl); + setValue('favoriteDecks', quickJoinCtx!.effectiveFavoriteDecks); + setValue('fabdb', quickJoinCtx!.effectiveFabdb); } - }, [quickJoinCtx?.selectedFavoriteDeck, quickJoinCtx?.importDeckUrl, setValue]); + }, [quickJoinCtx?.effectiveFavoriteDecks, quickJoinCtx?.effectiveFabdb, setValue]); // Normalize localStorage on mount - extract base option from expanded descriptions React.useEffect(() => { @@ -419,10 +460,8 @@ const CreateGame = () => { setValue('fabdb', PRECON_DECKS.LINKS[0]); } else if (isEmbedded && quickJoinCtx) { // Re-apply context deck values that were cleared by reset(initialValues). - // This prevents a paste-then-submit failure when the query completes after - // the user has already typed a URL into the QuickJoin panel. - setValue('favoriteDecks', quickJoinCtx.selectedFavoriteDeck); - setValue('fabdb', quickJoinCtx.importDeckUrl); + setValue('favoriteDecks', quickJoinCtx.effectiveFavoriteDecks); + setValue('fabdb', quickJoinCtx.effectiveFabdb); } setIsInitialized(true); }, [initialValues, reset, setValue]); // eslint-disable-line react-hooks/exhaustive-deps @@ -457,10 +496,13 @@ const CreateGame = () => { // When inside QuickJoinProvider (main menu), use deck from shared context. // For precon formats, keep the precon deck URL already set in values.fabdb via the form. if (isEmbedded && !isPreconFormat(values.format)) { - values.favoriteDecks = quickJoinCtx!.selectedFavoriteDeck; - values.fabdb = quickJoinCtx!.importDeckUrl; - // Only save deck if "Save Deck" is checked and a new deck URL is being used (not a saved favorite) - values.favoriteDeck = quickJoinCtx!.saveDeck && quickJoinCtx!.importDeckUrl.trim() !== ''; + values.favoriteDecks = quickJoinCtx!.effectiveFavoriteDecks; + values.fabdb = quickJoinCtx!.effectiveFabdb; + // Only save deck if "Save Deck" is checked and a new deck URL is being used (not a saved favorite or bazaar) + values.favoriteDeck = + quickJoinCtx!.saveDeck && + quickJoinCtx!.deckSource === 'talishar' && + quickJoinCtx!.importDeckUrl.trim() !== ''; } // Extract base game description (remove hero/class names) @@ -568,9 +610,64 @@ const CreateGame = () => { )} + {/* Deck source tabs — standalone logged-in non-precon only */} + {!isEmbedded && + isLoggedIn && + !isPreconFormat(formFormat || selectedFormat) && ( +
+ + +
+ )} + {/* FaB Bazaar deck picker (standalone) */} + {!isEmbedded && + isLoggedIn && + standaloneDeckSource === 'bazaar' && + !isPreconFormat(formFormat || selectedFormat) && ( + metafyHash ? ( + + ) : ( +

+ Link your FaB Bazaar account in your{' '} + profile to see your decks + here. +

+ ) + )} {!isEmbedded && isLoggedIn && !isLoading && + standaloneDeckSource === 'talishar' && !isPreconFormat(formFormat || selectedFormat) && ( )} - {!isEmbedded && ( + {!isEmbedded && standaloneDeckSource === 'talishar' && ( { )} /> )} - {(!isEmbedded || isPreconFormat(formFormat || selectedFormat)) && ( + {(isPreconFormat(formFormat || selectedFormat) || + (!isEmbedded && standaloneDeckSource === 'talishar')) && (