Merge branch 'owls-integration' into main
This commit is contained in:
commit
38957c50c0
6 changed files with 224 additions and 93 deletions
127
pages/api/allNCTradeValues.js
Normal file
127
pages/api/allNCTradeValues.js
Normal file
|
@ -0,0 +1,127 @@
|
||||||
|
const beeline = require("honeycomb-beeline")({
|
||||||
|
writeKey: process.env["HONEYCOMB_WRITE_KEY"],
|
||||||
|
dataset:
|
||||||
|
process.env["NODE_ENV"] === "production"
|
||||||
|
? "Dress to Impress (2020)"
|
||||||
|
: "Dress to Impress (2020, dev)",
|
||||||
|
serviceName: "impress-2020-gql-server",
|
||||||
|
});
|
||||||
|
|
||||||
|
import fetch from "node-fetch";
|
||||||
|
|
||||||
|
import connectToDb from "../../src/server/db";
|
||||||
|
|
||||||
|
async function handle(req, res) {
|
||||||
|
const allNcItemNamesAndIdsPromise = loadAllNcItemNamesAndIds();
|
||||||
|
|
||||||
|
let itemValuesByIdOrName;
|
||||||
|
try {
|
||||||
|
itemValuesByIdOrName = await loadOWLSValuesByIdOrName();
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
res.setHeader("Content-Type", "text/plain; charset=utf8");
|
||||||
|
res.status(500).send("Error loading OWLS Pricer data");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restructure the value data to use IDs as keys, instead of names.
|
||||||
|
const allNcItemNamesAndIds = await allNcItemNamesAndIdsPromise;
|
||||||
|
const itemValues = {};
|
||||||
|
for (const { name, id } of allNcItemNamesAndIds) {
|
||||||
|
if (id in itemValuesByIdOrName) {
|
||||||
|
itemValues[id] = itemValuesByIdOrName[id];
|
||||||
|
} else if (name in itemValuesByIdOrName) {
|
||||||
|
itemValues[id] = itemValuesByIdOrName[name];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache for 1 minute, and immediately serve stale data for a day after.
|
||||||
|
// This should keep it fast and responsive, and stay well within our API key
|
||||||
|
// limits. (This will cause the client to send more requests than necessary,
|
||||||
|
// but the CDN cache should generally respond quickly with a small 304 Not
|
||||||
|
// Modified, unless the data really did change.)
|
||||||
|
res.setHeader(
|
||||||
|
"Cache-Control",
|
||||||
|
"public, max-age=3600, stale-while-revalidate=86400"
|
||||||
|
);
|
||||||
|
return res.send(itemValues);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadAllNcItemNamesAndIds() {
|
||||||
|
const db = await connectToDb();
|
||||||
|
|
||||||
|
const [rows] = await db.query(`
|
||||||
|
SELECT items.id, item_translations.name FROM items
|
||||||
|
INNER JOIN item_translations ON item_translations.item_id = items.id
|
||||||
|
WHERE
|
||||||
|
(items.rarity_index IN (0, 500) OR is_manually_nc = 1)
|
||||||
|
AND item_translations.locale = "en"
|
||||||
|
`);
|
||||||
|
|
||||||
|
return rows.map(({ id, name }) => ({ id, name: normalizeItemName(name) }));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load all OWLS Pricer values from the spreadsheet. Returns an object keyed by
|
||||||
|
* ID or name - that is, if the item ID is provided, we use that as the key; or
|
||||||
|
* if not, we use the name as the key.
|
||||||
|
*/
|
||||||
|
async function loadOWLSValuesByIdOrName() {
|
||||||
|
const res = await fetch(
|
||||||
|
`https://neo-owls.herokuapp.com/itemdata/owls_script/`
|
||||||
|
);
|
||||||
|
const json = await res.json();
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(
|
||||||
|
`Could not load OWLS Pricer data: ${res.status} ${res.statusText}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const itemValuesByIdOrName = {};
|
||||||
|
for (const [itemName, valueText] of Object.entries(json)) {
|
||||||
|
// OWLS returns an empty string for NC Mall items they don't have a trade
|
||||||
|
// value for, to allow the script to distinguish between NP items vs
|
||||||
|
// no-data NC items. We omit it from our data instead, because our UI is
|
||||||
|
// already aware of whether the item is NP or NC.
|
||||||
|
if (valueText.trim() === "") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: OWLS doesn't currently provide item IDs ever. Add support for it
|
||||||
|
// if it does! (I'm keeping the rest of the code the same because I
|
||||||
|
// think that might happen for disambiguation, like Waka did.) Until
|
||||||
|
// then, we just always key by name.
|
||||||
|
const normalizedItemName = normalizeItemName(itemName);
|
||||||
|
|
||||||
|
// We wrap it in an object with the key `valueText`, just to not break
|
||||||
|
// potential external consumers of this endpoint if we add more fields.
|
||||||
|
// (This is kinda silly and unnecessary, but it should get gzipped out and
|
||||||
|
// shouldn't add substantial time to building or parsing, so like w/e!)
|
||||||
|
itemValuesByIdOrName[normalizedItemName] = { valueText };
|
||||||
|
}
|
||||||
|
|
||||||
|
return itemValuesByIdOrName;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeItemName(name) {
|
||||||
|
return (
|
||||||
|
name
|
||||||
|
// Remove all spaces, they're a common source of inconsistency
|
||||||
|
.replace(/\s+/g, "")
|
||||||
|
// Lower case, because capitalization is another common source
|
||||||
|
.toLowerCase()
|
||||||
|
// Remove diacritics: https://stackoverflow.com/a/37511463/107415
|
||||||
|
.normalize("NFD")
|
||||||
|
.replace(/[\u0300-\u036f]/g, "")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleWithBeeline(req, res) {
|
||||||
|
beeline.withTrace(
|
||||||
|
{ name: "api/allNCTradeValues", operation_name: "api/allNCTradeValues" },
|
||||||
|
() => handle(req, res)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default handleWithBeeline;
|
|
@ -36,7 +36,13 @@ import { useQuery, useMutation } from "@apollo/client";
|
||||||
import { Link, useParams } from "react-router-dom";
|
import { Link, useParams } from "react-router-dom";
|
||||||
|
|
||||||
import ItemPageLayout, { SubtleSkeleton } from "./ItemPageLayout";
|
import ItemPageLayout, { SubtleSkeleton } from "./ItemPageLayout";
|
||||||
import { Delay, logAndCapture, useLocalStorage, usePageTitle } from "./util";
|
import {
|
||||||
|
Delay,
|
||||||
|
logAndCapture,
|
||||||
|
MajorErrorMessage,
|
||||||
|
useLocalStorage,
|
||||||
|
usePageTitle,
|
||||||
|
} from "./util";
|
||||||
import HTML5Badge, { layerUsesHTML5 } from "./components/HTML5Badge";
|
import HTML5Badge, { layerUsesHTML5 } from "./components/HTML5Badge";
|
||||||
import {
|
import {
|
||||||
itemAppearanceFragment,
|
itemAppearanceFragment,
|
||||||
|
@ -77,7 +83,7 @@ export function ItemPageContent({ itemId, isEmbedded }) {
|
||||||
thumbnailUrl
|
thumbnailUrl
|
||||||
description
|
description
|
||||||
createdAt
|
createdAt
|
||||||
wakaValueText
|
ncTradeValueText
|
||||||
|
|
||||||
# For Support users.
|
# For Support users.
|
||||||
rarityIndex
|
rarityIndex
|
||||||
|
@ -91,7 +97,7 @@ export function ItemPageContent({ itemId, isEmbedded }) {
|
||||||
usePageTitle(data?.item?.name, { skip: isEmbedded });
|
usePageTitle(data?.item?.name, { skip: isEmbedded });
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return <Box color="red.400">{error.message}</Box>;
|
return <MajorErrorMessage error={error} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
const item = data?.item;
|
const item = data?.item;
|
||||||
|
|
|
@ -5,7 +5,6 @@ import {
|
||||||
Flex,
|
Flex,
|
||||||
Popover,
|
Popover,
|
||||||
PopoverArrow,
|
PopoverArrow,
|
||||||
PopoverBody,
|
|
||||||
PopoverContent,
|
PopoverContent,
|
||||||
PopoverTrigger,
|
PopoverTrigger,
|
||||||
Portal,
|
Portal,
|
||||||
|
@ -16,12 +15,7 @@ import {
|
||||||
useToast,
|
useToast,
|
||||||
VStack,
|
VStack,
|
||||||
} from "@chakra-ui/react";
|
} from "@chakra-ui/react";
|
||||||
import {
|
import { ExternalLinkIcon, ChevronRightIcon } from "@chakra-ui/icons";
|
||||||
ExternalLinkIcon,
|
|
||||||
ChevronRightIcon,
|
|
||||||
QuestionIcon,
|
|
||||||
WarningTwoIcon,
|
|
||||||
} from "@chakra-ui/icons";
|
|
||||||
import { gql, useMutation } from "@apollo/client";
|
import { gql, useMutation } from "@apollo/client";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
@ -111,6 +105,7 @@ export function SubtleSkeleton({ isLoaded, ...props }) {
|
||||||
|
|
||||||
function ItemPageBadges({ item, isEmbedded }) {
|
function ItemPageBadges({ item, isEmbedded }) {
|
||||||
const searchBadgesAreLoaded = item?.name != null && item?.isNc != null;
|
const searchBadgesAreLoaded = item?.name != null && item?.isNc != null;
|
||||||
|
const shouldShowOwls = useShouldShowOwls();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ItemBadgeList marginTop="1">
|
<ItemBadgeList marginTop="1">
|
||||||
|
@ -156,47 +151,18 @@ function ItemPageBadges({ item, isEmbedded }) {
|
||||||
Jellyneo
|
Jellyneo
|
||||||
</LinkBadge>
|
</LinkBadge>
|
||||||
</SubtleSkeleton>
|
</SubtleSkeleton>
|
||||||
{item.isNc && (
|
{item.isNc && shouldShowOwls && (
|
||||||
<SubtleSkeleton
|
<SubtleSkeleton
|
||||||
isLoaded={
|
isLoaded={
|
||||||
// Distinguish between undefined (still loading) and null (loaded and
|
// Distinguish between undefined (still loading) and null (loaded
|
||||||
// empty).
|
// and empty).
|
||||||
item.wakaValueText !== undefined
|
item.ncTradeValueText !== undefined
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{shouldShowWaka() && item.wakaValueText && (
|
{item.ncTradeValueText && (
|
||||||
<>
|
<LinkBadge href="http://www.neopets.com/~owls">
|
||||||
{/* For hover-y devices, use a hover popover over the badge. */}
|
OWLS: {item.ncTradeValueText}
|
||||||
<Box sx={{ "@media (hover: none)": { display: "none" } }}>
|
|
||||||
<WakaPopover trigger="hover">
|
|
||||||
<LinkBadge
|
|
||||||
href="http://www.neopets.com/~waka"
|
|
||||||
colorScheme="yellow"
|
|
||||||
>
|
|
||||||
<WarningTwoIcon marginRight="1" />
|
|
||||||
Waka: {item.wakaValueText}
|
|
||||||
</LinkBadge>
|
</LinkBadge>
|
||||||
</WakaPopover>
|
|
||||||
</Box>
|
|
||||||
{/* For touch-y devices, use a tappable help icon. */}
|
|
||||||
<Flex
|
|
||||||
sx={{ "@media (hover: hover)": { display: "none" } }}
|
|
||||||
align="center"
|
|
||||||
>
|
|
||||||
<LinkBadge
|
|
||||||
href="http://www.neopets.com/~waka"
|
|
||||||
colorScheme="yellow"
|
|
||||||
>
|
|
||||||
<WarningTwoIcon marginRight="1" />
|
|
||||||
Waka: {item.wakaValueText}
|
|
||||||
</LinkBadge>
|
|
||||||
<WakaPopover>
|
|
||||||
<Flex align="center" fontSize="sm" paddingX="2" tabIndex="0">
|
|
||||||
<QuestionIcon />
|
|
||||||
</Flex>
|
|
||||||
</WakaPopover>
|
|
||||||
</Flex>
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</SubtleSkeleton>
|
</SubtleSkeleton>
|
||||||
)}
|
)}
|
||||||
|
@ -439,55 +405,34 @@ function ShortTimestamp({ when }) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function WakaPopover({ children, ...props }) {
|
const SHOULD_SHOW_OWLS = false;
|
||||||
return (
|
|
||||||
<Popover placement="bottom" {...props}>
|
|
||||||
<PopoverTrigger>{children}</PopoverTrigger>
|
|
||||||
<Portal>
|
|
||||||
<PopoverContent>
|
|
||||||
<PopoverArrow />
|
|
||||||
<PopoverBody fontSize="sm">
|
|
||||||
<p>
|
|
||||||
<strong>
|
|
||||||
The Waka Guide for NC trade values is closing down!
|
|
||||||
</strong>{" "}
|
|
||||||
We're sad to see them go, but excited that the team gets to move
|
|
||||||
onto the next phase in their life 💖
|
|
||||||
</p>
|
|
||||||
<Box height="1em" />
|
|
||||||
<p>
|
|
||||||
The Waka guide was last updated August 7, 2021, so this value
|
|
||||||
might not be accurate anymore. Consider checking in with the
|
|
||||||
Neoboards!
|
|
||||||
</p>
|
|
||||||
<Box height="1em" />
|
|
||||||
<p>
|
|
||||||
Thanks again to the Waka team for letting us experiment with
|
|
||||||
sharing their trade values here. Best wishes for everything to
|
|
||||||
come! 💜
|
|
||||||
</p>
|
|
||||||
</PopoverBody>
|
|
||||||
</PopoverContent>
|
|
||||||
</Portal>
|
|
||||||
</Popover>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// August 21, 2021. (The month uses 0-indexing, but nothing else does! 🙃)
|
|
||||||
const STOP_SHOWING_WAKA_AFTER = new Date(2021, 7, 21, 0, 0, 0, 0);
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* shouldShowWaka returns true if, according to the browser, it's not yet
|
* useShouldShowOwls will return false until the user types "~owls" on the
|
||||||
* August 21, 2021. It starts returning false at midnight on Aug 21.
|
* page, after which the ~owls badge will appear.
|
||||||
*
|
*
|
||||||
* That way, our Waka deprecation message is on an auto-timer. After Aug 21,
|
* We also keep the value in a global, so it'll stick if you go search for
|
||||||
* it's safe to remove all Waka UI code, and the Waka API endpoint and GraphQL
|
* another item too!
|
||||||
* fields. (It might be kind to return a placeholder string for the GraphQL
|
|
||||||
* case!)
|
|
||||||
*/
|
*/
|
||||||
function shouldShowWaka() {
|
function useShouldShowOwls() {
|
||||||
const now = new Date();
|
const [mostRecentKeys, setMostRecentKeys] = React.useState([]);
|
||||||
return now < STOP_SHOWING_WAKA_AFTER;
|
const [shouldShowOwls, setShouldShowOwls] = React.useState(SHOULD_SHOW_OWLS);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const onKeyPress = (e) => {
|
||||||
|
const newMostRecentKeys = [...mostRecentKeys, e.key].slice(-5);
|
||||||
|
if (newMostRecentKeys.join("") === "~owls") {
|
||||||
|
SHOULD_SHOW_OWLS = true;
|
||||||
|
setShouldShowOwls(true);
|
||||||
|
}
|
||||||
|
setMostRecentKeys(newMostRecentKeys);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("keypress", onKeyPress);
|
||||||
|
return () => window.removeEventListener("keypress", onKeyPress);
|
||||||
|
});
|
||||||
|
|
||||||
|
return shouldShowOwls;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default ItemPageLayout;
|
export default ItemPageLayout;
|
||||||
|
|
|
@ -102,7 +102,7 @@ function ItemTradesPage({
|
||||||
thumbnailUrl
|
thumbnailUrl
|
||||||
description
|
description
|
||||||
createdAt
|
createdAt
|
||||||
wakaValueText
|
ncTradeValueText
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
`,
|
`,
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
import DataLoader from "dataloader";
|
import DataLoader from "dataloader";
|
||||||
|
import fetch from "node-fetch";
|
||||||
import { normalizeRow } from "./util";
|
import { normalizeRow } from "./util";
|
||||||
|
|
||||||
const buildClosetListLoader = (db) =>
|
const buildClosetListLoader = (db) =>
|
||||||
|
@ -825,6 +826,31 @@ const buildItemTradesLoader = (db, loaders) =>
|
||||||
{ cacheKeyFn: ({ itemId, isOwned }) => `${itemId}-${isOwned}` }
|
{ cacheKeyFn: ({ itemId, isOwned }) => `${itemId}-${isOwned}` }
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const buildItemNCTradeValueLoader = () =>
|
||||||
|
new DataLoader(async (itemIds) => {
|
||||||
|
// This loader calls our /api/allNCTradeValues endpoint, to take advantage
|
||||||
|
// of the CDN caching. This helps us respond a bit faster than calling the
|
||||||
|
// API directly would, and avoids putting network pressure or caching
|
||||||
|
// complexity on our ~owls friends! (It would also be pretty reasonable to
|
||||||
|
// do this as a process-level cache or something instead, but I'm reusing
|
||||||
|
// Waka code from when we were on a more distributed system where that
|
||||||
|
// wouldn't have worked out, and I don't think the effort to refactor this
|
||||||
|
// just for the potential perf win is worthy!)
|
||||||
|
const url = process.env.NODE_ENV === "production"
|
||||||
|
? "https://impress-2020.openneo.net/api/allNCTradeValues"
|
||||||
|
: "http://localhost:3000/api/allNCTradeValues";
|
||||||
|
const res = await fetch(url);
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(
|
||||||
|
`Error loading /api/allNCTradeValues: ${res.status} ${res.statusText}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const allNCTradeValues = await res.json();
|
||||||
|
|
||||||
|
return itemIds.map((itemId) => allNCTradeValues[itemId]);
|
||||||
|
});
|
||||||
|
|
||||||
const buildPetTypeLoader = (db, loaders) =>
|
const buildPetTypeLoader = (db, loaders) =>
|
||||||
new DataLoader(async (petTypeIds) => {
|
new DataLoader(async (petTypeIds) => {
|
||||||
const qs = petTypeIds.map((_) => "?").join(",");
|
const qs = petTypeIds.map((_) => "?").join(",");
|
||||||
|
@ -1495,6 +1521,7 @@ function buildLoaders(db) {
|
||||||
db
|
db
|
||||||
);
|
);
|
||||||
loaders.itemTradesLoader = buildItemTradesLoader(db, loaders);
|
loaders.itemTradesLoader = buildItemTradesLoader(db, loaders);
|
||||||
|
loaders.itemNCTradeValueLoader = buildItemNCTradeValueLoader();
|
||||||
loaders.petTypeLoader = buildPetTypeLoader(db, loaders);
|
loaders.petTypeLoader = buildPetTypeLoader(db, loaders);
|
||||||
loaders.petTypeBySpeciesAndColorLoader = buildPetTypeBySpeciesAndColorLoader(
|
loaders.petTypeBySpeciesAndColorLoader = buildPetTypeBySpeciesAndColorLoader(
|
||||||
db,
|
db,
|
||||||
|
|
|
@ -33,6 +33,18 @@ const typeDefs = gql`
|
||||||
"""
|
"""
|
||||||
wakaValueText: String @cacheControl(maxAge: ${oneHour})
|
wakaValueText: String @cacheControl(maxAge: ${oneHour})
|
||||||
|
|
||||||
|
"""
|
||||||
|
This item's NC trade value as a human-readable string. Returns null if the
|
||||||
|
value is not known.
|
||||||
|
|
||||||
|
Note that the format of this string is not well-specified—it's fully
|
||||||
|
human-curated and may include surprising words or extra notes! We recommend
|
||||||
|
presenting the text exactly as-is, rather than trying to parse and math it.
|
||||||
|
|
||||||
|
This data is currently curated by neopets.com/~owls, thank you!! <3
|
||||||
|
"""
|
||||||
|
ncTradeValueText: String @cacheControl(maxAge: ${oneHour})
|
||||||
|
|
||||||
currentUserOwnsThis: Boolean! @cacheControl(maxAge: 0, scope: PRIVATE)
|
currentUserOwnsThis: Boolean! @cacheControl(maxAge: 0, scope: PRIVATE)
|
||||||
currentUserWantsThis: Boolean! @cacheControl(maxAge: 0, scope: PRIVATE)
|
currentUserWantsThis: Boolean! @cacheControl(maxAge: 0, scope: PRIVATE)
|
||||||
|
|
||||||
|
@ -344,6 +356,20 @@ const resolvers = {
|
||||||
// This feature is deprecated, so now we just always return unknown value.
|
// This feature is deprecated, so now we just always return unknown value.
|
||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
|
ncTradeValueText: async ({ id }, _, { itemNCTradeValueLoader }) => {
|
||||||
|
let ncTradeValue;
|
||||||
|
try {
|
||||||
|
ncTradeValue = await itemNCTradeValueLoader.load(id);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(
|
||||||
|
`Error loading ncTradeValueText for item ${id}, skipping:`
|
||||||
|
);
|
||||||
|
console.error(e);
|
||||||
|
ncTradeValue = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ncTradeValue ? ncTradeValue.valueText : null;
|
||||||
|
},
|
||||||
|
|
||||||
currentUserOwnsThis: async (
|
currentUserOwnsThis: async (
|
||||||
{ id },
|
{ id },
|
||||||
|
|
Loading…
Reference in a new issue