import React from "react"; import { css } from "emotion"; import gql from "graphql-tag"; import { Box, Button, Flex, IconButton, Input, Textarea, useColorModeValue, useTheme, useToast, } from "@chakra-ui/core"; import { CloseIcon } from "@chakra-ui/icons"; import { useHistory, useLocation } from "react-router-dom"; import { useLazyQuery } from "@apollo/client"; import { Heading1, useLocalStorage, usePageTitle } from "./util"; import OutfitPreview from "./components/OutfitPreview"; import SpeciesColorPicker from "./components/SpeciesColorPicker"; import HomepageSplashImg from "../images/homepage-splash.png"; import HomepageSplashImg2x from "../images/homepage-splash@2x.png"; import FeedbackXweeImg from "../images/feedback-xwee.png"; import FeedbackXweeImg2x from "../images/feedback-xwee@2x.png"; function HomePage() { usePageTitle(null); useSupportSetup(); const [previewState, setPreviewState] = React.useState(null); return ( <Flex direction="column" align="center" textAlign="center" marginTop="8"> <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={ <Box as="img" src={HomepageSplashImg} srcSet={`${HomepageSplashImg} 1x, ${HomepageSplashImg2x} 2x`} alt="" /> } /> </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" /> <FeedbackFormSection /> </Flex> ); } function StartOutfitForm({ onChange }) { const history = useHistory(); 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, }); history.push(`/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 history = useHistory(); const theme = useTheme(); const toast = useToast(); const [petName, setPetName] = React.useState(""); const [loadPet, { loading }] = useLazyQuery( gql` query SubmitPetForm($petName: String!) { petOnNeopetsDotCom(petName: $petName) { color { id } species { id } pose items { id } } } `, { fetchPolicy: "network-only", onCompleted: (data) => { if (!data) return; const { species, color, pose, items } = data.petOnNeopetsDotCom; const params = new URLSearchParams({ name: petName, species: species.id, color: color.id, pose, }); for (const item of items) { params.append("objects[]", item.id); } history.push(`/outfits/new?${params}`); }, onError: () => { toast({ title: "We couldn't load that pet, sorry 😓", description: "Is it spelled correctly?", status: "error", }); }, } ); const onSubmit = (e) => { e.preventDefault(); loadPet({ variables: { petName } }); // Start preloading the WardrobePage, too! // eslint-disable-next-line no-unused-expressions import("./WardrobePage"); }; 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 ( <form onSubmit={onSubmit}> <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 }} 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> ); } function FeedbackFormSection() { const [isOpen, setIsOpen] = React.useState(false); const borderColor = useColorModeValue("gray.300", "blue.400"); const openButtonRef = React.useRef(null); const emailFieldRef = React.useRef(null); const elementAwaitingFocusRef = React.useRef(null); const openForm = React.useCallback(() => { setIsOpen(true); // Wait for the re-render to enable the field, then focus it. elementAwaitingFocusRef.current = emailFieldRef.current; }, [setIsOpen]); const closeForm = React.useCallback(() => { setIsOpen(false); // Wait for the re-render to enable the button, then focus it. elementAwaitingFocusRef.current = openButtonRef.current; }, [setIsOpen]); // This lil layout effect will focus whatever element is awaiting focus, then // clear it out. We use this to set up focus() calls, but wait until the next // layout finishes to actually call them. React.useLayoutEffect(() => { if (elementAwaitingFocusRef.current) { elementAwaitingFocusRef.current.focus(); elementAwaitingFocusRef.current = null; } }); return ( <Flex as="section" position="relative" alignItems="center" border="1px solid" borderColor={borderColor} borderRadius="lg" boxShadow="lg" maxWidth="500px" paddingLeft="2" paddingRight="4" paddingY="2" cursor={!isOpen && "pointer"} onClick={!isOpen ? openForm : null} > <Box padding="2" borderRadius="lg" overflow="hidden" flex="0 0 auto"> <Box as="img" src={FeedbackXweeImg} srcSet={`${FeedbackXweeImg} 1x, ${FeedbackXweeImg2x} 2x`} height="90px" width="90px" opacity="0.9" alt="" /> </Box> <Box display="grid" gridTemplateAreas="the-single-area" alignItems="center" marginLeft="2" > <Box position="absolute" left="1" top="1" aria-hidden={!isOpen} isDisabled={!isOpen} opacity={isOpen ? "0.5" : "0"} pointerEvents={isOpen ? "all" : "none"} transition="opacity 0.25s" > <IconButton aria-label="Close" icon={<CloseIcon />} size="xs" variant="ghost" onClick={closeForm} isDisabled={!isOpen} /> </Box> <Box gridArea="the-single-area" aria-hidden={isOpen} opacity={isOpen ? "0" : "1"} pointerEvents={isOpen ? "none" : "all"} transition="opacity 0.25s" > <FeedbackFormPitch isDisabled={isOpen} onClick={openForm} openButtonRef={openButtonRef} /> </Box> <Box gridArea="the-single-area" aria-hidden={!isOpen} opacity={isOpen ? "1" : "0"} pointerEvents={isOpen ? "all" : "none"} transition="opacity 0.25s" > <FeedbackForm isDisabled={!isOpen} onClose={closeForm} emailFieldRef={emailFieldRef} /> </Box> </Box> </Flex> ); } function FeedbackFormPitch({ isDisabled, onClick, openButtonRef }) { 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> <Box as="button" alignSelf="flex-end" fontSize="sm" marginTop="1" opacity="0.8" textDecoration="underline" disabled={isDisabled} onClick={onClick} ref={openButtonRef} > Got ideas? Send us your feedback → </Box> </Flex> ); } function FeedbackForm({ isDisabled, onClose, emailFieldRef }) { 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(""); onClose(); 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, onClose, toast] ); 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} onKeyDown={(e) => { if (e.key === "Escape") { onClose(); e.stopPropagation(); } }} > <Input type="email" placeholder="Email address (optional)" size="sm" gridArea="email" value={email} onChange={(e) => setEmail(e.target.value)} ref={emailFieldRef} isDisabled={isDisabled} /> <Textarea size="sm" placeholder={"I love…\nI wish…\nNext, you should add…"} gridArea="content" value={content} onChange={(e) => setContent(e.target.value)} isDisabled={isDisabled} /> <Button type="submit" size="sm" colorScheme="blue" gridArea="send" isDisabled={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 location = useLocation(); const toast = useToast(); React.useEffect(() => { const params = new URLSearchParams(location.search); const supportSecret = params.get("supportSecret"); 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, }); } }, [location, toast]); } export default HomePage;