import React from "react"; import { css } from "emotion"; import { AspectRatio, Badge, Button, Box, IconButton, Skeleton, SkeletonText, Tooltip, VisuallyHidden, VStack, useBreakpointValue, useColorModeValue, useTheme, useToast, } from "@chakra-ui/core"; import { CheckIcon, ExternalLinkIcon, ChevronRightIcon, StarIcon, WarningIcon, } from "@chakra-ui/icons"; import { MdPause, MdPlayArrow } from "react-icons/md"; import gql from "graphql-tag"; import { useQuery } from "@apollo/client"; import { useParams } from "react-router-dom"; import { ItemBadgeList, ItemThumbnail, NcBadge, NpBadge, } from "./components/ItemCard"; import { Delay, Heading1, usePageTitle } from "./util"; import { itemAppearanceFragment, petAppearanceFragment, } from "./components/useOutfitAppearance"; import OutfitPreview from "./components/OutfitPreview"; import SpeciesColorPicker from "./components/SpeciesColorPicker"; import { useLocalStorage } from "./util"; import WIPCallout from "./components/WIPCallout"; function ItemPage() { const { itemId } = useParams(); return ; } /** * ItemPageContent is the content of ItemPage, but we also use it as the * entry point for ItemPageDrawer! When embedded in ItemPageDrawer, the * `isEmbedded` prop is true, so we know not to e.g. set the page title. */ export function ItemPageContent({ itemId, isEmbedded }) { return ( {!isEmbedded && } Trade lists coming soon! ); } function ItemPageHeader({ itemId, isEmbedded }) { const { error, data } = useQuery( gql` query ItemPage($itemId: ID!) { item(id: $itemId) { id name isNc thumbnailUrl description createdAt } } `, { variables: { itemId }, returnPartialData: true } ); usePageTitle(data?.item?.name, { skip: isEmbedded }); // Show 2 lines of description text placeholder on small screens, or when // embedded in the wardrobe page's narrow drawer. In larger contexts, show // just 1 line. const viewportNumDescriptionLines = useBreakpointValue({ base: 2, md: 1 }); const numDescriptionLines = isEmbedded ? 2 : viewportNumDescriptionLines; if (error) { return {error.message}; } const item = data?.item; return ( <> {item?.name || "Item name here"} {item?.description || ( )} ); } function ItemPageBadges({ item, isEmbedded }) { const searchBadgesAreLoaded = item?.name != null && item?.isNc != null; return ( {item?.isNc ? : } { // If the createdAt date is null (loaded and empty), hide the badge. item.createdAt !== null && ( {item.createdAt && } ) } Classic DTI Jellyneo {!item?.isNc && ( Shop Wiz )} {!item?.isNc && ( Trade Post )} {!item?.isNc && ( Auctions )} ); } function LinkBadge({ children, href, isEmbedded }) { return ( {children} { // We also change the icon to signal whether this will launch in a new // window or not! isEmbedded ? : } ); } 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 ( {dateIsOlderThanLastMonth ? monthYearFormatter.format(date) : monthDayYearFormatter.format(date)} ); } function ItemPageOwnWantButtons({ itemId }) { const theme = useTheme(); const toast = useToast(); const [currentUserOwnsThis, setCurrentUserOwnsThis] = React.useState(false); const [currentUserWantsThis, setCurrentUserWantsThis] = React.useState(false); const { loading, error } = useQuery( gql` query ItemPageOwnWantButtons($itemId: ID!) { item(id: $itemId) { id currentUserOwnsThis currentUserWantsThis } } `, { variables: { itemId }, onCompleted: (data) => { setCurrentUserOwnsThis(data?.item?.currentUserOwnsThis || false); setCurrentUserWantsThis(data?.item?.currentUserWantsThis || false); }, } ); if (error) { return {error.message}; } return ( { setCurrentUserOwnsThis(e.target.checked); toast({ title: "Todo: This doesn't actually work yet!", status: "info", duration: 1500, }); }} /> { setCurrentUserWantsThis(e.target.checked); toast({ title: "Todo: This doesn't actually work yet!", status: "info", duration: 1500, }); }} /> ); } function IconCheckbox({ icon, isChecked, ...props }) { return ( {icon} ); } function ItemPageOutfitPreview({ itemId }) { const idealPose = React.useMemo( () => (Math.random() > 0.5 ? "HAPPY_FEM" : "HAPPY_MASC"), [] ); const [petState, setPetState] = React.useState({ // We'll fill these in once the canonical appearance data arrives. speciesId: null, colorId: null, pose: null, // We use appearance ID, in addition to the above, to give the Apollo cache // a really clear hint that the canonical pet appearance we preloaded is // the exact right one to show! But switching species/color will null this // out again, and that's okay. (We'll do an unnecessary reload if you // switch back to it though... we could maybe do something clever there!) appearanceId: null, }); // Start by loading the "canonical" pet and item appearance for the outfit // preview. We'll use this to initialize both the preview and the picker. // // TODO: If this is a non-standard pet color, like Mutant, we'll do an extra // query after this loads, because our Apollo cache can't detect the // shared item appearance. (For standard colors though, our logic to // cover standard-color switches works for this preloading too.) const { loading, error } = useQuery( gql` query ItemPageOutfitPreview($itemId: ID!) { item(id: $itemId) { id canonicalAppearance { id ...ItemAppearanceForOutfitPreview body { id canonicalAppearance { id species { id } color { id } pose ...PetAppearanceForOutfitPreview } } } } } ${itemAppearanceFragment} ${petAppearanceFragment} `, { variables: { itemId }, onCompleted: (data) => { const canonicalBody = data?.item?.canonicalAppearance?.body; const canonicalPetAppearance = canonicalBody?.canonicalAppearance; setPetState({ speciesId: canonicalPetAppearance?.species?.id, colorId: canonicalPetAppearance?.color?.id, pose: canonicalPetAppearance?.pose, appearanceId: canonicalPetAppearance?.id, }); }, } ); // To check whether the item is compatible with this pet, query for the // appearance, but only against the cache. That way, we don't send a // redundant network request just for this (the OutfitPreview component will // handle it!), but we'll get an update once it arrives in the cache. const { data: cachedData } = useQuery( gql` query ItemPageOutfitPreview_CacheOnly( $itemId: ID! $speciesId: ID! $colorId: ID! ) { item(id: $itemId) { appearanceOn(speciesId: $speciesId, colorId: $colorId) { layers { id } } } } `, { variables: { itemId, speciesId: petState.speciesId, colorId: petState.colorId, }, fetchPolicy: "cache-only", } ); const [hasAnimations, setHasAnimations] = React.useState(false); const [isPaused, setIsPaused] = useLocalStorage("DTIOutfitIsPaused", true); const borderColor = useColorModeValue("green.700", "green.400"); const errorColor = useColorModeValue("red.600", "red.400"); if (error) { return {error.message}; } // If the layers are null-y, then we're still loading. Otherwise, if the // layers are an empty array, then we're incomaptible. Or, if they're a // non-empty array, then we're compatible! const layers = cachedData?.item?.appearanceOn?.layers; const isIncompatible = Array.isArray(layers) && layers.length === 0; return ( {hasAnimations && ( : } aria-label={isPaused ? "Play" : "Pause"} onClick={() => setIsPaused(!isPaused)} borderRadius="full" boxShadow="md" color="gray.50" backgroundColor="blackAlpha.700" position="absolute" bottom="2" left="2" _hover={{ backgroundColor: "blackAlpha.900" }} _focus={{ backgroundColor: "blackAlpha.900" }} /> )} { setPetState({ speciesId: species.id, colorId: color.id, pose: closestPose, appearanceId: null, }); }} size="sm" showPlaceholders // This is just a UX affordance: while we could handle invalid states // from a UI perspective, we figure that, if a pet preview is already // visible and responsive to changes, it feels better to treat the // changes as atomic and always-valid. stateMustAlwaysBeValid /> {isIncompatible && ( )} ); } /** * 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 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! */ 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 ( ); } export default ItemPage;