diff --git a/config/default.json b/config/default.json index 61b8cf6b4..faa216988 100644 --- a/config/default.json +++ b/config/default.json @@ -64,7 +64,8 @@ "quests": false, "raids": true, "nests": false, - "stations": false + "stations": false, + "tappables": false }, "dataRequestLimits": { "categories": { @@ -76,7 +77,8 @@ "portals": 0, "routes": 0, "weather": 0, - "stations": 0 + "stations": 0, + "tappables": 0 }, "time": 60 }, @@ -89,7 +91,8 @@ "spawnpoints": 10000, "nests": 2500, "scanCells": 5000, - "stations": 5000 + "stations": 5000, + "tappables": 5000 }, "pvp": { "leagues": [ @@ -158,6 +161,7 @@ "gyms", "nests", "pokestops", + "tappables", "stations", "pokemon", "routes", @@ -266,6 +270,7 @@ "enablePokestopPopupCoordsSelector": false, "enablePortalPopupCoordsSelector": false, "enableStationPopupCoordsSelector": false, + "enableTappablePopupCoordsSelector": false, "customFloatingIcons": [], "expandAllScanAreas": false, "enableRouteDownload": false @@ -299,6 +304,10 @@ "stations": { "zoomLevel": 14, "forcedLimit": 2500 + }, + "tappables": { + "zoomLevel": 14, + "forcedLimit": 2500 } }, "messageOfTheDay": { @@ -389,6 +398,17 @@ "opacityFiveMinutes": 0.5, "opacityOneMinute": 0.25 }, + "tappables": { + "clustering": true, + "tappableTimers": false, + "interactionRanges": false, + "spacialRendRange": false, + "tappablesOpacity": true, + "enableTappablePopupCoords": false, + "opacityTenMinutes": 0.75, + "opacityFiveMinutes": 0.5, + "opacityOneMinute": 0.25 + }, "pokemon": { "clustering": true, "pokemonTimers": false, @@ -537,6 +557,10 @@ "enabled": true } }, + "tappables": { + "enabled": false, + "items": true + }, "pokemon": { "enabled": false, "easyMode": true, @@ -791,6 +815,11 @@ "trialPeriodEligible": false, "roles": [] }, + "tappables": { + "enabled": true, + "trialPeriodEligible": false, + "roles": [] + }, "lures": { "enabled": true, "trialPeriodEligible": false, @@ -1018,6 +1047,12 @@ "md": 25, "lg": 35, "xl": 45 + }, + "tappable": { + "sm": 15, + "md": 25, + "lg": 35, + "xl": 45 } } }, diff --git a/package.json b/package.json index 3f9490df4..ca2c82129 100644 --- a/package.json +++ b/package.json @@ -174,7 +174,7 @@ "source-map": "^0.7.4", "suncalc": "^1.9.0", "supercluster": "^8.0.1", - "uicons.js": "2.0.3", + "uicons.js": "2.1.0", "zustand": "4.4.6" }, "devDependencies": { diff --git a/packages/locales/lib/human/en.json b/packages/locales/lib/human/en.json index 16feaeaff..8b6344cba 100644 --- a/packages/locales/lib/human/en.json +++ b/packages/locales/lib/human/en.json @@ -805,11 +805,17 @@ "exclude_battle": "Exclude Max Battle", "station": "Power Spot", "stations": "Power Spots", + "tappables": "Tappables", + "tappable_type_breakfast": "Zygarde Cell", + "tappable_type_hat": "Party Hat", + "tappable_type_maple": "Apple", + "tappable_type_pokeball": "Poké Ball", "stations_filters": "Power Spots Filter Settings", "stations_options": "Power Spot Options", "all_stations": "All Power Spots", "gmax_stationed": "Gigantamax Placed", "search_battles": "Search Max Battles", + "search_tappables": "Search Tappables", "started": "Started", "ended": "Ended", "search_stations": "Search Power Spots", diff --git a/packages/types/lib/scanner.d.ts b/packages/types/lib/scanner.d.ts index 02bb5f5c6..2dc0954b0 100644 --- a/packages/types/lib/scanner.d.ts +++ b/packages/types/lib/scanner.d.ts @@ -10,6 +10,7 @@ import SpawnpointModel = require('server/src/models/Spawnpoint') import WeatherModel = require('server/src/models/Weather') import RouteModel = require('server/src/models/Route') import StationModel = require('server/src/models/Station') +import TappableModel = require('server/src/models/Tappable') import { S2Polygon } from './general' @@ -276,6 +277,20 @@ export interface Portal { export type FullPortal = FullModel +export interface Tappable { + id: string + lat: number + lon: number + type: string + item_id: number + count: number | null + expire_timestamp: number | null + expire_timestamp_verified: boolean + updated: number +} + +export type FullTappable = FullModel + export interface ScanCell { id?: string level?: number diff --git a/packages/types/lib/server.d.ts b/packages/types/lib/server.d.ts index b4174b684..ea586fe20 100644 --- a/packages/types/lib/server.d.ts +++ b/packages/types/lib/server.d.ts @@ -5,6 +5,7 @@ import type { RmModelKeys, ModelKeys, Station, + Tappable, Backup, Nest, NestSubmission, @@ -71,6 +72,7 @@ export interface Available { pokestops: ModelReturn nests: ModelReturn stations: ModelReturn + tappables: ModelReturn } export interface ApiEndpoint { @@ -205,6 +207,7 @@ export type AdvCategories = | 'pokestops' | 'nests' | 'stations' + | 'tappables' export type UIObject = ReturnType< (typeof import('server/src/ui/drawer'))['drawer'] diff --git a/server/src/filters/builder/base.js b/server/src/filters/builder/base.js index ab8b7fc85..e5c865a8b 100644 --- a/server/src/filters/builder/base.js +++ b/server/src/filters/builder/base.js @@ -5,6 +5,7 @@ const { state } = require('../../services/state') const { buildPokemon } = require('./pokemon') const { buildPokestops } = require('./pokestop') const { buildGyms } = require('./gym') +const { buildTappables } = require('./tappable') const { BaseFilter } = require('../Base') const { PokemonFilter } = require('../pokemon/Frontend') @@ -111,6 +112,14 @@ function buildDefaultFilters(perms) { }, } : undefined, + tappables: + perms.tappables && state.db.models.Tappable + ? { + enabled: defaultFilters.tappables.enabled, + standard: new BaseFilter(), + filter: buildTappables(perms, defaultFilters.tappables), + } + : undefined, stations: stationReducer && state.db.models.Station ? { diff --git a/server/src/filters/builder/tappable.js b/server/src/filters/builder/tappable.js new file mode 100644 index 000000000..be7780d90 --- /dev/null +++ b/server/src/filters/builder/tappable.js @@ -0,0 +1,29 @@ +// @ts-check +const { state } = require('../../services/state') +const { BaseFilter } = require('../Base') + +/** + * @param {import('@rm/types').Permissions} perms + * @param {import('@rm/types').Config['defaultFilters']['tappables']} defaults + * @returns {Record} + */ +function buildTappables(perms, defaults) { + const filters = { q0: new BaseFilter() } + if (!perms.tappables) { + return filters + } + + Object.keys(state.event.masterfile.items).forEach((itemId) => { + filters[`q${itemId}`] = new BaseFilter(defaults.items) + }) + + state.event.getAvailable('tappables').forEach((key) => { + if (!filters[key]) { + filters[key] = new BaseFilter(defaults.items) + } + }) + + return filters +} + +module.exports = { buildTappables } diff --git a/server/src/graphql/resolvers.js b/server/src/graphql/resolvers.js index a0b4705bf..afc4982a9 100644 --- a/server/src/graphql/resolvers.js +++ b/server/src/graphql/resolvers.js @@ -92,6 +92,8 @@ const resolvers = { }), availableStations: (_, _args, { Event, perms }) => perms?.dynamax ? Event.available.stations : [], + availableTappables: (_, _args, { Event, perms }) => + perms?.tappables ? Event.available.tappables : [], backup: (_, args, { req, perms, Db }) => { if (perms?.backups && req?.user?.id) { return Db.models.Backup.getOne(args.id, req?.user?.id) @@ -526,6 +528,12 @@ const resolvers = { } return [] }, + tappables: (_, args, { perms, Db }) => { + if (perms?.tappables) { + return Db.query('Tappable', 'getAll', perms, args) + } + return [] + }, submissionCells: async (_, args, { req, perms, Db }) => { const { submissionZoom } = config.getMapConfig(req).general if (perms?.submissionCells && args.zoom >= submissionZoom - 1) { diff --git a/server/src/graphql/typeDefs/index.graphql b/server/src/graphql/typeDefs/index.graphql index e2bd1aaa2..8dc658947 100644 --- a/server/src/graphql/typeDefs/index.graphql +++ b/server/src/graphql/typeDefs/index.graphql @@ -7,6 +7,7 @@ type Query { availableGyms: [String] availableNests: [String] availableStations: [String] + availableTappables: [String] badges: [Badge] backup(id: ID): Backup backups: [Backup] @@ -67,6 +68,13 @@ type Query { filters: JSON ): [Station] stationPokemon(id: ID): [StationPokemon] + tappables( + minLat: Float + maxLat: Float + minLon: Float + maxLon: Float + filters: JSON + ): [Tappable] s2cells( minLat: Float maxLat: Float diff --git a/server/src/graphql/typeDefs/scanner.graphql b/server/src/graphql/typeDefs/scanner.graphql index a76d26677..92d096125 100644 --- a/server/src/graphql/typeDefs/scanner.graphql +++ b/server/src/graphql/typeDefs/scanner.graphql @@ -203,6 +203,18 @@ type Portal { updated: Int } +type Tappable { + id: ID + lat: Float + lon: Float + type: String + item_id: Int + count: Int + expire_timestamp: Int + expire_timestamp_verified: Boolean + updated: Int +} + type ScanCell { id: ID level: Int diff --git a/server/src/models/Tappable.js b/server/src/models/Tappable.js new file mode 100644 index 000000000..5d4cb5e85 --- /dev/null +++ b/server/src/models/Tappable.js @@ -0,0 +1,119 @@ +// @ts-check +const { Model } = require('objection') +const config = require('@rm/config') + +const { getAreaSql } = require('../utils/getAreaSql') +const { applyManualIdFilter } = require('../utils/manualFilter') +const { getEpoch } = require('../utils/getClientTime') + +class Tappable extends Model { + static get tableName() { + return 'tappable' + } + + /** + * @param {import('@rm/types').Permissions} perms + * @param {{ + * filters: Record, + * minLat: number, + * maxLat: number, + * minLon: number, + * maxLon: number, + * }} args + * @param {import('@rm/types').DbContext} ctx + * @returns {Promise} + */ + static async getAll(perms, args, ctx) { + if (!perms?.tappables) return [] + + const { filters: filterArgs = {}, minLat, maxLat, minLon, maxLon } = args + + const { queryLimits = {} } = config.getSafe('api') + const timestamp = getEpoch() + + const query = this.query().select([ + 'id', + 'lat', + 'lon', + 'type', + 'item_id', + 'count', + 'expire_timestamp', + 'expire_timestamp_verified', + 'updated', + ]) + + applyManualIdFilter(query, { + manualId: filterArgs.onlyManualId, + latColumn: 'lat', + lonColumn: 'lon', + idColumn: 'id', + bounds: { minLat, maxLat, minLon, maxLon }, + }) + + const onlyAreas = filterArgs.onlyAreas || [] + if (!getAreaSql(query, perms.areaRestrictions, onlyAreas, ctx?.isMad)) { + return [] + } + + query.whereNull('pokemon_id').whereNotNull('item_id') + + query.andWhere('expire_timestamp', '>', timestamp) + + const itemIds = [] + Object.keys(filterArgs).forEach((key) => { + if (!key || key.startsWith('only')) return + switch (key.charAt(0)) { + case 'q': { + const itemId = Number.parseInt(key.slice(1), 10) + if (!Number.isNaN(itemId) && itemId !== 0) { + itemIds.push(itemId) + } + break + } + default: + break + } + }) + + if (itemIds.length) { + query.whereIn('item_id', itemIds) + } + + query.orderBy('updated', 'desc') + + const limit = queryLimits.tappables || queryLimits.pokestops || 5000 + const results = await query.limit(limit) + + return results.map((row) => ({ + ...row, + expire_timestamp_verified: !!row.expire_timestamp_verified, + })) + } + + /** + * Returns filter keys available for tappables + * @returns {Promise<{ available: string[] }>} + */ + static async getAvailable() { + const rows = await this.query() + .select('item_id') + .count('id as total') + .whereNull('pokemon_id') + .whereNotNull('item_id') + .groupBy('item_id') + + const available = Array.from( + new Set( + rows + .map((row) => row.item_id) + .filter((itemId) => itemId !== null) + .map((itemId) => `q${itemId}`), + ), + ) + + return { available } + } +} + +module.exports = { Tappable } diff --git a/server/src/models/index.js b/server/src/models/index.js index 3dd967fae..4e0da69f9 100644 --- a/server/src/models/index.js +++ b/server/src/models/index.js @@ -14,6 +14,7 @@ const { ScanCell } = require('./ScanCell') const { Session } = require('./Session') const { Spawnpoint } = require('./Spawnpoint') const { Station } = require('./Station') +const { Tappable } = require('./Tappable') const { User } = require('./User') const { Weather } = require('./Weather') @@ -36,6 +37,7 @@ const scannerModels = { ScanCell, Spawnpoint, Station, + Tappable, Weather, } diff --git a/server/src/routes/api/v1/available.js b/server/src/routes/api/v1/available.js index e79d1dd89..c0cb7298f 100644 --- a/server/src/routes/api/v1/available.js +++ b/server/src/routes/api/v1/available.js @@ -9,6 +9,7 @@ const queryObj = /** @type {const} */ ({ quests: { model: 'Pokestop', category: 'pokestops' }, raids: { model: 'Gym', category: 'gyms' }, nests: { model: 'Nest', category: 'nests' }, + tappables: { model: 'Tappable', category: 'tappables' }, }) /** @param {string} category */ @@ -27,6 +28,9 @@ const resolveCategory = (category) => { case 'pokemon': case 'pokemons': return 'pokemon' + case 'tappable': + case 'tappables': + return 'tappables' default: return 'all' } @@ -40,12 +44,14 @@ const getAll = async (compare) => { state.db.getAvailable('Pokestop'), state.db.getAvailable('Gym'), state.db.getAvailable('Nest'), + state.db.getAvailable('Tappable'), ]) : [ state.event.available.pokemon, state.event.available.pokestops, state.event.available.gyms, state.event.available.nests, + state.event.available.tappables, ] return Object.fromEntries( Object.keys(queryObj).map((key, i) => [key, available[i]]), @@ -118,6 +124,7 @@ router.put('/:category', async (req, res) => { state.event.setAvailable('gyms', 'Gym', state.db), state.event.setAvailable('nests', 'Nest', state.db), state.event.setAvailable('stations', 'Station', state.db), + state.event.setAvailable('tappables', 'Tappable', state.db), ]) } res diff --git a/server/src/routes/rootRouter.js b/server/src/routes/rootRouter.js index 4148f8bfa..78a27b1b4 100644 --- a/server/src/routes/rootRouter.js +++ b/server/src/routes/rootRouter.js @@ -215,6 +215,9 @@ rootRouter.get('/api/settings', async (req, res, next) => { if (settings.user.perms.stations && api.queryOnSessionInit.stations) { state.event.setAvailable('stations', 'Station', state.db) } + if (settings.user.perms.tappables && api.queryOnSessionInit.tappables) { + state.event.setAvailable('tappables', 'Tappable', state.db) + } } res.status(200).json(settings) diff --git a/server/src/services/DbManager.js b/server/src/services/DbManager.js index c4f97b418..9d49ea22c 100644 --- a/server/src/services/DbManager.js +++ b/server/src/services/DbManager.js @@ -24,6 +24,7 @@ class DbManager extends Logger { 'ScanCell', 'Spawnpoint', 'Station', + 'Tappable', 'Weather', ]) diff --git a/server/src/services/EventManager.js b/server/src/services/EventManager.js index 0bd7f4ae4..1eb5091bb 100644 --- a/server/src/services/EventManager.js +++ b/server/src/services/EventManager.js @@ -30,6 +30,7 @@ class EventManager extends Logger { pokemon: [], nests: [], stations: [], + tappables: [], }) this.uicons = getCache('uicons.json', []) this.uaudio = getCache('uaudio.json', []) diff --git a/server/src/services/state.js b/server/src/services/state.js index 89b79fd27..a5205e9a6 100644 --- a/server/src/services/state.js +++ b/server/src/services/state.js @@ -107,6 +107,7 @@ const state = { this.event.setAvailable('pokemon', 'Pokemon', this.db), this.event.setAvailable('nests', 'Nest', this.db), this.event.setAvailable('stations', 'Station', this.db), + this.event.setAvailable('tappables', 'Tappable', this.db), ) } await Promise.all(promises) diff --git a/server/src/ui/advMenus.js b/server/src/ui/advMenus.js index 908bb273f..4b2504353 100644 --- a/server/src/ui/advMenus.js +++ b/server/src/ui/advMenus.js @@ -20,6 +20,7 @@ const CATEGORIES = /** @type {const} */ ({ stations: ['pokemon'], pokemon: ['pokemon'], nests: ['pokemon'], + tappables: ['tappables'], }) /** @@ -110,6 +111,20 @@ function advMenus(perms) { } : {}, }, + tappables: { + categories: CATEGORIES.tappables, + filters: { + categories: Object.fromEntries( + CATEGORIES.tappables.map((item) => [item, false]), + ), + others: { + reverse: false, + selected: false, + unselected: false, + onlyAvailable: true, + }, + }, + }, pokemon: { categories: CATEGORIES.pokemon, filters: { diff --git a/server/src/ui/clientOptions.js b/server/src/ui/clientOptions.js index 638c62ed1..cf866c5a3 100644 --- a/server/src/ui/clientOptions.js +++ b/server/src/ui/clientOptions.js @@ -171,6 +171,51 @@ function clientOptions(perms) { ? { type: 'bool', perm: ['stations', 'dynamax'], category: 'popups' } : undefined, }, + tappables: { + clustering: { + type: 'bool', + perm: ['tappables'], + category: 'markers', + }, + tappableTimers: { + type: 'bool', + perm: ['tappables'], + category: 'tooltips', + }, + interactionRanges: { + type: 'bool', + perm: ['tappables'], + category: 'markers', + }, + spacialRendRange: { + type: 'bool', + perm: ['tappables'], + category: 'markers', + }, + tappablesOpacity: { + type: 'bool', + perm: ['tappables'], + category: 'dynamic_opacity', + }, + opacityTenMinutes: { + type: 'number', + perm: ['tappables'], + category: 'dynamic_opacity', + }, + opacityFiveMinutes: { + type: 'number', + perm: ['tappables'], + category: 'dynamic_opacity', + }, + opacityOneMinute: { + type: 'number', + perm: ['tappables'], + category: 'dynamic_opacity', + }, + enableTappablePopupCoords: map.misc.enableTappablePopupCoordsSelector + ? { type: 'bool', perm: ['tappables'], category: 'popups' } + : undefined, + }, pokemon: { clustering: { type: 'bool', perm: ['pokemon'], category: 'markers' }, linkGlobalAndAdvanced: { diff --git a/server/src/ui/drawer.js b/server/src/ui/drawer.js index 93c1b22b9..3af7527df 100644 --- a/server/src/ui/drawer.js +++ b/server/src/ui/drawer.js @@ -63,6 +63,12 @@ function drawer(req, perms) { arEligible: perms.pokestops || BLOCKED, } : BLOCKED, + tappables: + perms.tappables && state.db.models.Tappable + ? { + enabled: perms.tappables || BLOCKED, + } + : BLOCKED, stations: (perms.stations || perms.dynamax) && state.db.models.Station ? { diff --git a/src/assets/css/main.css b/src/assets/css/main.css index 15f8599e7..c87849231 100644 --- a/src/assets/css/main.css +++ b/src/assets/css/main.css @@ -347,6 +347,66 @@ img { filter: invert(75%) sepia(100%) hue-rotate(151deg) saturate(3.1); } +.tappable-marker { + position: relative; + width: var(--tappable-size, 32px); + height: var(--tappable-size, 32px); + display: flex; + align-items: center; + justify-content: center; +} + +.tappable-marker__icon { + width: 100%; + height: 100%; + object-fit: contain; +} + +.tappable-marker--reward-primary { + position: relative; + width: var(--tappable-size, 32px); + height: var(--tappable-size, 32px); + display: flex; + align-items: center; + justify-content: center; +} + +.tappable-marker__reward-primary { + width: 100%; + height: 100%; + object-fit: contain; +} + +.tappable-marker__count { + position: absolute; + right: -4px; + bottom: -6px; + min-width: 22px; + padding: 0 4px; + border-radius: 10px; + background: rgba(32, 32, 32, 0.8); + color: #fff; + font-size: 12px; + font-weight: 600; + line-height: 1.4; + text-align: center; + pointer-events: none; +} + +.tappable-marker__bubble { + position: absolute; + bottom: calc(100% + 6px); + left: 50%; + transform: translateX(-50%); + z-index: 1; +} + +.tappable-marker__bubble-svg { + display: block; + overflow: visible; + filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.3)); +} + .quest-popup-img { max-width: 30px; max-height: 30px; diff --git a/src/components/filters/FilterMenu.jsx b/src/components/filters/FilterMenu.jsx index 2557fb036..6d204a730 100644 --- a/src/components/filters/FilterMenu.jsx +++ b/src/components/filters/FilterMenu.jsx @@ -2,6 +2,7 @@ import * as React from 'react' import { toggleDialog, useLayoutStore } from '@store/useLayoutStore' +import { useMemory } from '@store/useMemory' import { StandardItem } from '@components/virtual/StandardItem' import { Menu } from '@components/Menu' import { Header } from '@components/dialogs/Header' @@ -19,13 +20,13 @@ const EXTRA_BUTTONS = [ export function FilterMenu() { const { open, category, type } = useLayoutStore((s) => s.dialog) + const menuConfig = useMemory((s) => (category ? s.menus?.[category] : null)) - return (category === 'pokemon' || - category === 'gyms' || - category === 'pokestops' || - category === 'nests' || - category === 'stations') && - type === 'filters' ? ( + if (!category || !menuConfig || type !== 'filters') { + return null + } + + return ( } - ) : null + ) } diff --git a/src/components/popups/ExtraInfo.jsx b/src/components/popups/ExtraInfo.jsx index a24a3bfd9..35dc7226b 100644 --- a/src/components/popups/ExtraInfo.jsx +++ b/src/components/popups/ExtraInfo.jsx @@ -6,14 +6,14 @@ import { useTranslation } from 'react-i18next' /** * - * @param {{ title?: string, data?: React.ReactNode, children?: React.ReactNode }} props + * @param {{ title?: string, data?: React.ReactNode, children?: React.ReactNode, xs?: number }} props * @returns */ -export const ExtraInfo = ({ title, data, children }) => { +export const ExtraInfo = ({ title, data, children, xs = 6 }) => { const { t } = useTranslation() return ( - + {title && ( {t(title)}: diff --git a/src/components/popups/TimeStamps.jsx b/src/components/popups/TimeStamps.jsx index 5550d26d2..28db7847b 100644 --- a/src/components/popups/TimeStamps.jsx +++ b/src/components/popups/TimeStamps.jsx @@ -8,10 +8,10 @@ import { ExtraInfo } from './ExtraInfo' /** * - * @param {{ time?: number, children: string }} props + * @param {{ time?: number, children: string, xs?: number }} props * @returns */ -export const TimeStamp = ({ time, children }) => { +export const TimeStamp = ({ time, children, xs = 6 }) => { const { i18n } = useTranslation() if (!time) return null @@ -23,7 +23,7 @@ export const TimeStamp = ({ time, children }) => { }) return ( - + diff --git a/src/features/drawer/Extras.jsx b/src/features/drawer/Extras.jsx index 9ada71fcc..d427e17cc 100644 --- a/src/features/drawer/Extras.jsx +++ b/src/features/drawer/Extras.jsx @@ -9,6 +9,7 @@ import { WayfarerDrawer } from './Wayfarer' import { S2CellsDrawer } from './S2Cells' import { AdminDrawer } from './Admin' import { StationsDrawer } from './Stations' +import { TappablesDrawer } from './Tappables' function ExtrasComponent({ category, subItem }) { switch (category) { @@ -28,6 +29,8 @@ function ExtrasComponent({ category, subItem }) { return case 'stations': return subItem === 'maxBattles' && + case 'tappables': + return default: return null } diff --git a/src/features/drawer/Tappables.jsx b/src/features/drawer/Tappables.jsx new file mode 100644 index 000000000..0625db5a4 --- /dev/null +++ b/src/features/drawer/Tappables.jsx @@ -0,0 +1,19 @@ +// @ts-check +import * as React from 'react' + +import { useStorage } from '@store/useStorage' + +import { CollapsibleItem } from './components/CollapsibleItem' +import { SelectorListMemo } from './components/SelectorList' + +const BaseTappablesDrawer = () => { + const enabled = useStorage((s) => !!s.filters?.tappables?.enabled) + + return ( + + + + ) +} + +export const TappablesDrawer = React.memo(BaseTappablesDrawer) diff --git a/src/features/drawer/components/Section.jsx b/src/features/drawer/components/Section.jsx index e78274d48..749cd236e 100644 --- a/src/features/drawer/components/Section.jsx +++ b/src/features/drawer/components/Section.jsx @@ -28,6 +28,7 @@ const ADV_CATEGORIES = new Set([ 'pokestops', 'nests', 'stations', + 'tappables', ]) /** @param {{ category: keyof import('@rm/types').UIObject }} props */ diff --git a/src/features/drawer/components/SelectorList.jsx b/src/features/drawer/components/SelectorList.jsx index 2bf6addb6..1787ab510 100644 --- a/src/features/drawer/components/SelectorList.jsx +++ b/src/features/drawer/components/SelectorList.jsx @@ -56,6 +56,7 @@ function SelectorList({ category, subCategory, label, height = 400 }) { ) const easyMode = useStorage((s) => !!s.filters[category]?.easyMode) const search = useStorage((s) => s.searches[searchKey] || '') + const disableGutters = !['pokemon', 'tappables'].includes(category) const translated = React.useMemo( () => @@ -92,6 +93,8 @@ function SelectorList({ category, subCategory, label, height = 400 }) { switch (category) { case 'gyms': return key.startsWith('t') + case 'tappables': + return key.startsWith('q') && key !== 'q0' default: return Number.isInteger(Number(key.charAt(0))) } @@ -133,7 +136,7 @@ function SelectorList({ category, subCategory, label, height = 400 }) { return ( {translated.length > 10 && ( - + )} @@ -145,7 +148,7 @@ function SelectorList({ category, subCategory, label, height = 400 }) { /> )} {!!items.length && ( - + {t(search ? 'set_filtered' : 'set_all')} setAll('enable')}> diff --git a/src/features/tappable/TappablePopup.jsx b/src/features/tappable/TappablePopup.jsx new file mode 100644 index 000000000..439c635c2 --- /dev/null +++ b/src/features/tappable/TappablePopup.jsx @@ -0,0 +1,372 @@ +// @ts-check +import * as React from 'react' +import ExpandMore from '@mui/icons-material/ExpandMore' +import MoreVert from '@mui/icons-material/MoreVert' +import Grid from '@mui/material/Unstable_Grid2' +import Typography from '@mui/material/Typography' +import Tooltip from '@mui/material/Tooltip' +import IconButton from '@mui/material/IconButton' +import Collapse from '@mui/material/Collapse' +import Menu from '@mui/material/Menu' +import MenuItem from '@mui/material/MenuItem' +import { useTranslation } from 'react-i18next' + +import { useStorage, setDeepStore } from '@store/useStorage' +import { useMemory } from '@store/useMemory' +import { Navigation } from '@components/popups/Navigation' +import { Coords } from '@components/popups/Coords' +import { TimeStamp } from '@components/popups/TimeStamps' +import { StatusIcon } from '@components/StatusIcon' +import { Title } from '@components/popups/Title' + +import { getTimeUntil } from '@utils/getTimeUntil' +import { getTappableDisplaySettings } from './displayRules' + +/** + * @param {{ + * tappable: import('@rm/types').Tappable, + * rewardIcon: string, + * }} props + */ +export function TappablePopup({ tappable, rewardIcon }) { + const { t, i18n } = useTranslation() + const showCoords = useStorage( + (s) => !!s.userSettings.tappables?.enableTappablePopupCoords, + ) + const popups = useStorage((s) => s.popups) + const filterKey = tappable.item_id ? `q${tappable.item_id}` : '' + const filterEnabled = useStorage((s) => + filterKey ? s.filters?.tappables?.filter?.[filterKey]?.enabled : undefined, + ) + const masterfile = useMemory((s) => s.masterfile) + const Icons = useMemory((s) => s.Icons) + const displaySettings = getTappableDisplaySettings(tappable) + + const count = tappable.count ?? 1 + const itemName = React.useMemo(() => { + if (i18n.exists(`item_${tappable.item_id}`)) { + return t(`item_${tappable.item_id}`) + } + return masterfile.items?.[tappable.item_id]?.name || `#${tappable.item_id}` + }, [t, i18n, masterfile.items, tappable.item_id]) + + const formattedType = React.useMemo(() => { + if (!tappable.type) return '' + const cleaned = tappable.type + .replace('TAPPABLE_TYPE_', '') + .replace(/_/g, ' ') + .toLowerCase() + const translationKey = `tappable_type_${cleaned.replace(/\s+/g, '_')}` + if (i18n.exists(translationKey)) { + return t(translationKey) + } + return cleaned + .split(' ') + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(' ') + }, [tappable.type, t, i18n]) + + const tappableIcon = React.useMemo(() => { + if (!Icons || typeof Icons.getTappable !== 'function') { + return '' + } + return Icons.getTappable(tappable.type) || '' + }, [Icons, tappable.type]) + + const hasExpireTime = !!tappable.expire_timestamp + const hasExtras = showCoords || tappable.updated + const hasRewardIcon = !!rewardIcon + const useRewardAsPrimary = + displaySettings.popup.rewardAsPrimary && hasRewardIcon + const hasTappableIcon = !!tappableIcon && !useRewardAsPrimary + const itemDisplayName = count > 1 ? `${itemName} x${count}` : itemName + + const [menuAnchorEl, setMenuAnchorEl] = React.useState(null) + + const handleExtrasToggle = React.useCallback(() => { + useStorage.setState((prev) => ({ + popups: { + ...prev.popups, + extras: !popups.extras, + }, + })) + }, [popups.extras]) + + const handleMenuOpen = React.useCallback((event) => { + setMenuAnchorEl(event.currentTarget) + }, []) + + const handleMenuClose = React.useCallback(() => { + setMenuAnchorEl(null) + }, []) + + const handleHide = React.useCallback(() => { + setMenuAnchorEl(null) + if (tappable.id === undefined || tappable.id === null) return + useMemory.setState((prev) => ({ + hideList: new Set(prev.hideList).add(tappable.id), + })) + }, [tappable.id]) + + const handleExclude = React.useCallback(() => { + setMenuAnchorEl(null) + if (!filterKey) return + setDeepStore(`filters.tappables.filter.${filterKey}.enabled`, false) + }, [filterKey]) + + const handleTimer = React.useCallback(() => { + setMenuAnchorEl(null) + if (tappable.id === undefined || tappable.id === null) return + useMemory.setState((prev) => { + if (prev.timerList.includes(tappable.id)) { + return { + timerList: prev.timerList.filter((entry) => entry !== tappable.id), + } + } + return { timerList: [...prev.timerList, tappable.id] } + }) + }, [tappable.id]) + + const menuOptions = React.useMemo(() => { + const options = [ + { key: 'timer', label: 'timer', action: handleTimer }, + { key: 'hide', label: 'hide', action: handleHide }, + ] + if (filterKey && filterEnabled) { + options.push({ key: 'exclude', label: 'exclude', action: handleExclude }) + } + return options + }, [handleTimer, handleHide, handleExclude, filterKey, filterEnabled]) + + return ( + + + {useRewardAsPrimary ? ( + <> + {hasRewardIcon ? ( + + {itemName} + + ) : null} + + {itemDisplayName} + + + ) : ( + <> + {hasTappableIcon ? ( + + {formattedType} + + ) : null} + + {formattedType} + + + )} + + + + + + + + {menuOptions.map((option) => ( + + {t(option.label)} + + ))} + + {!useRewardAsPrimary && ( + + {hasRewardIcon ? ( + + {itemName} + + ) : null} + + {itemDisplayName} + + + )} + {hasExpireTime && ( + + + + )} + + + + {hasExtras && ( + + + + + + )} + {hasExtras && ( + + + {tappable.updated && ( + + last_updated + + )} + {showCoords && ( + + + + )} + + + )} + + ) +} + +/** + * @param {{ + * expireTimestamp: number, + * verified: boolean, + * locale: string, + * t: import('i18next').TFunction + * }} props + */ +const TappableTimer = ({ expireTimestamp, verified, locale, t }) => { + const expireTimeMs = React.useMemo( + () => expireTimestamp * 1000, + [expireTimestamp], + ) + const [timer, setTimer] = React.useState(() => + getTimeUntil(expireTimeMs, true), + ) + + React.useEffect(() => { + setTimer(getTimeUntil(expireTimeMs, true)) + const interval = setInterval(() => { + setTimer(getTimeUntil(expireTimeMs, true)) + }, 1000) + return () => clearInterval(interval) + }, [expireTimeMs]) + + return ( + <> + + + {timer.str} + + + {new Date(expireTimeMs).toLocaleTimeString(locale || 'en', { + hour: 'numeric', + minute: 'numeric', + second: 'numeric', + })} + + + + + + + + + ) +} diff --git a/src/features/tappable/TappableTile.jsx b/src/features/tappable/TappableTile.jsx new file mode 100644 index 000000000..d51d5dcc8 --- /dev/null +++ b/src/features/tappable/TappableTile.jsx @@ -0,0 +1,276 @@ +/* eslint-disable react/destructuring-assignment */ +// @ts-check +import * as React from 'react' +import { Marker, Popup, Circle } from 'react-leaflet' +import { divIcon } from 'leaflet' +import { useTheme, alpha } from '@mui/material/styles' + +import { useMemory, basicEqualFn } from '@store/useMemory' +import { useStorage } from '@store/useStorage' +import { useManualPopupTracker } from '@hooks/useManualPopupTracker' +import { useForcePopup } from '@hooks/useForcePopup' +import { useMarkerTimer } from '@hooks/useMarkerTimer' +import { useOpacity } from '@hooks/useOpacity' +import { TooltipWrapper } from '@components/ToolTipWrapper' + +import { TappablePopup } from './TappablePopup' +import { getTappableDisplaySettings } from './displayRules' + +/** + * @param {import('@rm/types').Tappable} tappable + */ +const BaseTappableTile = (tappable) => { + const Icons = useMemory((s) => s.Icons) + const itemFilters = useStorage((s) => s.filters?.tappables?.filter || {}) + const [timerForced, interactionRangeZoom] = useMemory((s) => { + const { + timerList, + config: { general = {} }, + } = s + const zoomLimit = Number.isFinite(general.interactionRangeZoom) + ? general.interactionRangeZoom + : 15 + return [ + tappable.id == null ? false : timerList.includes(tappable.id), + zoomLimit, + ] + }, basicEqualFn) + const [showTimerSetting, showInteractionRange, showSpacialRendRange] = + useStorage((s) => { + const { userSettings, zoom } = s + return [ + !!userSettings.tappables?.tappableTimers, + !!userSettings.tappables?.interactionRanges && + zoom >= interactionRangeZoom, + !!userSettings.tappables?.spacialRendRange && + zoom >= interactionRangeZoom, + ] + }, basicEqualFn) + + const [markerRef, setMarkerRef] = React.useState(null) + useForcePopup(tappable.id, markerRef) + useMarkerTimer(tappable.expire_timestamp || 0, markerRef) + const handlePopupOpen = useManualPopupTracker('tappables', tappable.id) + + const getOpacity = useOpacity('tappables') + const opacity = React.useMemo( + () => + tappable.expire_timestamp ? getOpacity(tappable.expire_timestamp) : 1, + [getOpacity, tappable.expire_timestamp], + ) + const theme = useTheme() + const bubbleFill = alpha(theme.palette.background.paper, 0.5) + const bubbleTextColor = theme.palette.text.primary + const displaySettings = getTappableDisplaySettings(tappable) + const useRewardPrimary = displaySettings.map.rewardAsPrimary + + const { icon, rewardIcon } = React.useMemo(() => { + if (!Icons || !tappable.item_id) { + return { icon: null, rewardIcon: '', size: 24 } + } + const filterKey = `q${tappable.item_id}` + const tappableSize = Icons.getSize('tappable', itemFilters[filterKey]?.size) + const tappableIcon = Icons.getTappable(tappable.type) + const [tappableMod, rewardMod] = Icons.getModifiers('tappable', 'reward') + const popupAnchor = [ + tappableMod?.popupX || 0, + tappableSize * -0.7 * (tappableMod?.offsetY || 1) - + tappableSize / 2 + + (tappableMod?.popupY || 0), + ] + const tappableReward = Icons.getRewards( + 2, + tappable.item_id, + tappable.count || 1, + ) + const count = tappable.count || 1 + if (useRewardPrimary && tappableReward) { + const countBadge = + count > 1 ? `x${count}` : '' + const html = ` +
+ ${tappable.type || ''} + ${countBadge} +
+ ` + return { + rewardIcon: tappableReward, + icon: divIcon({ + className: 'tappable-marker-icon', + iconAnchor: [tappableSize / 2, tappableSize], + popupAnchor, + html, + }), + } + } + if (!tappableIcon) { + return { icon: null, rewardIcon: '', size: tappableSize } + } + const defaultRewardSize = Icons.getSize('reward') + const hasCount = count > 1 + const rewardSize = hasCount ? defaultRewardSize : 25 + const textHeight = hasCount ? 14 : 0 + const paddingX = hasCount ? 10 : (30 - rewardSize) / 2 + const paddingTop = hasCount ? 8 : 4 + const paddingBottom = hasCount ? 8 : 4 + const bubbleWidth = hasCount ? rewardSize + paddingX * 2 : 30 + const bubbleHeight = paddingTop + rewardSize + textHeight + paddingBottom + const tailHeight = hasCount ? 16 : 12 + const tailWidth = Math.min(hasCount ? 24 : 16, bubbleWidth * 0.45) + const svgHeight = bubbleHeight + tailHeight + const cornerRadius = hasCount ? 12 : 9 + const bubbleCenterX = bubbleWidth / 2 + const rewardX = paddingX + const rewardY = paddingTop + const countY = paddingTop + rewardSize + textHeight / 2 + const bubbleOffset = hasCount ? 6 : 4 + const bubblePath = [ + `M${cornerRadius} 0`, + `H${bubbleWidth - cornerRadius}`, + `A${cornerRadius} ${cornerRadius} 0 0 1 ${bubbleWidth} ${cornerRadius}`, + `V${bubbleHeight - cornerRadius}`, + `A${cornerRadius} ${cornerRadius} 0 0 1 ${ + bubbleWidth - cornerRadius + } ${bubbleHeight}`, + `H${bubbleCenterX + tailWidth / 2}`, + `L${bubbleCenterX} ${bubbleHeight + tailHeight}`, + `L${bubbleCenterX - tailWidth / 2} ${bubbleHeight}`, + `H${cornerRadius}`, + `A${cornerRadius} ${cornerRadius} 0 0 1 0 ${bubbleHeight - cornerRadius}`, + `V${cornerRadius}`, + `A${cornerRadius} ${cornerRadius} 0 0 1 ${cornerRadius} 0`, + 'Z', + ].join(' ') + const countText = + count > 1 + ? `x${count}` + : '' + const bubbleSvg = ` + + ` + + const html = ` +
+ ${ + tappableReward + ? ` +
+ ${bubbleSvg} +
` + : '' + } + ${tappable.type || ''} +
+ ` + + return { + rewardIcon: tappableReward, + icon: divIcon({ + className: 'tappable-marker-icon', + iconAnchor: [tappableSize / 2, tappableSize], + popupAnchor, + html, + }), + } + }, [ + Icons, + itemFilters, + tappable.type, + tappable.item_id, + tappable.count, + opacity, + bubbleFill, + bubbleTextColor, + useRewardPrimary, + ]) + + if (!Icons || !icon) { + return null + } + + const timers = React.useMemo( + () => (tappable.expire_timestamp ? [tappable.expire_timestamp] : []), + [tappable.expire_timestamp], + ) + + return ( + + + + + {(showTimerSetting || timerForced) && !!timers.length && ( + + )} + {showInteractionRange && ( + + )} + {showSpacialRendRange && ( + + )} + + ) +} + +export const TappableTile = React.memo( + BaseTappableTile, + (prev, next) => prev.id === next.id && prev.updated === next.updated, +) diff --git a/src/features/tappable/displayRules.js b/src/features/tappable/displayRules.js new file mode 100644 index 000000000..fa8c2179b --- /dev/null +++ b/src/features/tappable/displayRules.js @@ -0,0 +1,61 @@ +// @ts-check + +const rewardPrimaryBehavior = Object.freeze({ + map: Object.freeze({ + rewardAsPrimary: true, + }), + popup: Object.freeze({ + rewardAsPrimary: true, + }), +}) + +const defaultBehavior = Object.freeze({ + map: Object.freeze({ + rewardAsPrimary: false, + }), + popup: Object.freeze({ + rewardAsPrimary: false, + }), +}) + +const toFrozenSet = (values) => Object.freeze(new Set(values.map(Number))) + +const rewardPrimaryRules = [ + { + types: Object.freeze(new Set(['TAPPABLE_TYPE_MAPLE'])), + itemIds: toFrozenSet([1151, 1152, 1155]), + behavior: rewardPrimaryBehavior, + }, + { + types: Object.freeze(new Set(['TAPPABLE_TYPE_BREAKFAST'])), + itemIds: toFrozenSet([650]), + behavior: rewardPrimaryBehavior, + }, +] + +/** + * Determines display overrides for a tappable marker and popup. + * @param {import('@rm/types').Tappable} tappable + */ +export function getTappableDisplaySettings(tappable) { + const typeKey = tappable?.type?.toString() + if (!typeKey) { + return defaultBehavior + } + + const itemId = Number(tappable?.item_id) + if (!Number.isFinite(itemId)) { + return defaultBehavior + } + + for (let i = 0; i < rewardPrimaryRules.length; i += 1) { + const rule = rewardPrimaryRules[i] + if (rule.types.has(typeKey) && rule.itemIds.has(itemId)) { + return rule.behavior + } + } + + return defaultBehavior +} + +export const TAPPABLE_DISPLAY_DEFAULT = defaultBehavior diff --git a/src/features/tappable/index.js b/src/features/tappable/index.js new file mode 100644 index 000000000..b49abd07f --- /dev/null +++ b/src/features/tappable/index.js @@ -0,0 +1,4 @@ +// @ts-check + +export * from './TappableTile' +export * from './TappablePopup' diff --git a/src/hooks/useFilter.js b/src/hooks/useFilter.js index 8e37daa9a..8b6acce3f 100644 --- a/src/hooks/useFilter.js +++ b/src/hooks/useFilter.js @@ -24,8 +24,8 @@ const filteringPokemon = [ export function useFilter(category, webhookCategory, reqCategories) { const { t } = useTranslation() const tempFilters = webhookCategory - ? useWebhookStore((s) => s.tempFilters) - : useStorage((s) => s.filters[category].filter) + ? useWebhookStore((s) => s.tempFilters || {}) + : useStorage((s) => s.filters?.[category]?.filter || {}) const search = useGetDeepStore(`searches.${category}Advanced`, '') .toLowerCase() @@ -37,15 +37,15 @@ export function useFilter(category, webhookCategory, reqCategories) { menuFilters, menus: { [category]: staticMenus }, } = useMemory.getState() - const menus = useStorage((s) => s.menus[category].filters) + const menus = useStorage((s) => s.menus?.[category]?.filters || {}) const { - generations, - types, - rarity, - historicRarity, - forms, - others, - categories, + generations = {}, + types = {}, + rarity = {}, + historicRarity = {}, + forms = {}, + others = { onlyAvailable: true }, + categories = {}, } = menus const tempAdvFilter = {} @@ -55,7 +55,8 @@ export function useFilter(category, webhookCategory, reqCategories) { let switchKey Object.keys(menus).forEach((subCategory) => { - tempAdvFilter[subCategory] = Object.values(menus[subCategory]).every( + const options = menus[subCategory] || {} + tempAdvFilter[subCategory] = Object.values(options).every( (val) => val === false, ) }) diff --git a/src/hooks/useOpacity.js b/src/hooks/useOpacity.js index ee4a4833d..671405527 100644 --- a/src/hooks/useOpacity.js +++ b/src/hooks/useOpacity.js @@ -5,7 +5,7 @@ import { useStorage } from '@store/useStorage' /** * Returns dynamic opacity based on timestamp - * @template {'pokemon' | 'gyms' | 'pokestops' | 'stations'} T + * @template {'pokemon' | 'gyms' | 'pokestops' | 'stations' | 'tappables'} T * @param {T} category * @param {T extends 'pokestops' ? 'invasion' : T extends 'gyms' ? 'raid' : never} [subCategory] * @returns diff --git a/src/pages/map/components/Effects.jsx b/src/pages/map/components/Effects.jsx index 7541ce110..16f08294d 100644 --- a/src/pages/map/components/Effects.jsx +++ b/src/pages/map/components/Effects.jsx @@ -12,6 +12,7 @@ import { useMemory } from '@store/useMemory' import { useGenGyms } from '../hooks/useGenGyms' import { useGenPokestops } from '../hooks/useGenPokestops' import { useGenPokemon } from '../hooks/useGenPokemon' +import { useGenTappables } from '../hooks/useGenTappables' export function Effects() { const params = useParams() @@ -22,6 +23,7 @@ export function Effects() { useGenGyms() useGenPokestops() useGenPokemon() + useGenTappables() const isMobile = useMediaQuery( (/** @type {import('@mui/system').Theme} */ theme) => diff --git a/src/pages/map/components/QueryData.jsx b/src/pages/map/components/QueryData.jsx index 938619def..72dde54af 100644 --- a/src/pages/map/components/QueryData.jsx +++ b/src/pages/map/components/QueryData.jsx @@ -43,6 +43,7 @@ const MANUAL_ID_CATEGORIES = new Set([ 'portals', 'stations', 'routes', + 'tappables', ]) /** diff --git a/src/pages/map/hooks/useGenTappables.js b/src/pages/map/hooks/useGenTappables.js new file mode 100644 index 000000000..ceb6fbe33 --- /dev/null +++ b/src/pages/map/hooks/useGenTappables.js @@ -0,0 +1,49 @@ +// @ts-check +import { useEffect } from 'react' +import { useTranslation } from 'react-i18next' + +import { useMemory } from '@store/useMemory' + +export function useGenTappables() { + const { t } = useTranslation() + const tappables = useMemory((s) => s.filters.tappables) + const categories = useMemory((s) => s.menus.tappables?.categories) + + useEffect(() => { + const hasCategory = Array.isArray(categories) + ? categories.includes('tappables') + : !!categories?.tappables + if (!tappables?.filter || !hasCategory) { + return + } + + /** @type {import('@rm/types').ClientFilterObj['tappables']} */ + const tappableFilters = {} + + Object.keys(tappables.filter).forEach((id) => { + if (id === 'global' || id === 'q0') return + const itemId = id.startsWith('q') ? id.slice(1) : id + const name = t(`item_${itemId}`, `#${itemId}`) + tappableFilters[id] = { + name, + perms: ['tappables'], + } + tappableFilters[id].searchMeta = + `${t('tappables').toLowerCase()} ${String(name).toLowerCase()}` + }) + + if (Object.keys(tappableFilters).length === 0) { + return + } + + useMemory.setState((prev) => ({ + menuFilters: { + ...prev.menuFilters, + tappables: { + ...(prev.menuFilters?.tappables || {}), + ...tappableFilters, + }, + }, + })) + }, [tappables, categories, t]) +} diff --git a/src/pages/map/hooks/usePermCheck.js b/src/pages/map/hooks/usePermCheck.js index de13a9eff..8f8ed86b5 100644 --- a/src/pages/map/hooks/usePermCheck.js +++ b/src/pages/map/hooks/usePermCheck.js @@ -63,6 +63,11 @@ export function usePermCheck(category) { return true } break + case 'tappables': + if (filters?.enabled && perms?.tappables) { + return true + } + break default: if (filters?.enabled && perms[category]) { return true diff --git a/src/pages/map/tileObject.js b/src/pages/map/tileObject.js index 71ccdcc5e..df2c5b479 100644 --- a/src/pages/map/tileObject.js +++ b/src/pages/map/tileObject.js @@ -14,6 +14,7 @@ import { WayfarerTile as submissionCells } from '@features/wayfarer' import { ScanAreaTile as scanAreas } from '@features/scanArea' import { BaseCell as s2cells } from '@features/s2cell' import { StationTile as stations } from '@features/station' +import { TappableTile as tappables } from '@features/tappable' export const TILES = { devices, @@ -30,4 +31,5 @@ export const TILES = { s2cells, routes, stations, + tappables, } diff --git a/src/services/Assets.js b/src/services/Assets.js index 9a97bf922..e0439db6c 100644 --- a/src/services/Assets.js +++ b/src/services/Assets.js @@ -413,6 +413,26 @@ export class UAssets { } } + /** + * @param {string} [type] + * @returns {string} + */ + getTappable(type = 'TAPPABLE_TYPE_POKEBALL') { + try { + const selection = this.selected.tappable + const pack = selection ? this[selection] : undefined + if (pack?.class && typeof pack.class.tappable === 'function') { + const iconPath = pack.class.tappable(type) + if (iconPath) { + return iconPath + } + } + } catch (e) { + console.error(`[${this.assetType.toUpperCase()}]`, e) + } + return this.getRewards(2, 1) + } + /** * * @param {string | number} [rewardType] diff --git a/src/services/queries/available.js b/src/services/queries/available.js index 5722a3e53..debe67076 100644 --- a/src/services/queries/available.js +++ b/src/services/queries/available.js @@ -44,3 +44,9 @@ export const GET_AVAILABLE_STATIONS = gql` availableStations } ` + +export const GET_AVAILABLE_TAPPABLES = gql` + query AvailableTappables { + availableTappables + } +` diff --git a/src/services/queries/index.js b/src/services/queries/index.js index c0bd1eaa9..c36a3a105 100644 --- a/src/services/queries/index.js +++ b/src/services/queries/index.js @@ -9,6 +9,7 @@ import * as searchIndex from './search' import * as webhookIndex from './webhook' import * as user from './user' import * as stationIndex from './station' +import * as tappableIndex from './tappable' import { GET_ALL_DEVICES } from './device' import { GET_ALL_SPAWNPOINTS } from './spawnpoint' import { GET_ALL_WEATHER } from './weather' @@ -150,6 +151,10 @@ export class Query { return stationIndex[query] } + static tappables() { + return tappableIndex.GET_ALL_TAPPABLES + } + /** @param {string} category */ static search(category) { const { perms } = useMemory.getState().auth diff --git a/src/services/queries/tappable.js b/src/services/queries/tappable.js new file mode 100644 index 000000000..f77cd231b --- /dev/null +++ b/src/services/queries/tappable.js @@ -0,0 +1,30 @@ +// @ts-check +import { gql } from '@apollo/client' + +export const GET_ALL_TAPPABLES = gql` + query GetTappables( + $minLat: Float! + $maxLat: Float! + $minLon: Float! + $maxLon: Float! + $filters: JSON + ) { + tappables( + minLat: $minLat + maxLat: $maxLat + minLon: $minLon + maxLon: $maxLon + filters: $filters + ) { + id + lat + lon + type + item_id + count + expire_timestamp + expire_timestamp_verified + updated + } + } +` diff --git a/src/store/useMemory.js b/src/store/useMemory.js index f886857ae..ccd3d732c 100644 --- a/src/store/useMemory.js +++ b/src/store/useMemory.js @@ -58,6 +58,7 @@ import { create } from 'zustand' * pokestops: string[], * nests: string[], * stations: string[], + * tappables: string[], * questConditions: Record, * } * manualParams: { @@ -130,6 +131,7 @@ export const useMemory = create(() => ({ pokestops: [], nests: [], stations: [], + tappables: [], questConditions: {}, }, Icons: null, @@ -161,6 +163,7 @@ export const useMemory = create(() => ({ pokestops: { count: 0, show: 0, total: 0 }, nests: { count: 0, show: 0, total: 0 }, stations: { count: 0, show: 0, total: 0 }, + tappables: { count: 0, show: 0, total: 0 }, }, advMenuFiltered: { gyms: [], @@ -168,6 +171,7 @@ export const useMemory = create(() => ({ pokemon: [], nests: [], stations: [], + tappables: [], }, })) diff --git a/src/store/useStorage.js b/src/store/useStorage.js index 98ffc04ff..c933c39f9 100644 --- a/src/store/useStorage.js +++ b/src/store/useStorage.js @@ -28,6 +28,7 @@ import { setDeep } from '@utils/setDeep' * pokestops: string, * nests: string, * stations: string, + * tappables: string, * } * searches: Record, * tabs: Record, @@ -145,6 +146,7 @@ export const useStorage = create( pokestops: 'categories', nests: 'others', stations: 'others', + tappables: 'categories', }, search: '', searchTab: '', diff --git a/yarn.lock b/yarn.lock index ca946632e..3059c9ad6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9820,10 +9820,10 @@ uglify-js@^3.1.4: resolved "https://registry.yarnpkg.com/uglify-js/-/uglify-js-3.19.3.tgz#82315e9bbc6f2b25888858acd1fff8441035b77f" integrity sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ== -uicons.js@2.0.3: - version "2.0.3" - resolved "https://registry.yarnpkg.com/uicons.js/-/uicons.js-2.0.3.tgz#38833f9cfad5fcbf6968ff3f0a1f36ab47dd40be" - integrity sha512-+hFdKLHkB1bIrwNNnDO8HBe0R10UuJDZ6xqkroigBqouHxZExuxznY2GlDUqGXv7RHB9OFNzFLo5mFzeudlf7w== +uicons.js@2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/uicons.js/-/uicons.js-2.1.0.tgz#9aaa6254aeba25dad5d4672d2fb840ec4c4c97b0" + integrity sha512-Pmg2QBvQuy3VaTzZDKDCM0/K+fgKf0EK/ZGOnxzkN33HiRCtMOxcSmN80/tO1WFDzCXG0FvbF/zj2rgPDy5zTA== uid-safe@~2.1.5: version "2.1.5"