import React from "react"; import { ClassNames } from "@emotion/react"; import { AspectRatio, Button, Box, HStack, IconButton, SkeletonText, Tooltip, VisuallyHidden, VStack, useBreakpointValue, useColorModeValue, useTheme, useToast, useToken, Stack, Wrap, WrapItem, } from "@chakra-ui/react"; import { CheckIcon, ChevronRightIcon, StarIcon, WarningIcon, } from "@chakra-ui/icons"; import { MdPause, MdPlayArrow } from "react-icons/md"; import gql from "graphql-tag"; import { useQuery, useMutation } from "@apollo/client"; import { Link, useParams } from "react-router-dom"; import ItemPageLayout, { SubtleSkeleton } from "./ItemPageLayout"; import { Delay, ErrorMessage, usePageTitle } from "./util"; import { itemAppearanceFragment, petAppearanceFragment, } from "./components/useOutfitAppearance"; import OutfitPreview from "./components/OutfitPreview"; import SpeciesColorPicker from "./components/SpeciesColorPicker"; import useCurrentUser from "./components/useCurrentUser"; import { useLocalStorage } from "./util"; 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 }) { const { isLoggedIn } = useCurrentUser(); const { error, data } = useQuery( gql` query ItemPage($itemId: ID!) { item(id: $itemId) { id name isNc isPb thumbnailUrl description createdAt } } `, { variables: { itemId }, returnPartialData: true } ); usePageTitle(data?.item?.name, { skip: isEmbedded }); if (error) { return {error.message}; } const item = data?.item; return ( {isLoggedIn && } {!isEmbedded && } ); } function ItemPageDescription({ description, 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; return ( {description || ( )} ); } function ItemPageOwnWantButtons({ itemId }) { const { loading, error, data } = useQuery( gql` query ItemPageOwnWantButtons($itemId: ID!) { item(id: $itemId) { id currentUserOwnsThis currentUserWantsThis } } `, { variables: { itemId }, context: { sendAuth: true } } ); if (error) { return {error.message}; } return ( ); } function ItemPageOwnButton({ itemId, isChecked }) { const theme = useTheme(); const toast = useToast(); const [sendAddMutation] = useMutation( gql` mutation ItemPageOwnButtonAdd($itemId: ID!) { addToItemsCurrentUserOwns(itemId: $itemId) { id currentUserOwnsThis } } `, { variables: { itemId }, context: { sendAuth: true }, optimisticResponse: { __typename: "Mutation", addToItemsCurrentUserOwns: { __typename: "Item", id: itemId, currentUserOwnsThis: true, }, }, } ); const [sendRemoveMutation] = useMutation( gql` mutation ItemPageOwnButtonRemove($itemId: ID!) { removeFromItemsCurrentUserOwns(itemId: $itemId) { id currentUserOwnsThis } } `, { variables: { itemId }, context: { sendAuth: true }, optimisticResponse: { __typename: "Mutation", removeFromItemsCurrentUserOwns: { __typename: "Item", id: itemId, currentUserOwnsThis: false, }, }, } ); return ( {({ css }) => ( { if (e.target.checked) { sendAddMutation().catch((e) => { console.error(e); toast({ title: "We had trouble adding this to the items you own.", description: "Check your internet connection, and try again.", status: "error", duration: 5000, }); }); } else { sendRemoveMutation().catch((e) => { console.error(e); toast({ title: "We had trouble removing this from the items you own.", description: "Check your internet connection, and try again.", status: "error", duration: 5000, }); }); } }} /> )} ); } function ItemPageWantButton({ itemId, isChecked }) { const theme = useTheme(); const toast = useToast(); const [sendAddMutation] = useMutation( gql` mutation ItemPageWantButtonAdd($itemId: ID!) { addToItemsCurrentUserWants(itemId: $itemId) { id currentUserWantsThis } } `, { variables: { itemId }, context: { sendAuth: true }, optimisticResponse: { __typename: "Mutation", addToItemsCurrentUserWants: { __typename: "Item", id: itemId, currentUserWantsThis: true, }, }, } ); const [sendRemoveMutation] = useMutation( gql` mutation ItemPageWantButtonRemove($itemId: ID!) { removeFromItemsCurrentUserWants(itemId: $itemId) { id currentUserWantsThis } } `, { variables: { itemId }, context: { sendAuth: true }, optimisticResponse: { __typename: "Mutation", removeFromItemsCurrentUserWants: { __typename: "Item", id: itemId, currentUserWantsThis: false, }, }, } ); return ( {({ css }) => ( { if (e.target.checked) { sendAddMutation().catch((e) => { console.error(e); toast({ title: "We had trouble adding this to the items you want.", description: "Check your internet connection, and try again.", status: "error", duration: 5000, }); }); } else { sendRemoveMutation().catch((e) => { console.error(e); toast({ title: "We had trouble removing this from the items you want.", description: "Check your internet connection, and try again.", status: "error", duration: 5000, }); }); } }} /> )} ); } function ItemPageTradeLinks({ itemId, isEmbedded }) { const { data, loading, error } = useQuery( gql` query ItemPageTradeLinks($itemId: ID!) { item(id: $itemId) { id numUsersOfferingThis numUsersSeekingThis } } `, { variables: { itemId } } ); if (error) { return {error.message}; } return ( Trading: ); } function ItemPageTradeLink({ href, count, label, colorScheme, isEmbedded }) { return ( ); } 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 && ( setIsPaused(!isPaused)} /> )} { 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 && ( )} setPetState({ speciesId, colorId, pose: idealPose, appearanceId: null, }) } isLoading={loading} /> ); } function PlayPauseButton({ isPaused, onClick }) { return ( : } aria-label={isPaused ? "Play" : "Pause"} onClick={onClick} borderRadius="full" boxShadow="md" color="gray.50" backgroundColor="blackAlpha.700" position="absolute" bottom="2" left="2" _hover={{ backgroundColor: "blackAlpha.900" }} _focus={{ backgroundColor: "blackAlpha.900" }} /> ); } function SpeciesFacesPicker({ itemId, selectedSpeciesId, onChange, isLoading, }) { const selectedBorderColor = useColorModeValue("green.600", "green.400"); const selectedBackgroundColor = useColorModeValue("green.200", "green.600"); const [ selectedBorderColorValue, selectedBackgroundColorValue, ] = useToken("colors", [selectedBorderColor, selectedBackgroundColor]); const allSpeciesFaces = speciesFaces.sort((a, b) => a.speciesName.localeCompare(b.speciesName) ); return ( {({ css }) => ( {allSpeciesFaces.map(({ speciesId, speciesName, colorId, src }) => ( onChange({ speciesId, colorId })} /> & { opacity: 1; filter: saturate(110%); } `} /> ))} )} ); } // HACK: I'm just hardcoding all this, rather than connecting up to the // database and adding a loading state. Tbh I'm not sure it's a good idea // to load this dynamically until we have SSR to make it come in fast! // And it's not so bad if this gets out of sync with the database, // because the SpeciesColorPicker will still be usable! const colors = { BLUE: "8", RED: "61", GREEN: "34", YELLOW: "84" }; const speciesFaces = [ { speciesId: "1", src: "https://pets.neopets-asset-proxy.openneo.net/cp/obxdjm88/1/1.png", colorId: colors.GREEN, speciesName: "Acara", }, { speciesId: "2", src: "https://pets.neopets-asset-proxy.openneo.net/cp/n9ozx4z5/1/1.png", colorId: colors.BLUE, speciesName: "Aisha", }, { speciesId: "3", src: "https://pets.neopets-asset-proxy.openneo.net/cp/kfonqhdc/1/1.png", colorId: colors.YELLOW, speciesName: "Blumaroo", }, { speciesId: "4", src: "https://pets.neopets-asset-proxy.openneo.net/cp/sc2hhvhn/1/1.png", colorId: colors.YELLOW, speciesName: "Bori", }, { speciesId: "5", src: "https://pets.neopets-asset-proxy.openneo.net/cp/wqz8xn4t/1/1.png", colorId: colors.YELLOW, speciesName: "Bruce", }, { speciesId: "6", src: "https://pets.neopets-asset-proxy.openneo.net/cp/jc9klfxm/1/1.png", colorId: colors.YELLOW, speciesName: "Buzz", }, { speciesId: "7", src: "https://pets.neopets-asset-proxy.openneo.net/cp/4lrb4n3f/1/1.png", colorId: colors.RED, speciesName: "Chia", }, { speciesId: "8", src: "https://pets.neopets-asset-proxy.openneo.net/cp/bdml26md/1/1.png", colorId: colors.YELLOW, speciesName: "Chomby", }, { speciesId: "9", src: "https://pets.neopets-asset-proxy.openneo.net/cp/xl6msllv/1/1.png", colorId: colors.GREEN, speciesName: "Cybunny", }, { speciesId: "10", src: "https://pets.neopets-asset-proxy.openneo.net/cp/bob39shq/1/1.png", colorId: colors.YELLOW, speciesName: "Draik", }, { speciesId: "11", src: "https://pets.neopets-asset-proxy.openneo.net/cp/jhhhbrww/1/1.png", colorId: colors.RED, speciesName: "Elephante", }, { speciesId: "12", src: "https://pets.neopets-asset-proxy.openneo.net/cp/6kngmhvs/1/1.png", colorId: colors.RED, speciesName: "Eyrie", }, { speciesId: "13", src: "https://pets.neopets-asset-proxy.openneo.net/cp/47vt32x2/1/1.png", colorId: colors.GREEN, speciesName: "Flotsam", }, { speciesId: "14", src: "https://pets.neopets-asset-proxy.openneo.net/cp/5nrd2lvd/1/1.png", colorId: colors.YELLOW, speciesName: "Gelert", }, { speciesId: "15", src: "https://pets.neopets-asset-proxy.openneo.net/cp/6c275jcg/1/1.png", colorId: colors.BLUE, speciesName: "Gnorbu", }, { speciesId: "16", src: "https://pets.neopets-asset-proxy.openneo.net/cp/j7q65fv4/1/1.png", colorId: colors.BLUE, speciesName: "Grarrl", }, { speciesId: "17", src: "https://pets.neopets-asset-proxy.openneo.net/cp/5xn4kjf8/1/1.png", colorId: colors.GREEN, speciesName: "Grundo", }, { speciesId: "18", src: "https://pets.neopets-asset-proxy.openneo.net/cp/jsfvcqwt/1/1.png", colorId: colors.RED, speciesName: "Hissi", }, { speciesId: "19", src: "https://pets.neopets-asset-proxy.openneo.net/cp/w32r74vo/1/1.png", colorId: colors.GREEN, speciesName: "Ixi", }, { speciesId: "20", src: "https://pets.neopets-asset-proxy.openneo.net/cp/kz43rnld/1/1.png", colorId: colors.YELLOW, speciesName: "Jetsam", }, { speciesId: "21", src: "https://pets.neopets-asset-proxy.openneo.net/cp/m267j935/1/1.png", colorId: colors.GREEN, speciesName: "Jubjub", }, { speciesId: "22", src: "https://pets.neopets-asset-proxy.openneo.net/cp/4gsrb59g/1/1.png", colorId: colors.YELLOW, speciesName: "Kacheek", }, { speciesId: "23", src: "https://pets.neopets-asset-proxy.openneo.net/cp/ktlxmrtr/1/1.png", colorId: colors.BLUE, speciesName: "Kau", }, { speciesId: "24", src: "https://pets.neopets-asset-proxy.openneo.net/cp/42j5q3zx/1/1.png", colorId: colors.GREEN, speciesName: "Kiko", }, { speciesId: "25", src: "https://pets.neopets-asset-proxy.openneo.net/cp/ncfn87wk/1/1.png", colorId: colors.GREEN, speciesName: "Koi", }, { speciesId: "26", src: "https://pets.neopets-asset-proxy.openneo.net/cp/omx9c876/1/1.png", colorId: colors.RED, speciesName: "Korbat", }, { speciesId: "27", src: "https://pets.neopets-asset-proxy.openneo.net/cp/rfsbh59t/1/1.png", colorId: colors.BLUE, speciesName: "Kougra", }, { speciesId: "28", src: "https://pets.neopets-asset-proxy.openneo.net/cp/hxgsm5d4/1/1.png", colorId: colors.BLUE, speciesName: "Krawk", }, { speciesId: "29", src: "https://pets.neopets-asset-proxy.openneo.net/cp/blxmjgbk/1/1.png", colorId: colors.YELLOW, speciesName: "Kyrii", }, { speciesId: "30", src: "https://pets.neopets-asset-proxy.openneo.net/cp/8r94jhfq/1/1.png", colorId: colors.YELLOW, speciesName: "Lenny", }, { speciesId: "31", src: "https://pets.neopets-asset-proxy.openneo.net/cp/z42535zh/1/1.png", colorId: colors.YELLOW, speciesName: "Lupe", }, { speciesId: "32", src: "https://pets.neopets-asset-proxy.openneo.net/cp/qgg6z8s7/1/1.png", colorId: colors.BLUE, speciesName: "Lutari", }, { speciesId: "33", src: "https://pets.neopets-asset-proxy.openneo.net/cp/kk2nn2jr/1/1.png", colorId: colors.YELLOW, speciesName: "Meerca", }, { speciesId: "34", src: "https://pets.neopets-asset-proxy.openneo.net/cp/jgkoro5z/1/1.png", colorId: colors.GREEN, speciesName: "Moehog", }, { speciesId: "35", src: "https://pets.neopets-asset-proxy.openneo.net/cp/xwlo9657/1/1.png", colorId: colors.BLUE, speciesName: "Mynci", }, { speciesId: "36", src: "https://pets.neopets-asset-proxy.openneo.net/cp/bx7fho8x/1/1.png", colorId: colors.BLUE, speciesName: "Nimmo", }, { speciesId: "37", src: "https://pets.neopets-asset-proxy.openneo.net/cp/rjzmx24v/1/1.png", colorId: colors.YELLOW, speciesName: "Ogrin", }, { speciesId: "38", src: "https://pets.neopets-asset-proxy.openneo.net/cp/kokc52kh/1/1.png", colorId: colors.RED, speciesName: "Peophin", }, { speciesId: "39", src: "https://pets.neopets-asset-proxy.openneo.net/cp/fw6lvf3c/1/1.png", colorId: colors.GREEN, speciesName: "Poogle", }, { speciesId: "40", src: "https://pets.neopets-asset-proxy.openneo.net/cp/tjhwbro3/1/1.png", colorId: colors.RED, speciesName: "Pteri", }, { speciesId: "41", src: "https://pets.neopets-asset-proxy.openneo.net/cp/jdto7mj4/1/1.png", colorId: colors.YELLOW, speciesName: "Quiggle", }, { speciesId: "42", src: "https://pets.neopets-asset-proxy.openneo.net/cp/qsgbm5f6/1/1.png", colorId: colors.BLUE, speciesName: "Ruki", }, { speciesId: "43", src: "https://pets.neopets-asset-proxy.openneo.net/cp/hkjoncsx/1/1.png", colorId: colors.RED, speciesName: "Scorchio", }, { speciesId: "44", src: "https://pets.neopets-asset-proxy.openneo.net/cp/mmvn4tkg/1/1.png", colorId: colors.YELLOW, speciesName: "Shoyru", }, { speciesId: "45", src: "https://pets.neopets-asset-proxy.openneo.net/cp/fc4cxk3t/1/1.png", colorId: colors.RED, speciesName: "Skeith", }, { speciesId: "46", src: "https://pets.neopets-asset-proxy.openneo.net/cp/84gvowmj/1/1.png", colorId: colors.YELLOW, speciesName: "Techo", }, { speciesId: "47", src: "https://pets.neopets-asset-proxy.openneo.net/cp/jd433863/1/1.png", colorId: colors.BLUE, speciesName: "Tonu", }, { speciesId: "48", src: "https://pets.neopets-asset-proxy.openneo.net/cp/q39wn6vq/1/1.png", colorId: colors.YELLOW, speciesName: "Tuskaninny", }, { speciesId: "49", src: "https://pets.neopets-asset-proxy.openneo.net/cp/njzvoflw/1/1.png", colorId: colors.GREEN, speciesName: "Uni", }, { speciesId: "50", src: "https://pets.neopets-asset-proxy.openneo.net/cp/rox4mgh5/1/1.png", colorId: colors.RED, speciesName: "Usul", }, { speciesId: "51", src: "https://pets.neopets-asset-proxy.openneo.net/cp/dnr2kj4b/1/1.png", colorId: colors.YELLOW, speciesName: "Wocky", }, { speciesId: "52", src: "https://pets.neopets-asset-proxy.openneo.net/cp/tdkqr2b6/1/1.png", colorId: colors.RED, speciesName: "Xweetok", }, { speciesId: "53", src: "https://pets.neopets-asset-proxy.openneo.net/cp/h95cs547/1/1.png", colorId: colors.RED, speciesName: "Yurble", }, { speciesId: "54", src: "https://pets.neopets-asset-proxy.openneo.net/cp/x8c57g2l/1/1.png", colorId: colors.BLUE, speciesName: "Zafara", }, { speciesId: "55", src: "https://pets.neopets-asset-proxy.openneo.net/cp/xkntzsww/1/1.png", colorId: colors.YELLOW, speciesName: "Vandagyre", }, ]; export default ItemPage;