diff --git a/api/ApiHelper.tsx b/api/ApiHelper.tsx index 3f5f421eb..b2bddcfe3 100644 --- a/api/ApiHelper.tsx +++ b/api/ApiHelper.tsx @@ -559,7 +559,7 @@ export function initAPI(returnSSRResponse: boolean = false): API { return { id: t.id || 0, name: t.name || '', - isDisabled: false, + isDisabled: t.isDisabled ?? false, priority: 0 } }) @@ -568,6 +568,9 @@ export function initAPI(returnSSRResponse: boolean = false): API { resolve() }) .catch(e => { + // The listener was already created but linking the subscription failed. + // Roll it back so it doesn't linger invisibly and count against the limit. + unsubscribe(listener).catch(() => {}) let errorMessage = typeof e === 'string' ? e : e?.message || '' if (errorMessage.includes('subscription_limit_reached')) { toast.error( diff --git a/components/ArchivedAuctions.tsx/ExportArchivedData/ExportArchivedData.tsx b/components/ArchivedAuctions.tsx/ExportArchivedData/ExportArchivedData.tsx index 3f7d85d8d..8585b9bd9 100644 --- a/components/ArchivedAuctions.tsx/ExportArchivedData/ExportArchivedData.tsx +++ b/components/ArchivedAuctions.tsx/ExportArchivedData/ExportArchivedData.tsx @@ -6,6 +6,7 @@ import styles from './ExportArchivedData.module.css' import HelpIcon from '@mui/icons-material/Help' import Tooltip from '../../Tooltip/Tooltip' import NumberElement from '../../Number/Number' +import { isValidDiscordWebhookUrl } from '../../../utils/NotificationChannelUtils' interface Props { itemTag: string @@ -23,7 +24,7 @@ function ExportArchivedData(props: Props) { let [showConfirmDialog, setShowConfirmDialog] = useState(false) let [isExportRunning, setIsExportRunning] = useState(false) - let isDiscordWebhookUrlValid = discordWebhookUrl && (discordWebhookUrl.startsWith('https://discord.com/api/webhooks/') || discordWebhookUrl.startsWith('https://discordapp.com/api/webhooks/')) + let isDiscordWebhookUrlValid = !!discordWebhookUrl && isValidDiscordWebhookUrl(discordWebhookUrl) useEffect(() => { setShowModal(props.show) @@ -80,7 +81,7 @@ function ExportArchivedData(props: Props) { onChange={e => setDiscordWebhookUrl(e.target.value)} placeholder="Discord Webhook Url (https://discord.com/api/... or https://discordapp.com/api/...)" /> - {discordWebhookUrl && !discordWebhookUrl?.startsWith('https://discord.com/api/') && !discordWebhookUrl?.startsWith('https://discordapp.com/api/webhooks/') ? ( + {discordWebhookUrl && !isValidDiscordWebhookUrl(discordWebhookUrl) ? (
The Discord Webhook URL has to start with "https://discord.com/api/..." or "https://discordapp.com/api/webhooks/..."
diff --git a/components/Favorites/FavoriteItemCard.tsx b/components/Favorites/FavoriteItemCard.tsx index fd0a38b04..393ae1f48 100644 --- a/components/Favorites/FavoriteItemCard.tsx +++ b/components/Favorites/FavoriteItemCard.tsx @@ -115,6 +115,7 @@ export default function FavoriteItemCard({ favorite, movement, isMovementLoading Notify} /> @@ -135,6 +136,7 @@ export default function FavoriteItemCard({ favorite, movement, isMovementLoading diff --git a/components/NotificationTargets/NotificationTargetForm.tsx b/components/NotificationTargets/NotificationTargetForm.tsx index 16ff9b152..a2b5b29b4 100644 --- a/components/NotificationTargets/NotificationTargetForm.tsx +++ b/components/NotificationTargets/NotificationTargetForm.tsx @@ -5,6 +5,7 @@ import { Button, Form } from 'react-bootstrap' import api from '../../api/ApiHelper' import askForNotificationPermissons from '../../utils/NotificationPermisson' import { getNotficationWhenEnumAsString, getNotificationTypeAsString } from '../../utils/NotificationUtils' +import { isValidDiscordWebhookUrl } from '../../utils/NotificationChannelUtils' const TYPE_OPTIONS: NotificationType[] = ['DiscordWebhook', 'FIREBASE', 'InGame'] const WHEN_OPITIONS: NotificationWhen[] = ['NEVER', 'AfterFail', 'ALWAYS'] @@ -22,6 +23,8 @@ function NotificationTargetForm(props: Props) { let [when, setWhen] = useState(props.defaultNotificationTarget?.when || 'ALWAYS') let [disabled, setDisabled] = useState(false) + let discordUrlInvalid = type === 'DiscordWebhook' && !!target && !isValidDiscordWebhookUrl(target) + async function addNotificationTarget() { let targetToSend = target @@ -81,9 +84,9 @@ function NotificationTargetForm(props: Props) { type="text" onChange={e => setTarget(e.target.value)} placeholder={type === 'DiscordWebhook' ? 'Discord Webhook Url (https://discord.com/api/... or https://discordapp.com/api/...)' : 'Webhook Url'} - isInvalid={(type === 'DiscordWebhook' && target && !target?.startsWith('https://discord.com/api/') && !target?.startsWith('https://discordapp.com/api/webhooks/')) === true} + isInvalid={discordUrlInvalid} /> - {type === 'DiscordWebhook' && target && !target?.startsWith('https://discord.com/api/') && !target?.startsWith('https://discordapp.com/api/webhooks/') ? ( + {discordUrlInvalid ? (
The Discord Webhook URL has to start with "https://discord.com/api/..." or "https://discordapp.com/api/webhooks/..."
diff --git a/components/NotificationTargets/NotificationTargets.tsx b/components/NotificationTargets/NotificationTargets.tsx index d51f422b1..b86562263 100644 --- a/components/NotificationTargets/NotificationTargets.tsx +++ b/components/NotificationTargets/NotificationTargets.tsx @@ -2,6 +2,7 @@ import { useEffect, useState } from 'react' import { Button, Table } from 'react-bootstrap' +import { toast } from 'react-toastify' import api from '../../api/ApiHelper' import NotificationTargetForm from './NotificationTargetForm' import DeleteIcon from '@mui/icons-material/Delete' @@ -29,9 +30,20 @@ function NotificationTargets() { } function deleteNotificationTarget(target: NotificationTarget) { - api.deleteNotificationTarget(target).then(() => { - setNotificationTargets(notificationTargets.filter(t => t.name !== target.name)) - }) + api.deleteNotificationTarget(target) + .then(() => { + setNotificationTargets(notificationTargets.filter(t => t.name !== target.name)) + }) + .catch(error => { + // the http layer rejects with a plain string like `HTTP 500: {"slug":"subscription_depends",...}`, + // so apiErrorHandler (which only handles error.message) stays silent - surface it here instead. + let message = typeof error === 'string' ? error : error?.message + if (message && message.includes('subscription_depends')) { + toast.error(`"${target.name}" is still used by at least one notifier. Switch those notifiers to another channel first, then delete it.`) + } else { + toast.error(message || 'Could not delete this channel. Please try again.') + } + }) } function sendTestNotification(target: NotificationTarget) { @@ -42,7 +54,7 @@ function NotificationTargets() { <>
{!showAddNotificationTarget ? ( - + ) : (
- +
- +
diff --git a/components/SubscribeButton/ChannelPicker/ChannelPicker.module.css b/components/SubscribeButton/ChannelPicker/ChannelPicker.module.css new file mode 100644 index 000000000..e15d5df40 --- /dev/null +++ b/components/SubscribeButton/ChannelPicker/ChannelPicker.module.css @@ -0,0 +1,86 @@ +.channelPicker { + display: flex; + flex-direction: column; + gap: 8px; + margin-top: 5px; +} + +.channel { + display: flex; + align-items: center; + gap: 11px; + border: 1px solid var(--bs-border-color, #444); + border-radius: 9px; + padding: 10px 12px; + margin: 0; + cursor: pointer; +} + +.channelColumn { + flex-direction: column; + align-items: stretch; +} + +.channelRow { + display: flex; + align-items: center; + gap: 11px; + margin: 0; + cursor: pointer; +} + +.channelActive { + border-color: var(--bs-primary, #0d6efd); + background-color: rgba(13, 110, 253, 0.08); +} + +.channel :global(.form-check) { + margin: 0; + min-height: auto; + flex: none; +} + +.icon { + flex: none; + opacity: 0.85; +} + +.spinner { + flex: none; + margin: 2px; +} + +.text { + display: flex; + flex-direction: column; + min-width: 0; +} + +.title { + font-weight: 600; + line-height: 1.3; +} + +.subtitle { + font-size: 0.8rem; + opacity: 0.7; +} + +.error { + font-size: 0.8rem; + color: var(--bs-danger, #dc3545); + margin-top: 2px; +} + +.disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.discordExpand { + display: flex; + flex-direction: column; + gap: 5px; + margin-top: 10px; + padding-left: 30px; +} diff --git a/components/SubscribeButton/ChannelPicker/ChannelPicker.tsx b/components/SubscribeButton/ChannelPicker/ChannelPicker.tsx new file mode 100644 index 000000000..49a705ce8 --- /dev/null +++ b/components/SubscribeButton/ChannelPicker/ChannelPicker.tsx @@ -0,0 +1,208 @@ +'use client' +import { useEffect, useRef, useState } from 'react' +import { Form, Spinner } from 'react-bootstrap' +import GamepadIcon from '@mui/icons-material/SportsEsports' +import PhoneIcon from '@mui/icons-material/PhoneIphone' +import DiscordIcon from '@mui/icons-material/Forum' +import api from '../../../api/ApiHelper' +import askForNotificationPermissons from '../../../utils/NotificationPermisson' +import { getNotificationTypeAsString } from '../../../utils/NotificationUtils' +import { ChannelSelection, fetchWebhookName, getExtraTargets, isValidDiscordWebhookUrl } from '../../../utils/NotificationChannelUtils' +import { getLoadingElement } from '../../../utils/LoadingUtils' +import styles from './ChannelPicker.module.css' + +interface Props { + targets: NotificationTarget[] + isLoading: boolean + selection: ChannelSelection + onChange(selection: ChannelSelection): void +} + +const PUSH_SUPPORTED = typeof window !== 'undefined' && 'Notification' in window + +function ChannelPicker(props: Props) { + let [pushPending, setPushPending] = useState(false) + let [pushError, setPushError] = useState(null) + // once the user edits the name themselves, stop auto-filling it from the fetched webhook name + let nameTouchedRef = useRef(false) + let selectionRef = useRef(props.selection) + selectionRef.current = props.selection + + let { selection } = props + + // when a valid webhook URL is entered, prefill the name field with the webhook's own name (Discord's + // API doesn't expose the channel name, only its id), unless the user has already typed their own label + useEffect(() => { + let url = selection.newDiscordUrl + if (url === null || !isValidDiscordWebhookUrl(url) || nameTouchedRef.current) { + return + } + let cancelled = false + fetchWebhookName(url).then(name => { + if (cancelled || !name || nameTouchedRef.current) { + return + } + let current = selectionRef.current + if (current.newDiscordUrl !== url) { + return // the url changed while fetching, don't clobber + } + props.onChange({ ...current, newDiscordName: name.slice(0, 32) }) + }) + return () => { + cancelled = true + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [selection.newDiscordUrl]) + + async function onToggleDevice(checked: boolean) { + setPushError(null) + if (!checked) { + props.onChange({ ...selection, push: false }) + return + } + setPushPending(true) + try { + let token = await askForNotificationPermissons() + // the token is now stored locally, so the channel can be selected. Registering it with the + // backend also happens again at submit, so don't block the checkbox on that websocket call. + api.setToken(token).catch(e => console.error('registering fcm token failed', e)) + props.onChange({ ...selection, push: true }) + } catch (e) { + setPushError(e instanceof Error ? e.message : 'Could not enable push notifications.') + } finally { + setPushPending(false) + } + } + + function onToggleDiscord(checked: boolean) { + nameTouchedRef.current = false + props.onChange({ ...selection, newDiscordUrl: checked ? '' : null, newDiscordName: '' }) + } + + function onToggleExisting(id: number, checked: boolean) { + let existingTargetIds = checked + ? [...selection.existingTargetIds, id] + : selection.existingTargetIds.filter(existingId => existingId !== id) + props.onChange({ ...selection, existingTargetIds }) + } + + if (props.isLoading) { + return getLoadingElement(

Loading channels...

) + } + + let discordChecked = selection.newDiscordUrl !== null + let discordInvalid = discordChecked && selection.newDiscordUrl !== '' && !isValidDiscordWebhookUrl(selection.newDiscordUrl || '') + let discordValid = discordChecked && isValidDiscordWebhookUrl(selection.newDiscordUrl || '') + let extraTargets = getExtraTargets(props.targets) + + return ( +
+ {/* In-game chat */} + + + {/* This device (push) */} + + + {/* New Discord webhook */} +
+ + {discordChecked ? ( +
+ props.onChange({ ...selection, newDiscordUrl: e.target.value })} + /> + {discordInvalid ? ( + + The URL has to start with "https://discord.com/api/..." or "https://discordapp.com/api/webhooks/...". + + ) : ( + + Server Settings → Integrations → Webhooks → Copy URL ·{' '} + + show me how + + + )} + {discordValid ? ( + <> + { + nameTouchedRef.current = true + props.onChange({ ...selection, newDiscordName: e.target.value }) + }} + /> + Shown in your notifier list. Defaults to the webhook name. + + ) : null} +
+ ) : null} +
+ + {/* Other existing targets */} + {extraTargets.map(target => ( + + ))} +
+ ) +} + +export default ChannelPicker diff --git a/components/SubscribeButton/CreateTargetDialog/CreateTargetDialog.tsx b/components/SubscribeButton/CreateTargetDialog/CreateTargetDialog.tsx deleted file mode 100644 index e973daa6b..000000000 --- a/components/SubscribeButton/CreateTargetDialog/CreateTargetDialog.tsx +++ /dev/null @@ -1,38 +0,0 @@ -'use client' -import { Modal } from 'react-bootstrap' -import NotificationTargetForm from '../../NotificationTargets/NotificationTargetForm' -import styles from '../SubscribeButton.module.css' - -interface Props { - show: boolean - onHide: () => void - onTargetCreated: (target: NotificationTarget) => void - popupTitle?: string -} - -function CreateTargetDialog(props: Props) { - function onTargetCreated(target: NotificationTarget) { - props.onTargetCreated(target) - props.onHide() - } - - return ( - - - {props.popupTitle || 'Create a Notification Target'} - - - - - - ) -} - -export default CreateTargetDialog diff --git a/components/SubscribeButton/NotifierDialog.tsx b/components/SubscribeButton/NotifierDialog.tsx new file mode 100644 index 000000000..5df4d2c6f --- /dev/null +++ b/components/SubscribeButton/NotifierDialog.tsx @@ -0,0 +1,266 @@ +'use client' +import { useRef, useState } from 'react' +import { Button, Modal } from 'react-bootstrap' +import { useMatomo } from '@jonkoops/matomo-tracker-react' +import { toast } from 'react-toastify' +import { useRouter } from 'next/navigation' +import api from '../../api/ApiHelper' +import { SubscriptionType } from '../../api/ApiTypes.d' +import GoogleSignIn from '../GoogleSignIn/GoogleSignIn' +import { getLoadingElement } from '../../utils/LoadingUtils' +import { useWasAlreadyLoggedIn } from '../../utils/Hooks' +import SubscribeItemContent from './SubscribeItemContent/SubscribeItemContent' +import SubscribePlayerContent from './SubscribePlayerContent/SubscribePlayerContent' +import SubscribeAuctionContent from './SubscribeAuctionContent/SubscribeAuctionContent' +import SubscribeBazaarItemContent from './SubscribeBazaarItemContent/SubscribeBazaarItemContent' +import ChannelPicker from './ChannelPicker/ChannelPicker' +import { + ChannelSelection, + getInitialChannelSelection, + isChannelSelectionValid, + rememberChannelSelection, + resolveChannelSelection +} from '../../utils/NotificationChannelUtils' +import styles from './SubscribeButton.module.css' + +interface Props { + show: boolean + onHide(): void + topic: string + type: 'player' | 'item' | 'auction' | 'bazaar' + currentPrice?: number + currentSellPrice?: number + prefill?: SubscribePrefill + popupTitle?: string + popupButtonText?: string + successMessage?: string + onAfterSubscribe?(): void +} + +const MAX_FILTERS = 5 + +function getInitialPrice(props: Props): string { + if (props.prefill?.listener?.price !== undefined && props.prefill?.listener?.price !== null) { + return props.prefill.listener.price.toString() + } + if (props.type === 'item' && props.currentPrice) { + // start a touch below the current price so a "drops below" notifier fires on any dip + return Math.floor(props.currentPrice * 0.99).toString() + } + if (props.type === 'bazaar' && props.currentPrice) { + return Math.floor(props.currentPrice).toString() + } + return '' +} + +function NotifierDialog(props: Props) { + let { trackEvent } = useMatomo() + let router = useRouter() + let prefillTypes = (props.prefill?.listener?.types as unknown as string[]) || [] + let [price, setPrice] = useState(getInitialPrice(props)) + let priceDirty = useRef(false) + let [isPriceAbove, setIsPriceAbove] = useState(prefillTypes.includes(SubscriptionType[SubscriptionType.PRICE_HIGHER_THAN])) + let [onlyInstantBuy, setOnlyInstantBuy] = useState(prefillTypes.includes(SubscriptionType[SubscriptionType.BIN])) + let [gotOutbid, setGotOutbid] = useState(prefillTypes.includes(SubscriptionType[SubscriptionType.OUTBID])) + let [isSold, setIsSold] = useState(prefillTypes.includes(SubscriptionType[SubscriptionType.SOLD])) + let [isPlayerAuctionCreation, setIsPlayerAuctionCreation] = useState(prefillTypes.includes(SubscriptionType[SubscriptionType.PLAYER_CREATES_AUCTION])) + let [hasPlayerBoughtAnyAuction, setHasPlayerBoughtAnyAuction] = useState(prefillTypes.includes(SubscriptionType[SubscriptionType.BOUGHT_ANY_AUCTION])) + let [isUseBazaarSellNotBuy, setIsUseBazaarSellNotBuy] = useState(prefillTypes.includes(SubscriptionType[SubscriptionType.USE_SELL_NOT_BUY])) + let [itemFilter, setItemFilter] = useState(props.prefill?.listener?.filter || undefined) + let [isItemFilterValid, setIsItemFilterValid] = useState(true) + let [isLoggedIn, setIsLoggedIn] = useState(false) + let [notificationTargets, setNotificationTargets] = useState([]) + let [isLoadingNotificationTargets, setIsLoadingNotificationTargets] = useState(false) + let [channelSelection, setChannelSelection] = useState({ inGame: true, push: false, newDiscordUrl: null, existingTargetIds: [] }) + let [isSubmitting, setIsSubmitting] = useState(false) + let wasAlreadyLoggedIn = useWasAlreadyLoggedIn() + + function getSubscriptionTypes(): SubscriptionType[] { + let types: SubscriptionType[] = [] + if (props.type === 'item') { + types.push(isPriceAbove ? SubscriptionType.PRICE_HIGHER_THAN : SubscriptionType.PRICE_LOWER_THAN) + if (onlyInstantBuy) { + types.push(SubscriptionType.BIN) + } + } + if (props.type === 'player') { + if (gotOutbid) types.push(SubscriptionType.OUTBID) + if (isSold) types.push(SubscriptionType.SOLD) + if (isPlayerAuctionCreation) types.push(SubscriptionType.PLAYER_CREATES_AUCTION) + if (hasPlayerBoughtAnyAuction) types.push(SubscriptionType.BOUGHT_ANY_AUCTION) + } + if (props.type === 'auction') { + types.push(SubscriptionType.AUCTION) + } + if (props.type === 'bazaar') { + types.push(isPriceAbove ? SubscriptionType.PRICE_HIGHER_THAN : SubscriptionType.PRICE_LOWER_THAN) + if (isUseBazaarSellNotBuy) { + types.push(SubscriptionType.USE_SELL_NOT_BUY) + } + } + return types + } + + async function onSubscribe() { + trackEvent({ action: 'subscribed', category: 'subscriptions' }) + + let priceToSend = price + if ((props.type === 'item' || props.type === 'bazaar') && !priceToSend) { + priceToSend = '0' + } + let filterToSend = itemFilter + if (props.type === 'item' && !filterToSend) { + filterToSend = {} + } + + setIsSubmitting(true) + let resolvedTargets: NotificationTarget[] + try { + resolvedTargets = await resolveChannelSelection(channelSelection, notificationTargets) + } catch (e) { + setIsSubmitting(false) + toast.error(e instanceof Error ? e.message : 'Could not set up the selected channels. Please try again.') + return + } + + api.subscribe(props.topic, getSubscriptionTypes(), resolvedTargets, priceToSend ? parseInt(priceToSend) : undefined, filterToSend) + .then(() => { + rememberChannelSelection(channelSelection, resolvedTargets) + props.onHide() + toast.success(props.successMessage || 'Notifier successfully created!', { + onClick: () => { + router.push('/subscriptions') + } + }) + if (props.onAfterSubscribe) { + props.onAfterSubscribe() + } + }) + .catch(error => { + if (error?.message) { + toast.error(error.message, { + onClick: () => { + router.push('/subscriptions') + } + }) + } + }) + .finally(() => { + setIsSubmitting(false) + }) + } + + function onLogin() { + setIsLoggedIn(true) + setIsLoadingNotificationTargets(true) + api.getNotificationTargets().then(targets => { + setNotificationTargets(targets) + setChannelSelection(getInitialChannelSelection(targets, props.prefill?.targetIds)) + setIsLoadingNotificationTargets(false) + }) + } + + function onBazaarUseSellChange(useSell: boolean) { + setIsUseBazaarSellNotBuy(useSell) + // re-seed the price from the matching market average, unless the user has typed their own value + if (!priceDirty.current && props.prefill?.listener?.price === undefined) { + let source = useSell ? props.currentSellPrice : props.currentPrice + if (source) { + setPrice(Math.floor(source).toString()) + } + } + } + + function onPriceChange(value: string) { + priceDirty.current = true + setPrice(value) + } + + function isNotifyDisabled(): boolean { + if (!isChannelSelectionValid(channelSelection)) { + return true + } + if (itemFilter && Object.keys(itemFilter).length > MAX_FILTERS) { + return true + } + if (props.type === 'item') { + return itemFilter && Object.keys(itemFilter).length > 0 ? false : price === undefined || price === '' + } + if (props.type === 'player') { + return !gotOutbid && !isSold && !isPlayerAuctionCreation && !hasPlayerBoughtAnyAuction + } + return false + } + + return ( + + + {props.popupTitle || 'Notify me'} + + + {isLoggedIn ? ( +
+
When
+ {props.type === 'item' ? ( + setItemFilter({ ...filter })} + onIsPriceAboveChange={setIsPriceAbove} + onOnlyInstantBuyChange={setOnlyInstantBuy} + onPriceChange={onPriceChange} + prefill={props.prefill?.listener} + prefillPrice={getInitialPrice(props)} + onIsFilterValidChange={setIsItemFilterValid} + /> + ) : null} + {props.type === 'bazaar' ? ( + + ) : null} + {props.type === 'player' ? ( + + ) : null} + {props.type === 'auction' ? : null} + +
Where
+ + + + {itemFilter && Object.keys(itemFilter).length > MAX_FILTERS ? ( +

You currently can't use more than {MAX_FILTERS} filters for Notifiers

+ ) : null} +
+ ) : ( +

To use notifiers, please login with Google:

+ )} + + {wasAlreadyLoggedIn && !isLoggedIn ? getLoadingElement() : ''} +
+
+ ) +} + +export default NotifierDialog diff --git a/components/SubscribeButton/SubscribeBazaarItemContent/SubscribeBazaarItemContent.module.css b/components/SubscribeButton/SubscribeBazaarItemContent/SubscribeBazaarItemContent.module.css index 238ec7832..68fc2914c 100644 --- a/components/SubscribeButton/SubscribeBazaarItemContent/SubscribeBazaarItemContent.module.css +++ b/components/SubscribeButton/SubscribeBazaarItemContent/SubscribeBazaarItemContent.module.css @@ -2,3 +2,16 @@ display: inline; margin-left: 5px; } + +.priceDirection { + width: 100%; + margin-bottom: 12px; +} + +.priceDirection :global(.btn) { + flex: 1; +} + +.priceInput { + margin-bottom: 10px; +} diff --git a/components/SubscribeButton/SubscribeBazaarItemContent/SubscribeBazaarItemContent.tsx b/components/SubscribeButton/SubscribeBazaarItemContent/SubscribeBazaarItemContent.tsx index 137e6b52b..99fbb8f52 100644 --- a/components/SubscribeButton/SubscribeBazaarItemContent/SubscribeBazaarItemContent.tsx +++ b/components/SubscribeButton/SubscribeBazaarItemContent/SubscribeBazaarItemContent.tsx @@ -1,7 +1,9 @@ 'use client' -import { FormControl, InputGroup } from 'react-bootstrap' +import { useState } from 'react' +import { FormControl, InputGroup, ToggleButton, ToggleButtonGroup } from 'react-bootstrap' import { Form } from 'react-bootstrap' import { NotificationListener, SubscriptionType } from '../../../api/ApiTypes.d' +import { formatThousandsSpaced, parseFormattedNumber } from '../../../utils/Formatter' import styles from './SubscribeBazaarItemContent.module.css' interface Props { @@ -10,62 +12,62 @@ interface Props { onUseSellPriceChange(value: boolean) itemTag: string prefill?: NotificationListener + priceValue?: string } function SubscribeBazaarItemContent(props: Props) { + let prefillIsAbove = props.prefill + ? (props.prefill.types as unknown as string[]).includes(SubscriptionType[SubscriptionType.PRICE_HIGHER_THAN]) + : false + let prefillUseSell = props.prefill + ? (props.prefill.types as unknown as string[]).includes(SubscriptionType[SubscriptionType.USE_SELL_NOT_BUY]) + : false + let [isAbove, setIsAbove] = useState(prefillIsAbove) + let [useSell, setUseSell] = useState(prefillUseSell) + return ( <>
- - Item price + { + let above = val === 'above' + setIsAbove(above) + props.onIsPriceAboveChange(above) + }} + > + + Price drops below + + + Price rises above + + + + Price props.onPriceChange(e.target.value)} + type="text" + inputMode="numeric" + value={formatThousandsSpaced(props.priceValue ?? '')} + onChange={e => props.onPriceChange(parseFormattedNumber(e.target.value))} /> + coins -
-

Notify me...

- - if the price is above the selected value - props.onIsPriceAboveChange(true)} - className={styles.checkBox} - /> - - - if the price is below the selected value - props.onIsPriceAboveChange(false)} - className={styles.checkBox} - /> - - - if the sell price should be used - props.onUseSellPriceChange(e.target.checked)} - className={styles.checkBox} - /> - + { + setUseSell(e.target.checked) + props.onUseSellPriceChange(e.target.checked) + }} + />
) diff --git a/components/SubscribeButton/SubscribeButton.module.css b/components/SubscribeButton/SubscribeButton.module.css index d644606ab..a13d5104b 100644 --- a/components/SubscribeButton/SubscribeButton.module.css +++ b/components/SubscribeButton/SubscribeButton.module.css @@ -12,6 +12,7 @@ .notifyButton { margin-top: 20px; + width: 100%; } .multiSearch :global(.rbt-token){ @@ -19,6 +20,16 @@ color: white; } -.notifyButton{ - margin-top: 20px; +.sectionHeading { + font-size: 0.8rem; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + opacity: 0.7; + margin-top: 18px; + margin-bottom: 10px; +} + +.sectionHeading:first-child { + margin-top: 0; } \ No newline at end of file diff --git a/components/SubscribeButton/SubscribeButton.tsx b/components/SubscribeButton/SubscribeButton.tsx index dfa3819c9..9f3974f15 100644 --- a/components/SubscribeButton/SubscribeButton.tsx +++ b/components/SubscribeButton/SubscribeButton.tsx @@ -1,272 +1,54 @@ 'use client' import { useState, type JSX } from 'react' -import { Button, Modal } from 'react-bootstrap' +import { Button } from 'react-bootstrap' import { useMatomo } from '@jonkoops/matomo-tracker-react' -import api from '../../api/ApiHelper' -import { NotificationListener, SubscriptionType } from '../../api/ApiTypes.d' -import GoogleSignIn from '../GoogleSignIn/GoogleSignIn' -import { toast } from 'react-toastify' import NotificationIcon from '@mui/icons-material/NotificationsOutlined' -import styles from './SubscribeButton.module.css' -import SubscribeItemContent from './SubscribeItemContent/SubscribeItemContent' -import { getLoadingElement } from '../../utils/LoadingUtils' -import SubscribePlayerContent from './SubscribePlayerContent/SubscribePlayerContent' -import SubscribeAuctionContent from './SubscribeAuctionContent/SubscribeAuctionContent' -import { useRouter } from 'next/navigation' -import { useWasAlreadyLoggedIn } from '../../utils/Hooks' import EditIcon from '@mui/icons-material/Edit' -import { Typeahead } from 'react-bootstrap-typeahead' -import SubscribeBazaarItemContent from './SubscribeBazaarItemContent/SubscribeBazaarItemContent' -import CreateTargetDialog from './CreateTargetDialog/CreateTargetDialog' +import styles from './SubscribeButton.module.css' +import NotifierDialog from './NotifierDialog' interface Props { topic: string type: 'player' | 'item' | 'auction' | 'bazaar' buttonContent?: JSX.Element isEditButton?: boolean + currentPrice?: number + currentSellPrice?: number onAfterSubscribe?() - prefill?: { - listener: NotificationListener - targetNames: string[] - } + prefill?: SubscribePrefill popupTitle?: string popupButtonText?: string successMessage?: string } -const MAX_FILTERS = 5 - function SubscribeButton(props: Props) { let { trackEvent } = useMatomo() - let router = useRouter() let [showDialog, setShowDialog] = useState(false) - let [price, setPrice] = useState(props.prefill?.listener?.price?.toString() || '0') - let [isPriceAbove, setIsPriceAbove] = useState(props.prefill?.listener?.types?.includes(SubscriptionType.PRICE_HIGHER_THAN) ?? false) - let [onlyInstantBuy, setOnlyInstantBuy] = useState(props.prefill?.listener?.types?.includes(SubscriptionType.BIN) ?? false) - let [gotOutbid, setGotOutbid] = useState(props.prefill?.listener?.types?.includes(SubscriptionType.OUTBID) ?? false) - let [isSold, setIsSold] = useState(props.prefill?.listener?.types?.includes(SubscriptionType.SOLD) ?? false) - let [isPlayerAuctionCreation, setIsPlayerAuctionCreation] = useState( - props.prefill?.listener?.types?.includes(SubscriptionType.PLAYER_CREATES_AUCTION) ?? false - ) - let [hasPlayerBoughtAnyAuction, setHasPlayerBoughtAnyAuction] = useState( - props.prefill?.listener?.types?.includes(SubscriptionType.BOUGHT_ANY_AUCTION) ?? false - ) - let [isUseBazaarSellNotBuy, setIsUseBazaarSellNotBuy] = useState(props.prefill?.listener?.types?.includes(SubscriptionType.USE_SELL_NOT_BUY) ?? false) - let [isLoggedIn, setIsLoggedIn] = useState(false) - let [itemFilter, setItemFilter] = useState(props.prefill?.listener?.filter || undefined) - let [isItemFilterValid, setIsItemFilterValid] = useState(true) - let wasAlreadyLoggedIn = useWasAlreadyLoggedIn() - let [notificationTargets, setNotificationTargets] = useState([]) - let [selectedNotificationTargets, setSelectedNotificationTargets] = useState([]) - let [isLoadingNotificationTargets, setIsLoadingNotificationTargets] = useState(false) - let [showCreateTargetDialog, setShowCreateTargetDialog] = useState(false) - - async function onSubscribe() { - trackEvent({ action: 'subscribed', category: 'subscriptions' }) - setShowDialog(false) - // Set price to 0 per default for item subscriptions - // This happens if a user only selects a filter and leaves the price field empty - if (props.type === 'item' && !price) { - price = '0' - } - if (props.type === 'bazaar' && !price) { - price = '0' - } - if (props.type === 'item' && !itemFilter) { - itemFilter = {} - } - - api.subscribe(props.topic, getSubscriptionTypes(), selectedNotificationTargets, price ? parseInt(price) : undefined, itemFilter) - .then(() => { - toast.success(props.successMessage || 'Notifier successfully created!', { - onClick: () => { - router.push('/subscriptions') - } - }) - if (props.onAfterSubscribe) { - props.onAfterSubscribe() - } - }) - .catch(error => { - toast.error(error.message, { - onClick: () => { - router.push('/subscriptions') - } - }) - }) - } - - function getSubscriptionTypes(): SubscriptionType[] { - let types: SubscriptionType[] = [] - if (props.type === 'item') { - if (isPriceAbove) { - types.push(SubscriptionType.PRICE_HIGHER_THAN) - } - if (!isPriceAbove) { - types.push(SubscriptionType.PRICE_LOWER_THAN) - } - if (onlyInstantBuy) { - types.push(SubscriptionType.BIN) - } - } - if (props.type === 'player') { - if (gotOutbid) { - types.push(SubscriptionType.OUTBID) - } - if (isSold) { - types.push(SubscriptionType.SOLD) - } - if (isPlayerAuctionCreation) { - types.push(SubscriptionType.PLAYER_CREATES_AUCTION) - } - if (hasPlayerBoughtAnyAuction) { - types.push(SubscriptionType.BOUGHT_ANY_AUCTION) - } - } - if (props.type === 'auction') { - types.push(SubscriptionType.AUCTION) - } - if (props.type === 'bazaar') { - if (isPriceAbove) { - types.push(SubscriptionType.PRICE_HIGHER_THAN) - } - if (!isPriceAbove) { - types.push(SubscriptionType.PRICE_LOWER_THAN) - } - if (isUseBazaarSellNotBuy) { - types.push(SubscriptionType.USE_SELL_NOT_BUY) - } - } - return types - } - - function onLogin() { - setIsLoggedIn(true) - setIsLoadingNotificationTargets(true) - api.getNotificationTargets().then(targets => { - if (props.prefill?.targetNames) { - setSelectedNotificationTargets(targets.filter(target => (target.name ? props.prefill?.targetNames.includes(target.name) : false))) - } - setNotificationTargets(targets) - setIsLoadingNotificationTargets(false) - }) - } - - function isNotifyDisabled() { - if (itemFilter && Object.keys(itemFilter).length > MAX_FILTERS) { - return true - } - if (props.type === 'item') { - return itemFilter && Object.keys(itemFilter).length > 0 ? false : price === undefined || price === '' - } - if (props.type === 'player') { - return !gotOutbid && !isSold && !isPlayerAuctionCreation - } - } - - function closeDialog() { - trackEvent({ action: 'subscription dialog closed', category: 'subscriptions' }) - setShowDialog(false) - } function openDialog() { trackEvent({ action: 'subscription dialog opened', category: 'subscriptions' }) setShowDialog(true) } - function openCreateTargetDialog() { - setShowCreateTargetDialog(true) - } - - function onTargetCreated(target: NotificationTarget) { - setSelectedNotificationTargets([...selectedNotificationTargets, target]) - setNotificationTargets([...notificationTargets, target]) + function closeDialog() { + trackEvent({ action: 'subscription dialog closed', category: 'subscriptions' }) + setShowDialog(false) } - let dialog = ( - - - {props.popupTitle || 'Create a Notifier'} - - - {isLoggedIn ? ( -
- {props.type === 'item' ? ( - { - setItemFilter({ ...filter }) - }} - onIsPriceAboveChange={setIsPriceAbove} - onOnlyInstantBuyChange={setOnlyInstantBuy} - onPriceChange={setPrice} - prefill={props.prefill?.listener} - onIsFilterValidChange={setIsItemFilterValid} - /> - ) : null} - {props.type === 'bazaar' ? ( - - ) : null} - {props.type === 'player' ? ( - - ) : null} - {props.type === 'auction' ? : null} - -
- { - setSelectedNotificationTargets(selected as NotificationTarget[]) - }} - multiple={true} - /> - -
- - {itemFilter && Object.keys(itemFilter).length > MAX_FILTERS ? ( -

You currently can't use more than 5 filters for Notifiers

- ) : null} -
- ) : ( -

To use notifiers, please login with Google:

- )} - - {wasAlreadyLoggedIn && !isLoggedIn ? getLoadingElement() : ''} -
-
- ) - return (
- {dialog} - setShowCreateTargetDialog(false)} - onTargetCreated={onTargetCreated} + {props.isEditButton ? (
diff --git a/components/SubscribeButton/SubscribeItemContent/SubscribeItemContent.module.css b/components/SubscribeButton/SubscribeItemContent/SubscribeItemContent.module.css index 238ec7832..677a3fcc4 100644 --- a/components/SubscribeButton/SubscribeItemContent/SubscribeItemContent.module.css +++ b/components/SubscribeButton/SubscribeItemContent/SubscribeItemContent.module.css @@ -1,4 +1,28 @@ .checkBox { - display: inline; margin-left: 5px; } + +.priceDirection { + width: 100%; + margin-bottom: 12px; +} + +.priceDirection :global(.btn) { + flex: 1; +} + +.priceInput { + margin-bottom: 10px; +} + +.moreLink { + display: inline-block; + font-size: 0.9rem; +} + +.moreOptions { + display: flex; + flex-direction: column; + gap: 10px; + margin-top: 4px; +} diff --git a/components/SubscribeButton/SubscribeItemContent/SubscribeItemContent.tsx b/components/SubscribeButton/SubscribeItemContent/SubscribeItemContent.tsx index b2b0a40c4..aecbd2865 100644 --- a/components/SubscribeButton/SubscribeItemContent/SubscribeItemContent.tsx +++ b/components/SubscribeButton/SubscribeItemContent/SubscribeItemContent.tsx @@ -1,10 +1,11 @@ 'use client' import { useEffect, useState } from 'react' -import { FormControl, InputGroup } from 'react-bootstrap' +import { FormControl, InputGroup, ToggleButton, ToggleButtonGroup } from 'react-bootstrap' import { Form } from 'react-bootstrap' import api from '../../../api/ApiHelper' import { NotificationListener, SubscriptionType } from '../../../api/ApiTypes.d' import ItemFilter from '../../ItemFilter/ItemFilter' +import { formatThousandsSpaced, parseFormattedNumber } from '../../../utils/Formatter' import styles from './SubscribeItemContent.module.css' interface Props { @@ -14,11 +15,20 @@ interface Props { onFilterChange(filter: ItemFilter) itemTag: string prefill?: NotificationListener + prefillPrice?: string onIsFilterValidChange?(newIsFilter: boolean) } function SubscribeItemContent(props: Props) { let [filterOptions, setFilterOptions] = useState() + let prefillIsAbove = props.prefill + ? (props.prefill.types as unknown as string[]).includes(SubscriptionType[SubscriptionType.PRICE_HIGHER_THAN]) + : false + let prefillHasFilter = props.prefill?.filter && Object.keys(props.prefill.filter).length > 0 + let prefillIsBin = props.prefill ? (props.prefill.types as unknown as string[]).includes(SubscriptionType[SubscriptionType.BIN]) : false + let [isAbove, setIsAbove] = useState(prefillIsAbove) + let [showMore, setShowMore] = useState(!!prefillHasFilter || prefillIsBin) + let [priceDisplay, setPriceDisplay] = useState(formatThousandsSpaced(props.prefill?.price?.toString() ?? props.prefillPrice ?? '')) useEffect(() => { api.getFilters(props.itemTag).then(options => { @@ -30,67 +40,78 @@ function SubscribeItemContent(props: Props) { return ( <>
- - Item price + { + let above = val === 'above' + setIsAbove(above) + props.onIsPriceAboveChange(above) + }} + > + + Price drops below + + + Price rises above + + + + Price props.onPriceChange(e.target.value)} + type="text" + inputMode="numeric" + value={priceDisplay} + onChange={e => { + let raw = parseFormattedNumber(e.target.value) + setPriceDisplay(formatThousandsSpaced(raw)) + props.onPriceChange(raw) + }} /> + coins -
-

Notify me...

- - if the price is above the selected value - props.onIsPriceAboveChange(true)} - className={styles.checkBox} - /> - - - if the price is below the selected value - props.onIsPriceAboveChange(false)} - className={styles.checkBox} - /> - - - only for instant buy - { - props.onOnlyInstantBuyChange((e.target as HTMLInputElement).checked) + e.preventDefault() + setShowMore(true) }} - /> - - - - + > + + More options (instant-buy only, item filter) + + ) : ( +
+ + { + props.onOnlyInstantBuyChange((e.target as HTMLInputElement).checked) + }} + /> + + + + +
+ )}
) diff --git a/components/SubscribeButton/WhitelistSubscribeButton/WhitelistSubscribeButton.tsx b/components/SubscribeButton/WhitelistSubscribeButton/WhitelistSubscribeButton.tsx index a28b71965..102d91db0 100644 --- a/components/SubscribeButton/WhitelistSubscribeButton/WhitelistSubscribeButton.tsx +++ b/components/SubscribeButton/WhitelistSubscribeButton/WhitelistSubscribeButton.tsx @@ -6,15 +6,21 @@ import { toast } from 'react-toastify' import NotificationIcon from '@mui/icons-material/NotificationsOutlined' import styles from '../SubscribeButton.module.css' import { useRouter } from 'next/navigation' -import { Typeahead } from 'react-bootstrap-typeahead' import { SubscriptionType } from '../../../api/ApiTypes.d' import { useWasAlreadyLoggedIn } from '../../../utils/Hooks' import api from '../../../api/ApiHelper' -import CreateTargetDialog from '../CreateTargetDialog/CreateTargetDialog' import GoogleSignIn from '../../GoogleSignIn/GoogleSignIn' import { getLoadingElement } from '../../../utils/LoadingUtils' import { hasHighEnoughPremium, PREMIUM_RANK } from '../../../utils/PremiumTypeUtils' import Link from 'next/link' +import ChannelPicker from '../ChannelPicker/ChannelPicker' +import { + ChannelSelection, + getInitialChannelSelection, + isChannelSelectionValid, + rememberChannelSelection, + resolveChannelSelection +} from '../../../utils/NotificationChannelUtils' interface Props { onAfterSubscribe?(): void @@ -27,11 +33,11 @@ function WhitelistSubscribeButton(props: Props) { let [isLoggedIn, setIsLoggedIn] = useState(false) let wasAlreadyLoggedIn = useWasAlreadyLoggedIn() let [notificationTargets, setNotificationTargets] = useState([]) - let [selectedNotificationTargets, setSelectedNotificationTargets] = useState([]) + let [channelSelection, setChannelSelection] = useState({ inGame: true, push: false, newDiscordUrl: null, existingTargetIds: [] }) let [isLoadingNotificationTargets, setIsLoadingNotificationTargets] = useState(false) - let [showCreateTargetDialog, setShowCreateTargetDialog] = useState(false) let [hasPremium, setHasPremium] = useState(false) let [isPremiumLoading, setIsPremiumLoading] = useState(false) + let [isSubmitting, setIsSubmitting] = useState(false) async function onSubscribe() { if (!hasPremium) { @@ -40,10 +46,21 @@ function WhitelistSubscribeButton(props: Props) { } trackEvent({ action: 'subscribed', category: 'subscriptions' }) - setShowDialog(false) - api.subscribe("whitelist", [SubscriptionType.WHITELIST], selectedNotificationTargets, undefined, undefined) + setIsSubmitting(true) + let resolvedTargets: NotificationTarget[] + try { + resolvedTargets = await resolveChannelSelection(channelSelection, notificationTargets) + } catch (e) { + setIsSubmitting(false) + toast.error(e instanceof Error ? e.message : 'Could not set up the selected channels. Please try again.') + return + } + + api.subscribe('whitelist', [SubscriptionType.WHITELIST], resolvedTargets, undefined, undefined) .then(() => { + rememberChannelSelection(channelSelection, resolvedTargets) + setShowDialog(false) toast.success('Notifier successfully created!', { onClick: () => { router.push('/subscriptions') @@ -54,11 +71,16 @@ function WhitelistSubscribeButton(props: Props) { } }) .catch(error => { - toast.error(error.message, { - onClick: () => { - router.push('/subscriptions') - } - }) + if (error?.message) { + toast.error(error.message, { + onClick: () => { + router.push('/subscriptions') + } + }) + } + }) + .finally(() => { + setIsSubmitting(false) }) } @@ -73,6 +95,7 @@ function WhitelistSubscribeButton(props: Props) { }) ]).then(([targets]) => { setNotificationTargets(targets) + setChannelSelection(getInitialChannelSelection(targets)) setIsLoadingNotificationTargets(false) setIsPremiumLoading(false) }) @@ -100,35 +123,22 @@ function WhitelistSubscribeButton(props: Props) { ) : hasPremium ? (

- Creating this will send a notification to the selected target whenever a new auction matching your whitelist from Auction flipper is created. + Get notified whenever a new auction matching your whitelist from the{' '} + Auction flipper is created.

- -
- { - setSelectedNotificationTargets(selected as NotificationTarget[]) - }} - multiple={true} - /> - -
-
) : ( @@ -136,12 +146,8 @@ function WhitelistSubscribeButton(props: Props) {
🔒
Premium Feature
-

- Whitelist notifiers require at least Premium access. -

-

- Click the notification icon to open this dialog again after purchasing premium. -

+

Whitelist notifiers require at least Premium access.

+

Click the notification icon to open this dialog again after purchasing premium.

@@ -159,19 +165,9 @@ function WhitelistSubscribeButton(props: Props) { ) - function onTargetCreated(target: NotificationTarget) { - setSelectedNotificationTargets([...selectedNotificationTargets, target]) - setNotificationTargets([...notificationTargets, target]) - } - return (
{dialog} - setShowCreateTargetDialog(false)} - onTargetCreated={onTargetCreated} - /> + + setShowSearch(false)}> + + What do you want to be notified about? + + +

Search for an item or player to create a notifier for it.

+ +
+
+ + {picked ? ( + setPicked(null)} + topic={picked.topic} + type={picked.type} + onAfterSubscribe={() => { + setPicked(null) + props.onAfterSubscribe() + }} + /> + ) : null} + + ) +} + +export default NewNotifierButton diff --git a/components/SubscriptionList/SubscriptionList.module.css b/components/SubscriptionList/SubscriptionList.module.css index 23a10eeb1..fca888c34 100644 --- a/components/SubscriptionList/SubscriptionList.module.css +++ b/components/SubscriptionList/SubscriptionList.module.css @@ -13,4 +13,29 @@ .multiSearch :global(.rbt-token){ background-color: #444; color: white; +} + +.chips { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-top: 8px; +} + +.chip { + display: inline-flex; + align-items: center; + gap: 4px; + background-color: rgba(128, 128, 128, 0.18); + border-radius: 999px; + padding: 3px 10px; + font-size: 0.8rem; + white-space: nowrap; +} + +.noChannels { + display: inline-block; + margin-top: 8px; + font-size: 0.8rem; + opacity: 0.7; } \ No newline at end of file diff --git a/components/SubscriptionList/SubscriptionList.tsx b/components/SubscriptionList/SubscriptionList.tsx index 9126e10d6..2d59e40b3 100644 --- a/components/SubscriptionList/SubscriptionList.tsx +++ b/components/SubscriptionList/SubscriptionList.tsx @@ -17,9 +17,8 @@ import Number from '../Number/Number' import SubscribeButton from '../SubscribeButton/SubscribeButton' import styles from './SubscriptionList.module.css' import NotificationTargets from '../NotificationTargets/NotificationTargets' -import { Typeahead } from 'react-bootstrap-typeahead' - -import NotificationIcon from '@mui/icons-material/NotificationsOutlined' +import ChannelChips from './ChannelChips' +import NewNotifierButton from './NewNotifierButton' import WhitelistSubscribeButton from '../SubscribeButton/WhitelistSubscribeButton/WhitelistSubscribeButton' let mounted = true @@ -27,6 +26,7 @@ let mounted = true function SubscriptionList() { let [listener, setListener] = useState([]) let [subscriptions, setSubscriptions] = useState([]) + let [allTargets, setAllTargets] = useState([]) let [isLoggedIn, setIsLoggedIn] = useState(false) let [hasStarterPremium, setHasStarterPremium] = useState(false) let [showDeleteAllSubscriptionDialog, setShowDeleteAllSubscriptionDialog] = useState(false) @@ -100,6 +100,15 @@ function SubscriptionList() { }) } + function loadTargets() { + return api.getNotificationTargets().then(targets => { + if (!mounted) { + return + } + setAllTargets(targets) + }) + } + function onLogin() { let googleId = sessionStorage.getItem('googleId') if (googleId) { @@ -113,7 +122,7 @@ function SubscriptionList() { setHasStarterPremium(false) } ) - Promise.all([loadListener(), loadSubscriptions()]).then(() => { + Promise.all([loadListener(), loadSubscriptions(), loadTargets()]).then(() => { setIsLoading(false) }) } @@ -255,7 +264,7 @@ function SubscriptionList() { setSubscriptions([]) setIsLoading(true) - Promise.all([loadListener(), loadSubscriptions()]).then(() => { + Promise.all([loadListener(), loadSubscriptions(), loadTargets()]).then(() => { setIsLoading(false) }) }) @@ -336,22 +345,9 @@ function SubscriptionList() { {i + 1} - {subscription.sourceType} + Legacy notifier -

SourceSubIdRegex: {subscription.sourceSubIdRegex}

-
-

Notification Targets: {subscription.targets.length > 0 ? null : 'None'}

- {subscriptions.length > 0 ? ( - - ) : null} -
+
@@ -381,19 +377,7 @@ function SubscriptionList() { {getSubTypesAsList(listener.types, listener.price)} {listener.filter ?
: null} -
-

Notification Targets: {subscription.targets.length > 0 ? null : 'None'}

- {subscriptions.length > 0 ? ( - - ) : null} -
+
{listener.type === 'whitelist' ? null : ( 0 ? subscription.targets.map(t => t.name) : [] + targetIds: subscription.id && subscription.targets.length > 0 ? subscription.targets.map(t => t.id) : [] }} popupTitle="Update Notifier" popupButtonText="Update" @@ -423,14 +407,6 @@ function SubscriptionList() { } }) - function openDialog() { - toast.warn('Not on this page, use the search bar to go to what you want to be notified about') - const searchBar = document.getElementById('search-bar') as HTMLInputElement | null - if (searchBar) { - searchBar.focus() - } - } - let resetSettingsElement = ( - Notification Targets + Channels @@ -487,20 +463,28 @@ function SubscriptionList() { getLoadingElement() ) : ( <> -
+
+ { + setIsLoading(true) + Promise.all([loadListener(), loadSubscriptions(), loadTargets()]).then(() => { + setIsLoading(false) + }) + }} + /> { setIsLoading(true); - Promise.all([loadListener(), loadSubscriptions()]).then(() => { + Promise.all([loadListener(), loadSubscriptions(), loadTargets()]).then(() => { setIsLoading(false); }); }} /> {' '} - Button on a page you want to be notified about + Use New notifier above to search for an item or player, or press the Notify button on any item, player or auction page. Choose{' '} + where you want to be notified — in-game, on this device, or a Discord server — under Channels.

diff --git a/global.d.ts b/global.d.ts index 83726de96..c256da068 100644 --- a/global.d.ts +++ b/global.d.ts @@ -726,6 +726,14 @@ interface NotificationTarget { target: string | null name: string | null useCount: number + // only used when passing targets into subscribe(): attaches the target's connection as disabled + // (e.g. the in-game "never notify" marker, which must exist but must never be sent to) + isDisabled?: boolean +} + +interface SubscribePrefill { + listener: NotificationListener + targetIds: number[] } interface NotificationSubscription { diff --git a/public/preScript.js b/public/preScript.js index 7f4c97aac..d0b572b73 100644 --- a/public/preScript.js +++ b/public/preScript.js @@ -1,11 +1,28 @@ if ('serviceWorker' in navigator) { - window.addEventListener('load', function () { - navigator.serviceWorker.register('/serviceWorker.js').then( + // this script is injected after hydration (afterInteractive), so the window "load" event may have + // already fired by the time it runs. in that case addEventListener('load') would never trigger and + // window.messaging would never be set, so run immediately when the document is already complete. + if (document.readyState === 'complete') { + registerServiceWorker() + } else { + window.addEventListener('load', registerServiceWorker) + } +} else { + console.log('ServiceWorker was not registered') +} + +function registerServiceWorker() { + navigator.serviceWorker.register('/serviceWorker.js').then( function (registration) { setTimeout(() => { - loadScript('https://www.gstatic.com/firebasejs/8.2.2/firebase-app.js') - loadScript('https://www.gstatic.com/firebasejs/8.2.2/firebase-messaging.js') - pushNotifications(registration) + // load firebase-app before firebase-messaging: as two async scripts their execution order + // is a race, and firebase-messaging throws ("be sure to load firebase-app.js first" / + // reading 'INTERNAL') when it runs first. chaining the loads makes the order deterministic. + loadScript('https://www.gstatic.com/firebasejs/8.2.2/firebase-app.js', function () { + loadScript('https://www.gstatic.com/firebasejs/8.2.2/firebase-messaging.js', function () { + pushNotifications(registration) + }) + }) }, 5000) // Registration was successful @@ -34,16 +51,16 @@ if ('serviceWorker' in navigator) { break } } - }) -} else { - console.log('ServiceWorker was not registered') } -function loadScript(url) { +function loadScript(url, onLoad) { let script = document.createElement('script') script.type = 'text/javascript' script.async = true script.src = url + if (onLoad) { + script.onload = onLoad + } document.getElementsByTagName('head')[0].appendChild(script) } diff --git a/utils/Formatter.tsx b/utils/Formatter.tsx index ab3895c88..e73061d31 100644 --- a/utils/Formatter.tsx +++ b/utils/Formatter.tsx @@ -10,7 +10,35 @@ export function numberWithThousandsSeparators(number?: number, thousandSeperator if (!number) { return '0' } - return number.toLocaleString() + // no explicit separators: fall back to the viewer's locale (default behaviour) + if (thousandSeperator === undefined && decimalSeperator === undefined) { + return number.toLocaleString() + } + // explicit separators: group deterministically (used for SSR and the price inputs) + let [intPart, decPart] = Math.abs(number).toString().split('.') + let grouped = intPart.replace(/\B(?=(\d{3})+(?!\d))/g, thousandSeperator ?? '') + let sign = number < 0 ? '-' : '' + return decPart !== undefined ? `${sign}${grouped}${decimalSeperator ?? '.'}${decPart}` : `${sign}${grouped}` +} + +/* + Groups the digits of a numeric input value with spaces every three digits so big prices are + readable while typing. Example: "3333333" => "3 333 333". Non-digits are stripped; empty stays + empty (so a cleared input doesn't show "0"). +*/ +export function formatThousandsSpaced(value: string | number): string { + let digits = parseFormattedNumber(value.toString()) + if (!digits) { + return '' + } + return numberWithThousandsSeparators(Number(digits), ' ') +} + +/* + Strips any grouping/formatting back to a plain digit string, e.g. "3 333 333" => "3333333". +*/ +export function parseFormattedNumber(value: string): string { + return value.replace(/\D/g, '') } /** diff --git a/utils/NotificationChannelUtils.tsx b/utils/NotificationChannelUtils.tsx new file mode 100644 index 000000000..e74d7f693 --- /dev/null +++ b/utils/NotificationChannelUtils.tsx @@ -0,0 +1,234 @@ +import api from '../api/ApiHelper' +import { NOTIFIER_LAST_CHANNELS, getSettingsObject, setSetting } from './SettingsUtils' +import { isClientSideRendering } from './SSRUtils' +import { findInGameNeverTarget, isNotificationType } from './NotificationUtils' + +/** + * User-facing model of where a notifier should deliver. This is plain UI state: the actual + * NotificationTarget rows are only created/looked up at submit time via resolveChannelSelection. + */ +export interface ChannelSelection { + /** in-game chat, on by default; when false a shared "in-game disabled" marker target is attached */ + inGame: boolean + /** push to this device; only true once browser permission + FCM token have been obtained */ + push: boolean + /** null = Discord row unchecked; string (incl. '') = checked and being typed */ + newDiscordUrl: string | null + /** optional label for the new Discord webhook; prefilled with the fetched webhook name, user-overridable */ + newDiscordName?: string + /** ids of other existing targets (past webhooks, other devices, legacy targets) toggled on */ + existingTargetIds: number[] +} + +const MAX_TARGET_NAME_LENGTH = 32 + +export function isValidDiscordWebhookUrl(url: string): boolean { + let trimmed = url.trim() + return trimmed.startsWith('https://discord.com/api/') || trimmed.startsWith('https://discordapp.com/api/webhooks/') +} + +/** Builds a friendly name for the current browser/device, e.g. "Chrome on Linux". */ +export function getDeviceName(): string { + if (typeof navigator === 'undefined') { + return 'This device' + } + let ua = navigator.userAgent + let browser = 'Browser' + // order matters: Edge/Opera UAs also contain "Chrome", Chrome's UA also contains "Safari" + if (/Edg\//.test(ua)) browser = 'Edge' + else if (/OPR\//.test(ua) || /Opera/.test(ua)) browser = 'Opera' + else if (/Firefox\//.test(ua)) browser = 'Firefox' + else if (/Chrome\//.test(ua)) browser = 'Chrome' + else if (/Safari\//.test(ua)) browser = 'Safari' + + let os = 'device' + if (/Windows/.test(ua)) os = 'Windows' + else if (/Android/.test(ua)) os = 'Android' + else if (/iPhone|iPad|iPod/.test(ua)) os = 'iOS' + else if (/Mac OS X/.test(ua)) os = 'macOS' + else if (/Linux/.test(ua)) os = 'Linux' + + return `${browser} on ${os}`.slice(0, MAX_TARGET_NAME_LENGTH) +} + +/** Discord serves an unauthenticated GET on webhook URLs returning { name, ... }; used only to label the row. */ +export async function fetchWebhookName(url: string): Promise { + try { + let controller = new AbortController() + let timeout = setTimeout(() => controller.abort(), 2000) + let res = await fetch(url.trim(), { signal: controller.signal }) + clearTimeout(timeout) + if (!res.ok) { + return null + } + let data = await res.json() + return data?.name || null + } catch { + return null + } +} + +function getStoredFcmToken(): string | null { + return isClientSideRendering() ? localStorage.getItem('fcmToken') : null +} + +/** The FIREBASE target that represents the current browser (matched by the stored FCM token). */ +export function findDeviceTarget(targets: NotificationTarget[]): NotificationTarget | undefined { + let token = getStoredFcmToken() + if (!token) { + return undefined + } + return targets.find(t => isNotificationType(t.type, 'FIREBASE') && t.target === token) +} + +/** + * Extra target rows to show below the three standard channels: everything that isn't the current + * device (shown as "This device") and isn't an in-game marker (owned by the in-game toggle). + */ +export function getExtraTargets(targets: NotificationTarget[]): NotificationTarget[] { + let deviceTarget = findDeviceTarget(targets) + return targets.filter(t => { + if (isNotificationType(t.type, 'InGame')) return false + if (deviceTarget && t.id === deviceTarget.id) return false + return true + }) +} + +export function isChannelSelectionValid(selection: ChannelSelection): boolean { + let discordChecked = selection.newDiscordUrl !== null + if (discordChecked && !isValidDiscordWebhookUrl(selection.newDiscordUrl || '')) { + return false + } + return selection.inGame || selection.push || discordChecked || selection.existingTargetIds.length > 0 +} + +export function getInitialChannelSelection(targets: NotificationTarget[], prefillTargetIds?: number[]): ChannelSelection { + let deviceTarget = findDeviceTarget(targets) + + let filterExtras = (ids: number[]) => + ids.filter(id => { + let t = targets.find(t => t.id === id) + if (!t) return false // stale id, target no longer exists + if (isNotificationType(t.type, 'InGame')) return false // owned by the in-game toggle + if (deviceTarget && t.id === deviceTarget.id) return false // owned by the push toggle + return true + }) + + // edit mode: derive the selection from the notifier's attached targets + if (prefillTargetIds) { + let inGameMarker = findInGameNeverTarget(targets) + let inGame = !(inGameMarker && inGameMarker.id !== undefined && prefillTargetIds.includes(inGameMarker.id)) + let push = deviceTarget?.id !== undefined && prefillTargetIds.includes(deviceTarget.id) + return { inGame, push, newDiscordUrl: null, existingTargetIds: filterExtras(prefillTargetIds) } + } + + // create mode: start from the remembered selection + let remembered = getSettingsObject<{ inGame: boolean; push: boolean; targetIds: number[] }>(NOTIFIER_LAST_CHANNELS, { + inGame: true, + push: false, + targetIds: [] + }) + let permissionGranted = typeof Notification !== 'undefined' && Notification.permission === 'granted' + let push = !!remembered.push && !!deviceTarget && permissionGranted + return { + inGame: remembered.inGame ?? true, + push, + newDiscordUrl: null, + existingTargetIds: filterExtras(remembered.targetIds || []) + } +} + +/** Persists the selection so the next notifier starts with the same channels pre-selected. */ +export function rememberChannelSelection(selection: ChannelSelection, resolvedTargets: NotificationTarget[]) { + let token = getStoredFcmToken() + let targetIds = resolvedTargets + .filter(t => !isNotificationType(t.type, 'InGame')) // in-game state tracked separately + .filter(t => !(token && t.target === token)) // device tracked by the push flag + .map(t => t.id) + .filter((id): id is number => id !== undefined) + setSetting(NOTIFIER_LAST_CHANNELS, JSON.stringify({ inGame: selection.inGame, push: selection.push, targetIds })) +} + +/** + * Turns a ChannelSelection into the concrete NotificationTarget list to pass into api.subscribe, + * creating any targets that don't exist yet (device, Discord webhook, in-game marker). + * Throws if a required target can't be created, so no listener is created on failure. + */ +export async function resolveChannelSelection(selection: ChannelSelection, targets: NotificationTarget[]): Promise { + let resolved: NotificationTarget[] = [] + + // 1. extra existing targets the user toggled on + selection.existingTargetIds.forEach(id => { + let t = targets.find(t => t.id === id) + if (t && !isNotificationType(t.type, 'InGame')) { + resolved.push(t) + } + }) + + // 2. this device (push) + if (selection.push) { + let token = getStoredFcmToken() + if (!token) { + throw new Error('Push notifications are not set up. Please re-enable "This device".') + } + let existing = findDeviceTarget(targets) + if (existing) { + if (!resolved.some(t => t.id === existing!.id)) { + resolved.push(existing) + } + } else { + await api.setToken(token) + let created = await api.addNotificationTarget({ + id: undefined, + type: 'FIREBASE', + when: 'ALWAYS', + target: token, + name: getDeviceName(), + useCount: 0 + }) + resolved.push(created) + } + } + + // 3. new Discord webhook + if (selection.newDiscordUrl !== null && isValidDiscordWebhookUrl(selection.newDiscordUrl)) { + let url = selection.newDiscordUrl.trim() + let existing = targets.find(t => t.target === url) + if (existing) { + if (!resolved.some(t => t.id === existing!.id)) { + resolved.push(existing) + } + } else { + // prefer the label the user typed, fall back to the webhook's own name, then a generic default + let name = (selection.newDiscordName || '').trim() || (await fetchWebhookName(url)) || 'Discord webhook' + let created = await api.addNotificationTarget({ + id: undefined, + type: 'DiscordWebhook', + when: 'ALWAYS', + target: url, + name: name.slice(0, MAX_TARGET_NAME_LENGTH), + useCount: 0 + }) + resolved.push(created) + } + } + + // 4. in-game disabled marker (only when the user turned in-game off) + if (!selection.inGame) { + let marker = findInGameNeverTarget(targets) + if (!marker) { + marker = await api.addNotificationTarget({ + id: undefined, + type: 'InGame', + when: 'NEVER', + target: null, + name: 'In-game disabled', + useCount: 0 + }) + } + // attach as a disabled connection so it is never sent to, even before the backend update ships + resolved.push({ ...marker, isDisabled: true }) + } + + return resolved +} diff --git a/utils/NotificationPermisson.tsx b/utils/NotificationPermisson.tsx index 739b8d648..a8235c715 100644 --- a/utils/NotificationPermisson.tsx +++ b/utils/NotificationPermisson.tsx @@ -3,24 +3,66 @@ export default function askForNotificationPermissons(): Promise { let token = localStorage.getItem('fcmToken') if (token) { resolve(token as string) + return + } + if (typeof Notification !== 'undefined' && Notification.permission === 'denied') { + reject(new Error('Notifications are blocked in your browser. Enable them in the site settings and try again.')) + return } // Retrieve an instance of Firebase Messaging so that it can handle background // messages. + let attempts = 0 + let settled = false // @ts-ignore waitTilSet() function waitTilSet() { + if (settled) { + return + } if (!(window as any).messaging) { + // give up after ~10s so the caller isn't left hanging if messaging never initializes. + // this is usually a content/privacy blocker stopping the Firebase scripts, or the page + // being served over plain http from a non-localhost host (push needs https or localhost). + if (attempts++ > 200) { + settled = true + reject(new Error('Push setup could not finish. Reload the page, and disable content blockers for this site if it keeps failing.')) + return + } setTimeout(waitTilSet, 50) //wait 50 millisecnds then recheck return } + // getToken() can hang indefinitely (e.g. the push service worker never becomes active, or the + // browser silently drops the FCM registration), so bound it with a timeout instead of leaving + // the caller spinning forever. + let timeout = setTimeout(() => { + if (settled) { + return + } + settled = true + reject(new Error('Getting a push token timed out. Reload the page and try again.')) + }, 15000) ;(window as any).messaging .getToken({ vapidKey: 'BESZjJEHTRUVz5_8NW-jjOToWiSJFZHDzK9AYZP6No8cqGHkP7UQ_1XnEPqShuQtGj8lvtjBlkfoV86m_PadW30' }) .then((token: string) => { + if (settled) { + return + } + settled = true + clearTimeout(timeout) localStorage.setItem('fcmToken', token) resolve(token) }) + .catch((e: any) => { + if (settled) { + return + } + settled = true + clearTimeout(timeout) + console.error('FCM getToken failed', e) + reject(e instanceof Error ? e : new Error('Could not enable push notifications. Please allow notifications and try again.')) + }) } }) } diff --git a/utils/NotificationUtils.tsx b/utils/NotificationUtils.tsx index fc338f5e0..7dd28ee60 100644 --- a/utils/NotificationUtils.tsx +++ b/utils/NotificationUtils.tsx @@ -76,3 +76,36 @@ export function getNotficationWhenEnumAsString(when: NotificationWhen | number): return 'Never' } } + +// The API sends notification type/when either as string enum names or as their numeric value depending on the endpoint. +// These helpers let callers compare against a name without caring which form they got. +const NOTIFICATION_TYPE_NUMBERS: { [key in NotificationType]?: number } = { + WEBHOOK: 1, + DISCORD: 2, + DiscordWebhook: 3, + FIREBASE: 4, + EMAIL: 5, + InGame: 6 +} + +const NOTIFICATION_WHEN_NUMBERS: { [key in NotificationWhen]: number } = { + NEVER: 0, + AfterFail: 1, + ALWAYS: 2 +} + +export function isNotificationType(type: NotificationType | number | undefined, name: NotificationType): boolean { + return type === name || type === NOTIFICATION_TYPE_NUMBERS[name] +} + +export function isNotificationWhen(when: NotificationWhen | number | undefined, name: NotificationWhen): boolean { + return when === name || when === NOTIFICATION_WHEN_NUMBERS[name] +} + +/** + * Finds the target that marks in-game notifications as disabled (type InGame, when NEVER). + * Only one such marker is ever needed per user; it is shared across all notifiers. + */ +export function findInGameNeverTarget(targets: NotificationTarget[]): NotificationTarget | undefined { + return targets.find(t => isNotificationType(t.type, 'InGame') && isNotificationWhen(t.when, 'NEVER')) +} diff --git a/utils/SettingsUtils.tsx b/utils/SettingsUtils.tsx index 352d41069..839e033c4 100644 --- a/utils/SettingsUtils.tsx +++ b/utils/SettingsUtils.tsx @@ -526,3 +526,4 @@ export const AUTO_REDIRECT_FROM_LINKVERTISE_EXPLANATION = 'autoRedirectFromLinkv export const ITEM_ICON_TYPE = 'itemIconType' export const ITEM_FAVORITES_KEY = 'favoriteItems' export const GENRIC_FLIP_LIST_COLUMNS = 'genericFlipListColumns' +export const NOTIFIER_LAST_CHANNELS = 'notifierLastChannels'