import React from "react"; import { ClassNames } from "@emotion/react"; import gql from "graphql-tag"; import { Box, Button, Center, Flex, FormControl, FormHelperText, FormLabel, HStack, IconButton, Input, InputGroup, InputLeftElement, InputRightElement, Link as ChakraLink, ListItem, Popover, PopoverArrow, PopoverBody, PopoverCloseButton, PopoverContent, PopoverTrigger, Skeleton, Switch, Textarea, Tooltip, UnorderedList, useColorModeValue, useTheme, useToast, VStack, } from "@chakra-ui/react"; import { ArrowForwardIcon, SearchIcon } from "@chakra-ui/icons"; import { useLazyQuery, useQuery } from "@apollo/client"; import Link from "next/link"; import { useRouter } from "next/router"; import Image from "next/image"; import { Delay, ErrorMessage, Heading1, Heading2, TestErrorSender, useCommonStyles, useLocalStorage, } from "./util"; import OutfitPreview from "./components/OutfitPreview"; import SpeciesColorPicker from "./components/SpeciesColorPicker"; import SquareItemCard, { SquareItemCardSkeleton, } from "./components/SquareItemCard"; import WIPCallout from "./components/WIPCallout"; import { useAuthModeFeatureFlag } from "./components/useCurrentUser"; import HomepageSplashImg from "./images/homepage-splash.png"; import FeedbackKikoImg from "./images/feedback-kiko.png"; function HomePage() { useSupportSetup(); const [previewState, setPreviewState] = React.useState(null); return ( <Flex direction="column" align="center" textAlign="center" marginTop="8"> <Box height="4" /> <Box width="200px" height="200px" borderRadius="lg" boxShadow="md" overflow="hidden" > <OutfitPreview speciesId={previewState?.speciesId} colorId={previewState?.colorId} pose={previewState?.pose} wornItemIds={[]} loadingDelayMs={1500} placeholder={ <Image src={HomepageSplashImg} width={200} height={200} alt="" layout="fixed" /> } /> </Box> <Box height="4" /> <Heading1>Dress to Impress</Heading1> <Box fontSize="lg" fontStyle="italic" opacity="0.85" role="doc-subtitle"> Design and share your Neopets outfits! </Box> <Box height="8" /> <StartOutfitForm onChange={setPreviewState} /> <Box height="4" /> <Box fontStyle="italic" fontSize="sm"> or </Box> <Box height="4" /> <SubmitPetForm /> <Box height="16" /> <NewItemsSection /> <Box height="16" /> <FeedbackFormSection /> <Box height="16" /> <WIPCallout details="We started building this in 2020, but, well… sheesh, what a time 😅 This will eventually become the main site, at impress.openneo.net!"> Maybe we'll rename it to Impress 2022… or maybe not! 🤔 </WIPCallout> <TestErrorSender /> </Flex> ); } function StartOutfitForm({ onChange }) { const { push: pushHistory } = useRouter(); const idealPose = React.useMemo( () => (Math.random() > 0.5 ? "HAPPY_FEM" : "HAPPY_MASC"), [] ); const [speciesId, setSpeciesId] = React.useState("1"); const [colorId, setColorId] = React.useState("8"); const [isValid, setIsValid] = React.useState(true); const [closestPose, setClosestPose] = React.useState(idealPose); const onSubmit = (e) => { e.preventDefault(); if (!isValid) { return; } const params = new URLSearchParams({ species: speciesId, color: colorId, pose: closestPose, }); pushHistory(`/outfits/new?${params}`); }; const buttonBgColor = useColorModeValue("green.600", "green.300"); const buttonBgColorHover = useColorModeValue("green.700", "green.200"); return ( <form onSubmit={onSubmit}> <Flex> <SpeciesColorPicker speciesId={speciesId} colorId={colorId} idealPose={idealPose} showPlaceholders colorPlaceholderText="Blue" speciesPlaceholderText="Acara" onChange={(species, color, isValid, closestPose) => { setSpeciesId(species.id); setColorId(color.id); setIsValid(isValid); setClosestPose(closestPose); if (isValid) { onChange({ speciesId: species.id, colorId: color.id, pose: closestPose, }); } }} /> <Box width="4" /> <Button type="submit" colorScheme="green" disabled={!isValid} backgroundColor={buttonBgColor} _hover={{ backgroundColor: buttonBgColorHover }} > Start </Button> </Flex> </form> ); } function SubmitPetForm() { const { query, push: pushHistory } = useRouter(); const theme = useTheme(); const toast = useToast(); const [petName, setPetName] = React.useState(""); const [loadPetQuery, { loading }] = useLazyQuery( gql` query SubmitPetForm($petName: String!) { petOnNeopetsDotCom(petName: $petName) { petAppearance { color { id } species { id } pose } wornItems { id } } } `, { fetchPolicy: "network-only", onCompleted: (data) => { if (!data) return; const { petAppearance, wornItems } = data.petOnNeopetsDotCom; if (petAppearance == null) { toast({ title: "This pet exists, but is in a glitchy state on Neopets.com.", description: "Hopefully it gets fixed soon! If this doesn't sound right to you, contact us and let us know!", status: "error", }); return; } const { species, color, pose } = petAppearance; const params = new URLSearchParams({ name: petName, species: species.id, color: color.id, pose, }); for (const item of wornItems) { params.append("objects[]", item.id); } pushHistory(`/outfits/new?${params}`); }, onError: () => { toast({ title: "We couldn't load that pet, sorry 😓", description: "Is it spelled correctly?", status: "error", }); }, } ); const loadPet = React.useCallback( (petName) => { loadPetQuery({ variables: { petName } }); // Start preloading the WardrobePage, too! // eslint-disable-next-line no-unused-expressions import("./WardrobePage").catch((e) => { // Let's just let this slide, because it's a preload error. Critical // failures will happen elsewhere, and trigger reloads! console.error(e); }); }, [loadPetQuery] ); // If the ?loadPet= query param is provided, auto-load the pet immediately. // This isn't used in-app, but is a helpful hook for things like link-ins and // custom search engines. (I feel like a route or a different UX would be // better, but I don't really know enough to commit to one… let's just keep // this simple for now, I think. We might change this someday!) const autoLoadPetName = query.loadPet; React.useEffect(() => { if (autoLoadPetName != null) { setPetName(autoLoadPetName); loadPet(autoLoadPetName); } }, [autoLoadPetName, loadPet]); const { brightBackground } = useCommonStyles(); const inputBorderColor = useColorModeValue("green.600", "green.500"); const inputBorderColorHover = useColorModeValue("green.400", "green.300"); const buttonBgColor = useColorModeValue("green.600", "green.300"); const buttonBgColorHover = useColorModeValue("green.700", "green.200"); return ( <ClassNames> {({ css }) => ( <form onSubmit={(e) => { e.preventDefault(); loadPet(petName); }} > <Flex> <Input value={petName} onChange={(e) => setPetName(e.target.value)} isDisabled={loading} placeholder="Enter a pet's name" aria-label="Enter a pet's name" borderColor={inputBorderColor} _hover={{ borderColor: inputBorderColorHover }} background={brightBackground} boxShadow="md" width="14em" className={css` &::placeholder { color: ${theme.colors.gray["500"]}; } `} /> <Box width="4" /> <Button type="submit" colorScheme="green" isDisabled={!petName} isLoading={loading} backgroundColor={buttonBgColor} // for AA contrast _hover={{ backgroundColor: buttonBgColorHover }} > Start </Button> </Flex> </form> )} </ClassNames> ); } function NewItemsSection() { return ( <Box width="100%"> <Flex align="center" wrap="wrap"> <Heading2 flex="0 0 auto" marginRight="2" textAlign="left"> Latest items </Heading2> <Box flex="0 0 auto" marginLeft="auto" width="48"> <ItemsSearchField /> </Box> </Flex> <NewItemsSectionContent /> </Box> ); } function ItemsSearchField() { const [query, setQuery] = React.useState(""); const { brightBackground } = useCommonStyles(); const { push: pushHistory } = useRouter(); return ( <form onSubmit={(e) => { e.preventDefault(); if (query) { pushHistory(`/items/search/${encodeURIComponent(query)}`); } }} > <InputGroup size="sm"> <InputLeftElement> <Link href="/items/search" passHref> <Box as="a" display="flex"> <SearchIcon color="gray.400" /> </Box> </Link> </InputLeftElement> <Input value={query} backgroundColor={query ? brightBackground : "transparent"} _focus={{ backgroundColor: brightBackground }} onChange={(e) => setQuery(e.target.value)} placeholder="Search all items…" borderRadius="full" /> <InputRightElement> <IconButton type="submit" variant="ghost" icon={<ArrowForwardIcon />} aria-label="Search" minWidth="1.5rem" minHeight="1.5rem" width="1.5rem" height="1.5rem" borderRadius="full" opacity={query ? 1 : 0} transition="opacity 0.2s" aria-hidden={query ? "false" : "true"} /> </InputRightElement> </InputGroup> </form> ); } function NewItemsSectionContent() { const { loading, error, data } = useQuery( gql` query NewItemsSection { newestItems { id name thumbnailUrl isNc isPb speciesThatNeedModels { id name } babySpeciesThatNeedModels: speciesThatNeedModels(colorId: "6") { id name } maraquanSpeciesThatNeedModels: speciesThatNeedModels(colorId: "44") { id name } mutantSpeciesThatNeedModels: speciesThatNeedModels(colorId: "46") { id name } compatibleBodiesAndTheirZones { body { id representsAllBodies species { id name } canonicalAppearance { id color { id name isStandard } } } } } } ` ); const { data: userData } = useQuery( gql` query NewItemsSection_UserData { newestItems { id currentUserOwnsThis currentUserWantsThis } } `, { context: { sendAuth: true }, onError: (e) => console.error("Error loading NewItemsSection_UserData, skipping:", e), } ); if (loading) { const footer = ( <Center fontSize="xs" height="1.5em"> <Skeleton height="4px" width="100%" /> </Center> ); return ( <Delay> <ItemCardHStack> <SquareItemCardSkeleton footer={footer} /> <SquareItemCardSkeleton footer={footer} minHeightNumLines={3} /> <SquareItemCardSkeleton footer={footer} /> <SquareItemCardSkeleton footer={footer} /> <SquareItemCardSkeleton footer={footer} minHeightNumLines={3} /> <SquareItemCardSkeleton footer={footer} /> <SquareItemCardSkeleton footer={footer} minHeightNumLines={3} /> <SquareItemCardSkeleton footer={footer} /> <SquareItemCardSkeleton footer={footer} /> <SquareItemCardSkeleton footer={footer} /> <SquareItemCardSkeleton footer={footer} /> <SquareItemCardSkeleton footer={footer} minHeightNumLines={3} /> <SquareItemCardSkeleton footer={footer} /> <SquareItemCardSkeleton footer={footer} /> <SquareItemCardSkeleton footer={footer} minHeightNumLines={3} /> <SquareItemCardSkeleton footer={footer} /> <SquareItemCardSkeleton footer={footer} minHeightNumLines={3} /> <SquareItemCardSkeleton footer={footer} /> <SquareItemCardSkeleton footer={footer} /> <SquareItemCardSkeleton footer={footer} /> </ItemCardHStack> </Delay> ); } if (error) { return ( <ErrorMessage> Couldn't load new items. Check your connection and try again! </ErrorMessage> ); } // Merge in the results from the user data query, if available. const newestItems = data.newestItems.map((item) => { const itemUserData = (userData?.newestItems || []).find((i) => i.id === item.id) || {}; return { ...item, ...itemUserData }; }); return ( <ItemCardHStack> {newestItems.map((item) => ( <SquareItemCard key={item.id} item={item} footer={<ItemModelingSummary item={item} />} /> ))} </ItemCardHStack> ); } function ItemModelingSummary({ item }) { // NOTE: To test this logic, I like to swap out `newestItems` in the query: // `newestItems: items(ids: ["81546", "35082", "75149", "81797", "58741", "78953", "82427", "82727", "82726"])` const numModelsNeeded = item.speciesThatNeedModels.length + item.babySpeciesThatNeedModels.length + item.maraquanSpeciesThatNeedModels.length + item.mutantSpeciesThatNeedModels.length; if (numModelsNeeded > 0) { return ( <Box fontSize="xs" fontStyle="italic" fontWeight="600" opacity="0.8"> Need {numModelsNeeded} models </Box> ); } const bodies = item.compatibleBodiesAndTheirZones.map((bz) => bz.body); const fitsAllPets = bodies.some((b) => b.representsAllBodies); if (fitsAllPets) { return ( <Box fontSize="xs" fontStyle="italic" opacity="0.8"> Fits all pets </Box> ); } // HACK: The Maraquan Mynci and the Blue Mynci have the same body, so to test // whether something is *meant* for standard colors, we check for more // than const standardBodies = bodies.filter( (b) => b.canonicalAppearance.color.isStandard ); const isMeantForStandardBodies = standardBodies.length >= 2; const colors = bodies.map((b) => b.canonicalAppearance.color); const specialColor = colors.find((c) => !c.isStandard); const hasSpecialColorOnly = !isMeantForStandardBodies && specialColor != null; if (hasSpecialColorOnly && bodies.length === 1) { return ( <Box fontSize="xs" fontStyle="italic" opacity="0.8"> {specialColor.name} {bodies[0].species.name} only </Box> ); } if (bodies.length === 1) { return ( <Box fontSize="xs" fontStyle="italic" opacity="0.8"> {bodies[0].species.name} only </Box> ); } if (hasSpecialColorOnly) { return ( <Box fontSize="xs" fontStyle="italic" opacity="0.8"> {specialColor.name} only </Box> ); } return ( <Box fontSize="xs" fontStyle="italic" opacity="0.8"> Fits all{" "} <Tooltip label={ <Box fontSize="xs" textAlign="center"> Not special colors like Baby, Maraquan, or Mutant. </Box> } > <Box display="inline-block" borderBottom="1px dotted" tabIndex="0"> basic </Box> </Tooltip>{" "} pets </Box> ); } function ItemCardHStack({ children }) { return ( // HACK: I wanted to just have an HStack with overflow:auto and internal // paddingX, but the right-hand-side padding didn't seem to work // during overflow. This was the best I could come up with... <Flex maxWidth="100%" overflow="auto" paddingY="4"> <Box minWidth="2" /> <HStack align="flex-start" spacing="4"> {children} </HStack> <Box minWidth="2" /> </Flex> ); } function FeedbackFormSection() { const { brightBackground } = useCommonStyles(); const pitchBorderColor = useColorModeValue("gray.300", "green.400"); const formBorderColor = useColorModeValue("gray.300", "blue.400"); return ( <VStack spacing="4" alignItems="stretch"> <FeedbackFormContainer background={brightBackground} borderColor={pitchBorderColor} > <Flex> <Box padding="2" borderRadius="lg" overflow="hidden" flex="0 0 auto" marginTop="4" > <Image src={FeedbackKikoImg} alt="Smiling purple Kiko chef, holding a spoon" width={90} height={90} layout="fixed" opacity="0.9" /> </Box> <FeedbackFormPitch /> </Flex> </FeedbackFormContainer> <FeedbackFormContainer borderColor={formBorderColor}> <FeedbackForm /> </FeedbackFormContainer> </VStack> ); } function FeedbackFormContainer({ background, borderColor, children }) { return ( <Box as="section" background={background} border="1px solid" borderColor={borderColor} borderRadius="lg" boxShadow="lg" maxWidth="500px" paddingLeft="2" paddingRight="4" paddingY="2" transition="all 0.2s" > {children} </Box> ); } function FeedbackFormPitch() { const [authMode, setAuthMode] = useAuthModeFeatureFlag(); return ( <Flex direction="column" textAlign="left" opacity="0.9"> <Box as="header">Hi friends! Welcome to the beta!</Box> <Box as="p" fontSize="sm"> This is the new Dress to Impress! It's ready for the future, and it even works great on mobile! More coming soon! </Box> <Flex direction={{ base: "column", sm: "row" }}> <Box as="section" fontSize="sm" marginY="2" flex={{ base: "0 0 auto", sm: "0 1 50%" }} > <Box as="h3" fontWeight="600"> New updates (Oct 14) </Box> <UnorderedList> <ListItem>Paginated item search (bye infinite scroll!)</ListItem> <ListItem>Automatic modeling! :0</ListItem> <ListItem> <ChakraLink href="https://twitter.com/NeopetsDTI" textDecoration="underline" > See more on Twitter! </ChakraLink> </ListItem> </UnorderedList> </Box> <Box width="2" /> <Box as="section" fontSize="sm" marginY="2" flex={{ base: "0 0 auto", sm: "0 1 50%" }} > <Box as="h3" fontWeight="600"> Coming soon </Box> <UnorderedList> <ListItem> <Popover> <PopoverTrigger> <Button variant="link" textDecoration="underline" color="inherit" fontSize="1em" fontWeight="inherit" > Better login system </Button> </PopoverTrigger> <PopoverContent> <PopoverArrow /> <PopoverCloseButton /> <PopoverBody> <FormControl> <Flex> <Box> <FormLabel htmlFor="hi-res-mode-setting" fontSize="sm" margin="0" > Experimental login mode </FormLabel> <FormHelperText marginTop="0" fontSize="xs"> Should be faster and easier—help us try it out! After turning this on, try logging in. </FormHelperText> </Box> <Box width="2" /> <Switch id="hi-res-mode-setting" size="sm" marginTop="0.1rem" isChecked={authMode === "db"} onChange={(e) => setAuthMode(e.target.checked ? "db" : "auth0") } /> </Flex> </FormControl> </PopoverBody> </PopoverContent> </Popover> </ListItem> <ListItem>Making sure we're ready for the long-term</ListItem> <ListItem> …a lot of little things{" "} <span role="img" aria-label="Sweat smile emoji"> 😅 </span> </ListItem> </UnorderedList> </Box> </Flex> <Box fontSize="sm" marginTop="1"> ↓ Got ideas? Send them to us, please!{" "} <span role="img" aria-label="Sparkle heart emoji"> 💖 </span> </Box> </Flex> ); } function FeedbackForm() { const [content, setContent] = React.useState(""); const [email, setEmail] = useLocalStorage("DTIFeedbackFormEmail", ""); const [isSending, setIsSending] = React.useState(false); const toast = useToast(); const onSubmit = React.useCallback( (e) => { e.preventDefault(); fetch("/api/sendFeedback", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ content, email }), }) .then((res) => { if (!res.ok) { throw new Error(`/api/sendFeedback returned status ${res.status}`); } setIsSending(false); setContent(""); toast({ status: "success", title: "Got it! We'll take a look soon.", description: "Thanks for helping us get better! Best wishes to you and your " + "pets!!", }); }) .catch((e) => { setIsSending(false); console.error(e); toast({ status: "warning", title: "Oops, we had an error sending this, sorry!", description: "We'd still love to hear from you! Please reach out to " + "matchu@openneo.net with whatever's on your mind. Thanks and " + "enjoy the site!", duration: null, isClosable: true, }); }); setIsSending(true); }, [content, email, toast] ); const { brightBackground } = useCommonStyles(); return ( <Box as="form" // We use Grid here rather than our usual Flex, mainly so the fields will // tab in the correct order! display="grid" gridTemplateAreas={`"email send" "content content"`} gridTemplateColumns="1fr auto" gridGap="2" onSubmit={onSubmit} > <Input type="email" placeholder="Email address (optional)" size="sm" gridArea="email" value={email} onChange={(e) => setEmail(e.target.value)} background={brightBackground} /> <Textarea size="sm" placeholder={"I love…\nI wish…\nNext, you should add…"} gridArea="content" value={content} onChange={(e) => setContent(e.target.value)} background={brightBackground} /> <Button type="submit" size="sm" colorScheme="blue" gridArea="send" isDisabled={content.trim().length === 0} isLoading={isSending} > Send </Button> </Box> ); } /** * useSupportSetup helps our support staff get set up with special access. * If you provide ?supportSecret=... in the URL, we'll save it in a cookie and * pop up a toast! * * This doesn't guarantee the secret is correct, of course! We don't bother to * check that here; the server will reject requests from bad support secrets. * And there's nothing especially secret in the support UI, so it's okay if * other people know about the tools and poke around a powerless interface! */ function useSupportSetup() { const { query } = useRouter(); const toast = useToast(); const supportSecret = query.supportSecret; React.useEffect(() => { const existingSupportSecret = localStorage.getItem("supportSecret"); if (supportSecret && supportSecret !== existingSupportSecret) { localStorage.setItem("supportSecret", supportSecret); toast({ title: "Support secret saved!", description: `You should now see special Support UI across the site. ` + `Thanks for your help! 💖`, status: "success", duration: 10000, isClosable: true, }); } else if (supportSecret === "") { localStorage.removeItem("supportSecret"); toast({ title: "Support secret deleted.", description: `The Support UI will now stop appearing on this device.`, status: "success", duration: 10000, isClosable: true, }); } }, [supportSecret, toast]); } export default HomePage;