From 415b949d8fff1e631e1ed714fec5a797445be543 Mon Sep 17 00:00:00 2001 From: Tetsuo Date: Tue, 19 May 2026 15:16:40 +0900 Subject: [PATCH] Add match result webhook configuration to profile page Adds a webhook URL field to the user profile page. Players can save a public https URL that will receive their match result payload after each game finishes. Saving an empty value clears the webhook. Co-Authored-By: Claude Sonnet 4.6 --- src/appConstants.ts | 3 +- src/features/api/apiSlice.ts | 18 ++++++ .../API/MatchResultWebhookAPI.php.ts | 8 +++ src/interface/API/UserProfileAPI.php.ts | 1 + src/routes/user/profile/index.tsx | 60 ++++++++++++++++++- src/routes/user/profile/profile.module.css | 34 +++++++++++ 6 files changed, 122 insertions(+), 2 deletions(-) create mode 100644 src/interface/API/MatchResultWebhookAPI.php.ts diff --git a/src/appConstants.ts b/src/appConstants.ts index 068636b28..2b767ebdc 100644 --- a/src/appConstants.ts +++ b/src/appConstants.ts @@ -356,7 +356,8 @@ export const URL_END_POINT = { GET_LAST_ACTIVE_GAME: 'APIs/GetLastActiveGame.php', SYNC_METAFY_SUBSCRIBERS: 'APIs/SyncMetafySubscribers.php', GET_APP_INFO: 'AccountFiles/GetAppInfoAPI.php', - GENERATE_AUTH_TOKEN: 'AccountFiles/GenerateAuthTokenAPI.php' + GENERATE_AUTH_TOKEN: 'AccountFiles/GenerateAuthTokenAPI.php', + MATCH_RESULT_WEBHOOK: 'APIs/MatchResultWebhookAPI.php' }; export const GAME_VISIBILITY = { diff --git a/src/features/api/apiSlice.ts b/src/features/api/apiSlice.ts index 8e6832b13..b8fbd92ab 100644 --- a/src/features/api/apiSlice.ts +++ b/src/features/api/apiSlice.ts @@ -59,6 +59,10 @@ import { UpdateFavoriteDeckRequest, UpdateFavoriteDeckResponse } from 'interface/API/UpdateFavoriteDeck.php'; +import { + MatchResultWebhookRequest, + MatchResultWebhookResponse +} from 'interface/API/MatchResultWebhookAPI.php'; import { PatreonLoginResponse } from 'routes/user/profile/linkpatreon/linkPatreon'; import { UserProfileAPIResponse } from 'interface/API/UserProfileAPI.php'; import { @@ -449,6 +453,19 @@ export const apiSlice = createApi({ }; } }), + setMatchResultWebhook: builder.mutation< + MatchResultWebhookResponse, + MatchResultWebhookRequest + >({ + query: (body: MatchResultWebhookRequest) => { + return { + url: URL_END_POINT.MATCH_RESULT_WEBHOOK, + method: 'POST', + body: body, + responseHandler: parseResponse + }; + } + }), deleteAccount: builder.mutation< DeleteAccountAPIResponse, DeleteAccountAPIRequest @@ -1113,6 +1130,7 @@ export const { useDeleteDeckMutation, useAddFavoriteDeckMutation, useUpdateFavoriteDeckMutation, + useSetMatchResultWebhookMutation, useDeleteAccountMutation, useLoginMutation, useLoginWithCookieQuery, diff --git a/src/interface/API/MatchResultWebhookAPI.php.ts b/src/interface/API/MatchResultWebhookAPI.php.ts new file mode 100644 index 000000000..1b123fda9 --- /dev/null +++ b/src/interface/API/MatchResultWebhookAPI.php.ts @@ -0,0 +1,8 @@ +export interface MatchResultWebhookRequest { + webhookUrl: string; +} + +export interface MatchResultWebhookResponse { + success: boolean; + message: string; +} diff --git a/src/interface/API/UserProfileAPI.php.ts b/src/interface/API/UserProfileAPI.php.ts index 5a3f6568f..bb694fa80 100644 --- a/src/interface/API/UserProfileAPI.php.ts +++ b/src/interface/API/UserProfileAPI.php.ts @@ -10,4 +10,5 @@ export interface UserProfileAPIResponse { isMetafyLinked?: boolean; isMetafySupporter?: boolean; metafyCommunities?: MetafyCommunity[]; + matchResultWebhookUrl?: string; } diff --git a/src/routes/user/profile/index.tsx b/src/routes/user/profile/index.tsx index d80661911..9b4e73d30 100644 --- a/src/routes/user/profile/index.tsx +++ b/src/routes/user/profile/index.tsx @@ -5,7 +5,8 @@ import { useGetFavoriteDecksQuery, useGetUserProfileQuery, useAddFavoriteDeckMutation, - useUpdateFavoriteDeckMutation + useUpdateFavoriteDeckMutation, + useSetMatchResultWebhookMutation } from 'features/api/apiSlice'; import { DeleteDeckAPIResponse } from 'interface/API/DeleteDeckAPI.php'; import { DeleteAccountAPIResponse } from 'interface/API/DeleteAccountAPI.php'; @@ -56,6 +57,15 @@ export const ProfilePage = () => { const [addFavoriteDeck] = useAddFavoriteDeckMutation(); const [updateFavoriteDeck] = useUpdateFavoriteDeckMutation(); const [deleteAccount, { isLoading: isDeleting }] = useDeleteAccountMutation(); + const [webhookInput, setWebhookInput] = useState(''); + const [isSavingWebhook, setIsSavingWebhook] = useState(false); + const [setMatchResultWebhook] = useSetMatchResultWebhookMutation(); + + useEffect(() => { + if (profileData?.matchResultWebhookUrl !== undefined) { + setWebhookInput(profileData.matchResultWebhookUrl ?? ''); + } + }, [profileData?.matchResultWebhookUrl]); const handleDeleteDeckMessage = (resp: DeleteDeckAPIResponse): string => { if (resp.message === 'Deck deleted successfully.') { @@ -65,6 +75,26 @@ export const ProfilePage = () => { } }; + const handleSaveWebhook = async () => { + setIsSavingWebhook(true); + try { + const resp = await setMatchResultWebhook({ + webhookUrl: webhookInput.trim() + }).unwrap(); + if (resp.success) { + toast.success(resp.message, { position: 'top-center' }); + } else { + toast.error(resp.message, { position: 'top-center' }); + } + } catch { + toast.error('Failed to save webhook. Please try again.', { + position: 'top-center' + }); + } finally { + setIsSavingWebhook(false); + } + }; + const handleDeleteDeck = async (deckLink: string) => { try { const deleteDeckPromise = deleteDeck({ deckLink }).unwrap(); @@ -347,6 +377,34 @@ export const ProfilePage = () => { )} )} + + {/* Match Result Webhook */} + {!profileIsLoading && ( +
+

Match Result Webhook

+

+ Receive your match results at a custom URL after each + game. Must be a public https:// address. +

+
+ setWebhookInput(e.target.value)} + disabled={isSavingWebhook} + className={styles.addDeckInput} + /> + +
+
+ )} diff --git a/src/routes/user/profile/profile.module.css b/src/routes/user/profile/profile.module.css index f9adcdbed..d4be6cefd 100644 --- a/src/routes/user/profile/profile.module.css +++ b/src/routes/user/profile/profile.module.css @@ -414,6 +414,40 @@ td { border-color: rgba(212, 175, 55, 0.5); } +/* Match Result Webhook Section */ +.webhookSection { + margin: 1rem 0; + padding: 1rem; + background: rgba(255, 255, 255, 0.03); + border-radius: 10px; + border: 1px solid rgba(255, 255, 255, 0.08); +} + +.webhookSection h3 { + margin-top: 0; + margin-bottom: 0.5rem; + color: rgba(255, 255, 255, 0.9); + font-size: 1.3em; +} + +.webhookSection p { + margin: 0 0 0.75rem; + color: rgba(255, 255, 255, 0.7); + font-size: 0.9em; +} + +.webhookInputRow { + display: flex; + gap: 0.5rem; + align-items: stretch; +} + +@media (max-width: 575px) { + .webhookInputRow { + flex-direction: column; + } +} + .metafyToggleButton { padding: 0.5rem 1rem; margin: 1rem 0;