import React from "react"; import { useQuery } from "@apollo/client"; import gql from "graphql-tag"; import { AspectRatio, Box, Button, Flex, Grid, IconButton, Tooltip, useColorModeValue, usePrefersReducedMotion, } from "@chakra-ui/react"; import { EditIcon, WarningIcon } from "@chakra-ui/icons"; import { MdPause, MdPlayArrow } from "react-icons/md"; import HTML5Badge, { layerUsesHTML5 } from "./components/HTML5Badge"; import SpeciesColorPicker, { useAllValidPetPoses, getValidPoses, getClosestPose, } from "./components/SpeciesColorPicker"; import SpeciesFacesPicker, { colorIsBasic, } from "./ItemPage/SpeciesFacesPicker"; import { itemAppearanceFragment, petAppearanceFragment, } from "./components/useOutfitAppearance"; import { useOutfitPreview } from "./components/OutfitPreview"; import { logAndCapture, useLocalStorage } from "./util"; import { useItemAppearances } from "./loaders/items"; 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, isValid: false, // 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, }); const [preferredSpeciesId, setPreferredSpeciesId] = useLocalStorage( "DTIItemPreviewPreferredSpeciesId", null, ); const [preferredColorId, setPreferredColorId] = useLocalStorage( "DTIItemPreviewPreferredColorId", null, ); const setPetStateFromUserAction = React.useCallback( (newPetState) => setPetState((prevPetState) => { // When the user _intentionally_ chooses a species or color, save it in // local storage for next time. (This won't update when e.g. their // preferred species or color isn't available for this item, so we update // to the canonical species or color automatically.) // // Re the "ifs", I have no reason to expect null to come in here, but, // since this is touching client-persisted data, I want it to be even more // reliable than usual! if ( newPetState.speciesId && newPetState.speciesId !== prevPetState.speciesId ) { setPreferredSpeciesId(newPetState.speciesId); } if ( newPetState.colorId && newPetState.colorId !== prevPetState.colorId ) { if (colorIsBasic(newPetState.colorId)) { // When the user chooses a basic color, don't index on it specifically, // and instead reset to use default colors. setPreferredColorId(null); } else { setPreferredColorId(newPetState.colorId); } } return newPetState; }), [setPreferredColorId, setPreferredSpeciesId], ); // We don't need to reload this query when preferred species/color change, so // cache their initial values here to use as query arguments. const [initialPreferredSpeciesId] = React.useState(preferredSpeciesId); const [initialPreferredColorId] = React.useState(preferredColorId); const { data: itemAppearancesData, loading: loadingAppearances, error: errorAppearances, } = useItemAppearances(itemId); const itemAppearances = itemAppearancesData || []; // 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. // // If the user has a preferred species saved from using the ItemPage in the // past, we'll send that instead. This will return the appearance on that // species if possible, or the default canonical species if not. // // 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: loadingGQL, error: errorGQL, data, } = useQuery( gql` query ItemPageOutfitPreview( $itemId: ID! $preferredSpeciesId: ID $preferredColorId: ID ) { item(id: $itemId) { id name restrictedZones { id label } canonicalAppearance( preferredSpeciesId: $preferredSpeciesId preferredColorId: $preferredColorId ) { id ...ItemAppearanceForOutfitPreview body { id canonicalAppearance(preferredColorId: $preferredColorId) { id species { id name } color { id } pose ...PetAppearanceForOutfitPreview } } } } } ${itemAppearanceFragment} ${petAppearanceFragment} `, { variables: { itemId, preferredSpeciesId: initialPreferredSpeciesId, preferredColorId: initialPreferredColorId, }, onCompleted: (data) => { const canonicalBody = data?.item?.canonicalAppearance?.body; const canonicalPetAppearance = canonicalBody?.canonicalAppearance; setPetState({ speciesId: canonicalPetAppearance?.species?.id, colorId: canonicalPetAppearance?.color?.id, pose: canonicalPetAppearance?.pose, isValid: true, appearanceId: canonicalPetAppearance?.id, }); }, }, ); const compatibleBodies = itemAppearances?.map(({ body }) => body) || []; // If there's only one compatible body, and the canonical species's name // appears in the item name, then this is probably a species-specific item, // and we should adjust the UI to avoid implying that other species could // model it. const isProbablySpeciesSpecific = compatibleBodies.length === 1 && compatibleBodies[0] !== "all" && (data?.item?.name || "") .toLowerCase() .includes( data?.item?.canonicalAppearance?.body?.canonicalAppearance?.species?.name.toLowerCase(), ); const couldProbablyModelMoreData = !isProbablySpeciesSpecific; // TODO: Does this double-trigger the HTTP request with SpeciesColorPicker? const { loading: loadingValids, error: errorValids, valids, } = useAllValidPetPoses(); const [hasAnimations, setHasAnimations] = React.useState(false); const [isPaused, setIsPaused] = useLocalStorage("DTIOutfitIsPaused", true); // This is like , but we can use the appearance data, too! const { appearance, preview } = useOutfitPreview({ speciesId: petState.speciesId, colorId: petState.colorId, pose: petState.pose, appearanceId: petState.appearanceId, wornItemIds: [itemId], isLoading: loadingGQL || loadingValids, spinnerVariant: "corner", engine: "canvas", onChangeHasAnimations: setHasAnimations, }); // If there's an appearance loaded for this item, but it's empty, then the // item is incompatible. (There should only be one item appearance: this one!) const itemAppearance = appearance?.itemAppearances?.[0]; const itemLayers = itemAppearance?.layers || []; const isCompatible = itemLayers.length > 0; const usesHTML5 = itemLayers.every(layerUsesHTML5); const onChange = React.useCallback( ({ speciesId, colorId }) => { const validPoses = getValidPoses(valids, speciesId, colorId); const pose = getClosestPose(validPoses, idealPose); setPetStateFromUserAction({ speciesId, colorId, pose, isValid: true, appearanceId: null, }); }, [valids, idealPose, setPetStateFromUserAction], ); const borderColor = useColorModeValue("green.700", "green.400"); const errorColor = useColorModeValue("red.600", "red.400"); const error = errorGQL || errorAppearances || errorValids; if (error) { return {error.message}; } return ( {petState.isValid && preview} {hasAnimations && ( setIsPaused(!isPaused)} /> )} { setPetStateFromUserAction({ speciesId: species.id, colorId: color.id, pose: closestPose, isValid, appearanceId: null, }); }} speciesIsDisabled={isProbablySpeciesSpecific} size="sm" showPlaceholders /> { // Wait for us to start _requesting_ the appearance, and _then_ // for it to load, and _then_ check compatibility. !loadingGQL && !loadingAppearances && !appearance.loading && petState.isValid && !isCompatible && ( ) } {itemAppearances.length > 0 && ( )} ); } function CustomizeMoreButton({ speciesId, colorId, pose, itemId, isDisabled }) { const url = `/outfits/new?species=${speciesId}&color=${colorId}&pose=${pose}&` + `objects[]=${itemId}`; // The default background is good in light mode, but in dark mode it's a // very subtle transparent white... make it a semi-transparent black, for // better contrast against light-colored background items! const backgroundColor = useColorModeValue(undefined, "blackAlpha.700"); const backgroundColorHover = useColorModeValue(undefined, "blackAlpha.900"); return ( Customize more ); } function LinkOrButton({ href, ...props }) { if (href != null) { return