Matchu
43ae248e87
The tricky part here was that `returnPartialData` seems to behave differently during SSR. On the page itself, this seems to cause us to always get back at least an empty object, but in SSR we can sometimes get null—which means that a LOT of code that expects the item object to exist while in loading state gets thrown off. To keep this situation maximally clear, I added a bunch of null handling with `?.` to `ItemPageLayout`. An alternative would have been to check for null and put in an empty object if not, but this feels more resilient and more true to the situation. The search bar here is a bit tricky, but is pretty straightforwardly adapted from how we did the layouts in App.js. Fingers crossed that it works as smoothly as expected when the search page is migrated too! (Right now typing in there is all messy because it hops over to the fallback route and does its whole separate thing.)
411 lines
12 KiB
JavaScript
411 lines
12 KiB
JavaScript
import React from "react";
|
|
import {
|
|
Badge,
|
|
Box,
|
|
Flex,
|
|
Popover,
|
|
PopoverArrow,
|
|
PopoverContent,
|
|
PopoverTrigger,
|
|
Portal,
|
|
Select,
|
|
Skeleton,
|
|
Spinner,
|
|
Tooltip,
|
|
useToast,
|
|
VStack,
|
|
} from "@chakra-ui/react";
|
|
import { ExternalLinkIcon, ChevronRightIcon } from "@chakra-ui/icons";
|
|
import { gql, useMutation } from "@apollo/client";
|
|
|
|
import {
|
|
ItemBadgeList,
|
|
ItemKindBadge,
|
|
ItemThumbnail,
|
|
} from "./components/ItemCard";
|
|
import { Heading1 } from "./util";
|
|
|
|
import useSupport from "./WardrobePage/support/useSupport";
|
|
|
|
function ItemPageLayout({ children, item, isEmbedded }) {
|
|
return (
|
|
<Box>
|
|
<ItemPageHeader item={item} isEmbedded={isEmbedded} />
|
|
<Box>{children}</Box>
|
|
</Box>
|
|
);
|
|
}
|
|
|
|
function ItemPageHeader({ item, isEmbedded }) {
|
|
return (
|
|
<Box
|
|
display="flex"
|
|
alignItems="center"
|
|
justifyContent="flex-start"
|
|
width="100%"
|
|
>
|
|
<SubtleSkeleton isLoaded={item?.thumbnailUrl} marginRight="4">
|
|
<ItemThumbnail item={item} size="lg" isActive flex="0 0 auto" />
|
|
</SubtleSkeleton>
|
|
<Box>
|
|
<SubtleSkeleton isLoaded={item?.name}>
|
|
<Heading1
|
|
lineHeight="1.1"
|
|
// Nudge down the size a bit in the embed case, to better fit the
|
|
// tighter layout!
|
|
size={isEmbedded ? "xl" : "2xl"}
|
|
>
|
|
{item?.name || "Item name here"}
|
|
</Heading1>
|
|
</SubtleSkeleton>
|
|
<ItemPageBadges item={item} isEmbedded={isEmbedded} />
|
|
</Box>
|
|
</Box>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* SubtleSkeleton hides the skeleton animation until a second has passed, and
|
|
* doesn't fade in the content if it loads near-instantly. This helps avoid
|
|
* flash-of-content stuff!
|
|
*
|
|
* For plain Skeletons, we often use <Delay><Skeleton /></Delay> instead. But
|
|
* that pattern doesn't work as well for wrapper skeletons where we're using
|
|
* placeholder content for layout: we don't want the delay if the content
|
|
* really _is_ present!
|
|
*/
|
|
export function SubtleSkeleton({ isLoaded, ...props }) {
|
|
const [shouldFadeIn, setShouldFadeIn] = React.useState(false);
|
|
const [shouldShowSkeleton, setShouldShowSkeleton] = React.useState(false);
|
|
|
|
React.useEffect(() => {
|
|
const t = setTimeout(() => {
|
|
if (!isLoaded) {
|
|
setShouldFadeIn(true);
|
|
}
|
|
}, 150);
|
|
return () => clearTimeout(t);
|
|
});
|
|
|
|
React.useEffect(() => {
|
|
const t = setTimeout(() => setShouldShowSkeleton(true), 500);
|
|
return () => clearTimeout(t);
|
|
});
|
|
|
|
return (
|
|
<Skeleton
|
|
fadeDuration={shouldFadeIn ? undefined : 0}
|
|
startColor={shouldShowSkeleton ? undefined : "transparent"}
|
|
endColor={shouldShowSkeleton ? undefined : "transparent"}
|
|
isLoaded={isLoaded}
|
|
{...props}
|
|
/>
|
|
);
|
|
}
|
|
|
|
function ItemPageBadges({ item, isEmbedded }) {
|
|
const searchBadgesAreLoaded = item?.name != null && item?.isNc != null;
|
|
|
|
return (
|
|
<ItemBadgeList marginTop="1">
|
|
<SubtleSkeleton isLoaded={item?.isNc != null}>
|
|
<ItemKindBadgeWithSupportTools item={item} />
|
|
</SubtleSkeleton>
|
|
{
|
|
// If the createdAt date is null (loaded and empty), hide the badge.
|
|
item?.createdAt !== null && (
|
|
<SubtleSkeleton
|
|
// Distinguish between undefined (still loading) and null (loaded and
|
|
// empty).
|
|
isLoaded={item?.createdAt !== undefined}
|
|
>
|
|
<Badge
|
|
display="block"
|
|
minWidth="5.25em"
|
|
boxSizing="content-box"
|
|
textAlign="center"
|
|
>
|
|
{item?.createdAt && <ShortTimestamp when={item?.createdAt} />}
|
|
</Badge>
|
|
</SubtleSkeleton>
|
|
)
|
|
}
|
|
<SubtleSkeleton isLoaded={searchBadgesAreLoaded}>
|
|
<LinkBadge
|
|
href={`https://impress.openneo.net/items/${item?.id}`}
|
|
isEmbedded={isEmbedded}
|
|
>
|
|
Classic DTI
|
|
</LinkBadge>
|
|
</SubtleSkeleton>
|
|
<SubtleSkeleton isLoaded={searchBadgesAreLoaded}>
|
|
<LinkBadge
|
|
href={
|
|
"https://items.jellyneo.net/search/?name=" +
|
|
encodeURIComponent(item?.name) +
|
|
"&name_type=3"
|
|
}
|
|
isEmbedded={isEmbedded}
|
|
>
|
|
Jellyneo
|
|
</LinkBadge>
|
|
</SubtleSkeleton>
|
|
{item?.isNc && (
|
|
<SubtleSkeleton
|
|
isLoaded={
|
|
// Distinguish between undefined (still loading) and null (loaded
|
|
// and empty).
|
|
item?.ncTradeValueText !== undefined
|
|
}
|
|
>
|
|
{item?.ncTradeValueText && (
|
|
<LinkBadge href="http://www.neopets.com/~owls">
|
|
OWLS: {item?.ncTradeValueText}
|
|
</LinkBadge>
|
|
)}
|
|
</SubtleSkeleton>
|
|
)}
|
|
<SubtleSkeleton isLoaded={searchBadgesAreLoaded}>
|
|
{!item?.isNc && !item?.isPb && (
|
|
<LinkBadge
|
|
href={
|
|
"http://www.neopets.com/shops/wizard.phtml?string=" +
|
|
encodeURIComponent(item?.name)
|
|
}
|
|
isEmbedded={isEmbedded}
|
|
>
|
|
Shop Wiz
|
|
</LinkBadge>
|
|
)}
|
|
</SubtleSkeleton>
|
|
<SubtleSkeleton isLoaded={searchBadgesAreLoaded}>
|
|
{!item?.isNc && !item?.isPb && (
|
|
<LinkBadge
|
|
href={
|
|
"http://www.neopets.com/portal/supershopwiz.phtml?string=" +
|
|
encodeURIComponent(item?.name)
|
|
}
|
|
isEmbedded={isEmbedded}
|
|
>
|
|
Super Wiz
|
|
</LinkBadge>
|
|
)}
|
|
</SubtleSkeleton>
|
|
<SubtleSkeleton isLoaded={searchBadgesAreLoaded}>
|
|
{!item?.isNc && !item?.isPb && (
|
|
<LinkBadge
|
|
href={
|
|
"http://www.neopets.com/island/tradingpost.phtml?type=browse&criteria=item_exact&search_string=" +
|
|
encodeURIComponent(item?.name)
|
|
}
|
|
isEmbedded={isEmbedded}
|
|
>
|
|
Trade Post
|
|
</LinkBadge>
|
|
)}
|
|
</SubtleSkeleton>
|
|
<SubtleSkeleton isLoaded={searchBadgesAreLoaded}>
|
|
{!item?.isNc && !item?.isPb && (
|
|
<LinkBadge
|
|
href={
|
|
"http://www.neopets.com/genie.phtml?type=process_genie&criteria=exact&auctiongenie=" +
|
|
encodeURIComponent(item?.name)
|
|
}
|
|
isEmbedded={isEmbedded}
|
|
>
|
|
Auctions
|
|
</LinkBadge>
|
|
)}
|
|
</SubtleSkeleton>
|
|
</ItemBadgeList>
|
|
);
|
|
}
|
|
|
|
function ItemKindBadgeWithSupportTools({ item }) {
|
|
const { isSupportUser, supportSecret } = useSupport();
|
|
const toast = useToast();
|
|
|
|
const ncRef = React.useRef(null);
|
|
|
|
const isNcAutoDetectedFromRarity =
|
|
item?.rarityIndex === 500 || item?.rarityIndex === 0;
|
|
|
|
const [mutate, { loading }] = useMutation(gql`
|
|
mutation ItemPageSupportSetIsManuallyNc(
|
|
$itemId: ID!
|
|
$isManuallyNc: Boolean!
|
|
$supportSecret: String!
|
|
) {
|
|
setItemIsManuallyNc(
|
|
itemId: $itemId
|
|
isManuallyNc: $isManuallyNc
|
|
supportSecret: $supportSecret
|
|
) {
|
|
id
|
|
isNc
|
|
isManuallyNc
|
|
}
|
|
}
|
|
`);
|
|
|
|
if (
|
|
isSupportUser &&
|
|
item?.rarityIndex != null &&
|
|
item?.isManuallyNc != null
|
|
) {
|
|
// TODO: Could code-split this into a SupportOnly file...
|
|
return (
|
|
<Popover placement="bottom-start" initialFocusRef={ncRef} showArrow>
|
|
<PopoverTrigger>
|
|
<ItemKindBadge isNc={item.isNc} isPb={item.isPb} isEditButton />
|
|
</PopoverTrigger>
|
|
<Portal>
|
|
<PopoverContent padding="4">
|
|
<PopoverArrow />
|
|
<VStack spacing="2" align="flex-start">
|
|
<Flex align="center">
|
|
<Box as="span" fontWeight="600" marginRight="2">
|
|
NC:
|
|
</Box>
|
|
<Select
|
|
ref={ncRef}
|
|
size="xs"
|
|
value={item.isManuallyNc ? "true" : "false"}
|
|
onChange={(e) => {
|
|
const isManuallyNc = e.target.value === "true";
|
|
mutate({
|
|
variables: {
|
|
itemId: item.id,
|
|
isManuallyNc,
|
|
supportSecret,
|
|
},
|
|
optimisticResponse: {
|
|
setItemIsManuallyNc: {
|
|
__typename: "Item",
|
|
id: item.id,
|
|
isNc: isManuallyNc || isNcAutoDetectedFromRarity,
|
|
isManuallyNc,
|
|
},
|
|
},
|
|
}).catch((e) => {
|
|
console.error(e);
|
|
toast({
|
|
status: "error",
|
|
title:
|
|
"Could not set NC status for this item. Try again?",
|
|
});
|
|
});
|
|
}}
|
|
>
|
|
<option value="false">
|
|
Auto-detect: {isNcAutoDetectedFromRarity ? "Yes" : "No"}.{" "}
|
|
(Rarity {item.rarityIndex})
|
|
</option>
|
|
<option value="true">Manually set: Yes.</option>
|
|
</Select>
|
|
{loading && <Spinner size="sm" marginLeft="2" />}
|
|
</Flex>
|
|
<Flex align="center">
|
|
<Box as="span" fontWeight="600" marginRight="1">
|
|
PB:
|
|
</Box>
|
|
<Select size="xs" isReadOnly value="auto-detect">
|
|
<option value="auto-detect">
|
|
Auto-detect: {item.isPb ? "Yes" : "No"}. (from description)
|
|
</option>
|
|
<option style={{ fontStyle: "italic" }}>
|
|
(This cannot be manually set.)
|
|
</option>
|
|
</Select>
|
|
</Flex>
|
|
<Badge
|
|
colorScheme="pink"
|
|
alignSelf="flex-end"
|
|
marginBottom="-2"
|
|
marginRight="-2"
|
|
>
|
|
Support <span aria-hidden="true">💖</span>
|
|
</Badge>
|
|
</VStack>
|
|
</PopoverContent>
|
|
</Portal>
|
|
</Popover>
|
|
);
|
|
}
|
|
|
|
return <ItemKindBadge isNc={item?.isNc} isPb={item?.isPb} />;
|
|
}
|
|
|
|
const LinkBadge = React.forwardRef(
|
|
({ children, href, isEmbedded, ...props }, ref) => {
|
|
return (
|
|
<Badge
|
|
ref={ref}
|
|
as="a"
|
|
href={href}
|
|
display="flex"
|
|
alignItems="center"
|
|
// Normally we want to act like a normal webpage, and treat links as
|
|
// normal. But when we're on the wardrobe page, we want to avoid
|
|
// disrupting the outfit, and open in a new window instead.
|
|
target={isEmbedded ? "_blank" : undefined}
|
|
_focus={{ outline: "none", boxShadow: "outline" }}
|
|
{...props}
|
|
>
|
|
{children}
|
|
{
|
|
// We also change the icon to signal whether this will launch in a new
|
|
// window or not!
|
|
isEmbedded ? (
|
|
<ExternalLinkIcon marginLeft="1" />
|
|
) : (
|
|
<ChevronRightIcon />
|
|
)
|
|
}
|
|
</Badge>
|
|
);
|
|
}
|
|
);
|
|
|
|
const fullDateFormatter = new Intl.DateTimeFormat("en-US", {
|
|
dateStyle: "long",
|
|
});
|
|
const monthYearFormatter = new Intl.DateTimeFormat("en-US", {
|
|
month: "short",
|
|
year: "numeric",
|
|
});
|
|
const monthDayYearFormatter = new Intl.DateTimeFormat("en-US", {
|
|
month: "short",
|
|
day: "numeric",
|
|
year: "numeric",
|
|
});
|
|
function ShortTimestamp({ when }) {
|
|
const date = new Date(when);
|
|
|
|
// To find the start of last month, take today, then set its date to the 1st
|
|
// and its time to midnight (the start of this month), and subtract one
|
|
// month. (JS handles negative months and rolls them over correctly.)
|
|
const startOfLastMonth = new Date();
|
|
startOfLastMonth.setDate(1);
|
|
startOfLastMonth.setHours(0);
|
|
startOfLastMonth.setMinutes(0);
|
|
startOfLastMonth.setSeconds(0);
|
|
startOfLastMonth.setMilliseconds(0);
|
|
startOfLastMonth.setMonth(startOfLastMonth.getMonth() - 1);
|
|
|
|
const dateIsOlderThanLastMonth = date < startOfLastMonth;
|
|
|
|
return (
|
|
<Tooltip
|
|
label={`First seen on ${fullDateFormatter.format(date)}`}
|
|
placement="top"
|
|
openDelay={400}
|
|
>
|
|
{dateIsOlderThanLastMonth
|
|
? monthYearFormatter.format(date)
|
|
: monthDayYearFormatter.format(date)}
|
|
</Tooltip>
|
|
);
|
|
}
|
|
|
|
export default ItemPageLayout;
|