impress-2020/src/app/HomePage.js

883 lines
24 KiB
JavaScript

import React from "react";
import { ClassNames } from "@emotion/react";
import gql from "graphql-tag";
import {
Alert,
Box,
Button,
Center,
Flex,
HStack,
IconButton,
Input,
InputGroup,
InputLeftElement,
InputRightElement,
Link as ChakraLink,
ListItem,
Skeleton,
Textarea,
Tooltip,
UnorderedList,
useColorModeValue,
useTheme,
useToast,
VStack,
} from "@chakra-ui/react";
import { ArrowForwardIcon, SearchIcon } from "@chakra-ui/icons";
import { Link, useHistory, useLocation } from "react-router-dom";
import { useLazyQuery, useQuery } from "@apollo/client";
import Image from "next/image";
import {
Delay,
ErrorMessage,
Heading1,
Heading2,
TestErrorSender,
useCommonStyles,
useLocalStorage,
usePageTitle,
} from "./util";
import OutfitPreview from "./components/OutfitPreview";
import SpeciesColorPicker from "./components/SpeciesColorPicker";
import SquareItemCard, {
SquareItemCardSkeleton,
} from "./components/SquareItemCard";
import WIPCallout from "./components/WIPCallout";
import HomepageSplashImg from "./images/homepage-splash.png";
import FeedbackXweeImg from "./images/feedback-xwee.png";
function HomePage() {
usePageTitle(null);
useSupportSetup();
const [previewState, setPreviewState] = React.useState(null);
return (
<Flex direction="column" align="center" textAlign="center" marginTop="8">
<Alert status="warning" maxWidth="600px">
<Box>
<strong>
The Neopets Metaverse team is no longer licensed to use this
software.
</strong>{" "}
<Box
as="a"
href="https://twitter.com/NeopetsDTI/status/1460386400839168001?s=20"
textDecoration="underline"
>
More information available here.
</Box>{" "}
Thanks for understanding!
</Box>
</Alert>
<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 last year, but, well… what a year 😅 Anyway, this will eventually become the main site, at impress.openneo.net!">
Maybe we'll rename it to Impress 2021… or maybe not! 🤔
</WIPCallout>
<TestErrorSender />
</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 location = useLocation();
const [petName, setPetName] = React.useState("");
const [loadPetQuery, { 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 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!)
React.useEffect(() => {
const params = new URLSearchParams(location.search);
const petName = params.get("loadPet");
if (petName) {
setPetName(petName);
loadPet(petName);
}
}, [location, 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 history = useHistory();
return (
<form
onSubmit={(e) => {
e.preventDefault();
if (query) {
history.push(`/items/search/${encodeURIComponent(query)}`);
}
}}
>
<InputGroup size="sm">
<InputLeftElement>
<Box as={Link} to="/items/search" display="flex">
<SearchIcon color="gray.400" />
</Box>
</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={FeedbackXweeImg}
alt="Smiling green Xweetok"
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() {
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 (Sep 30)
</Box>
<UnorderedList>
<ListItem>Search bar on item page</ListItem>
<ListItem>Remove items from lists</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>Better outfit editing on large screens</ListItem>
<ListItem>Item list editing</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 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;