Remove the item page drawer, just link to the item page instead

The wardrobe-2020 app had a cute drawer that embeds the item page, but
honestly I don't think it was that valuable, and especially not when it
means we have to basically maintain two item pages lol. Let's decrease
the surface area!
This commit is contained in:
Emi Matchu 2023-11-03 16:56:51 -07:00
parent a2feee2d9b
commit a18ffb22a7
5 changed files with 22 additions and 1213 deletions

View file

@ -1,30 +0,0 @@
import React from "react";
import {
Drawer,
DrawerBody,
DrawerContent,
DrawerCloseButton,
DrawerOverlay,
useBreakpointValue,
} from "@chakra-ui/react";
import { ItemPageContent } from "./ItemPage";
function ItemPageDrawer({ item, isOpen, onClose }) {
const placement = useBreakpointValue({ base: "bottom", lg: "right" });
return (
<Drawer placement={placement} size="md" isOpen={isOpen} onClose={onClose}>
<DrawerOverlay>
<DrawerContent>
<DrawerCloseButton />
<DrawerBody>
<ItemPageContent itemId={item.id} isEmbedded />
</DrawerBody>
</DrawerContent>
</DrawerOverlay>
</Drawer>
);
}
export default ItemPageDrawer;

View file

@ -1,411 +0,0 @@
import React from "react";
import {
Badge,
Box,
Flex,
Popover,
PopoverArrow,
PopoverContent,
PopoverTrigger,
Portal,
Select,
Skeleton,
Spinner,
Tooltip,
useToast,
VStack,
} from "@chakra-ui/react";
import { ExternalLinkIcon, ChevronRightIcon } from "@chakra-ui/icons";
import { gql, useMutation } from "@apollo/client";
import {
ItemBadgeList,
ItemKindBadge,
ItemThumbnail,
} from "./components/ItemCard";
import { Heading1 } from "./util";
import useSupport from "./WardrobePage/support/useSupport";
function ItemPageLayout({ children, item, isEmbedded }) {
return (
<Box>
<ItemPageHeader item={item} isEmbedded={isEmbedded} />
<Box>{children}</Box>
</Box>
);
}
function ItemPageHeader({ item, isEmbedded }) {
return (
<Box
display="flex"
alignItems="center"
justifyContent="flex-start"
width="100%"
>
<SubtleSkeleton isLoaded={item?.thumbnailUrl} marginRight="4">
<ItemThumbnail item={item} size="lg" isActive flex="0 0 auto" />
</SubtleSkeleton>
<Box>
<SubtleSkeleton isLoaded={item?.name}>
<Heading1
lineHeight="1.1"
// Nudge down the size a bit in the embed case, to better fit the
// tighter layout!
size={isEmbedded ? "xl" : "2xl"}
>
{item?.name || "Item name here"}
</Heading1>
</SubtleSkeleton>
<ItemPageBadges item={item} isEmbedded={isEmbedded} />
</Box>
</Box>
);
}
/**
* SubtleSkeleton hides the skeleton animation until a second has passed, and
* doesn't fade in the content if it loads near-instantly. This helps avoid
* flash-of-content stuff!
*
* For plain Skeletons, we often use <Delay><Skeleton /></Delay> instead. But
* that pattern doesn't work as well for wrapper skeletons where we're using
* placeholder content for layout: we don't want the delay if the content
* really _is_ present!
*/
export function SubtleSkeleton({ isLoaded, ...props }) {
const [shouldFadeIn, setShouldFadeIn] = React.useState(false);
const [shouldShowSkeleton, setShouldShowSkeleton] = React.useState(false);
React.useEffect(() => {
const t = setTimeout(() => {
if (!isLoaded) {
setShouldFadeIn(true);
}
}, 150);
return () => clearTimeout(t);
});
React.useEffect(() => {
const t = setTimeout(() => setShouldShowSkeleton(true), 500);
return () => clearTimeout(t);
});
return (
<Skeleton
fadeDuration={shouldFadeIn ? undefined : 0}
startColor={shouldShowSkeleton ? undefined : "transparent"}
endColor={shouldShowSkeleton ? undefined : "transparent"}
isLoaded={isLoaded}
{...props}
/>
);
}
function ItemPageBadges({ item, isEmbedded }) {
const searchBadgesAreLoaded = item?.name != null && item?.isNc != null;
return (
<ItemBadgeList marginTop="1">
<SubtleSkeleton isLoaded={item?.isNc != null}>
<ItemKindBadgeWithSupportTools item={item} />
</SubtleSkeleton>
{
// If the createdAt date is null (loaded and empty), hide the badge.
item?.createdAt !== null && (
<SubtleSkeleton
// Distinguish between undefined (still loading) and null (loaded and
// empty).
isLoaded={item?.createdAt !== undefined}
>
<Badge
display="block"
minWidth="5.25em"
boxSizing="content-box"
textAlign="center"
>
{item?.createdAt && <ShortTimestamp when={item?.createdAt} />}
</Badge>
</SubtleSkeleton>
)
}
<SubtleSkeleton isLoaded={searchBadgesAreLoaded}>
<LinkBadge
href={`https://impress.openneo.net/items/${item?.id}`}
isEmbedded={isEmbedded}
>
Classic DTI
</LinkBadge>
</SubtleSkeleton>
<SubtleSkeleton isLoaded={searchBadgesAreLoaded}>
<LinkBadge
href={
"https://items.jellyneo.net/search/?name=" +
encodeURIComponent(item?.name) +
"&name_type=3"
}
isEmbedded={isEmbedded}
>
Jellyneo
</LinkBadge>
</SubtleSkeleton>
{item?.isNc && (
<SubtleSkeleton
isLoaded={
// Distinguish between undefined (still loading) and null (loaded
// and empty).
item?.ncTradeValueText !== undefined
}
>
{item?.ncTradeValueText && (
<LinkBadge href="https://www.neopets.com/~owls">
OWLS: {item?.ncTradeValueText}
</LinkBadge>
)}
</SubtleSkeleton>
)}
<SubtleSkeleton isLoaded={searchBadgesAreLoaded}>
{!item?.isNc && !item?.isPb && (
<LinkBadge
href={
"https://www.neopets.com/shops/wizard.phtml?string=" +
encodeURIComponent(item?.name)
}
isEmbedded={isEmbedded}
>
Shop Wiz
</LinkBadge>
)}
</SubtleSkeleton>
<SubtleSkeleton isLoaded={searchBadgesAreLoaded}>
{!item?.isNc && !item?.isPb && (
<LinkBadge
href={
"https://www.neopets.com/portal/supershopwiz.phtml?string=" +
encodeURIComponent(item?.name)
}
isEmbedded={isEmbedded}
>
Super Wiz
</LinkBadge>
)}
</SubtleSkeleton>
<SubtleSkeleton isLoaded={searchBadgesAreLoaded}>
{!item?.isNc && !item?.isPb && (
<LinkBadge
href={
"https://www.neopets.com/island/tradingpost.phtml?type=browse&criteria=item_exact&search_string=" +
encodeURIComponent(item?.name)
}
isEmbedded={isEmbedded}
>
Trade Post
</LinkBadge>
)}
</SubtleSkeleton>
<SubtleSkeleton isLoaded={searchBadgesAreLoaded}>
{!item?.isNc && !item?.isPb && (
<LinkBadge
href={
"https://www.neopets.com/genie.phtml?type=process_genie&criteria=exact&auctiongenie=" +
encodeURIComponent(item?.name)
}
isEmbedded={isEmbedded}
>
Auctions
</LinkBadge>
)}
</SubtleSkeleton>
</ItemBadgeList>
);
}
function ItemKindBadgeWithSupportTools({ item }) {
const { isSupportUser, supportSecret } = useSupport();
const toast = useToast();
const ncRef = React.useRef(null);
const isNcAutoDetectedFromRarity =
item?.rarityIndex === 500 || item?.rarityIndex === 0;
const [mutate, { loading }] = useMutation(gql`
mutation ItemPageSupportSetIsManuallyNc(
$itemId: ID!
$isManuallyNc: Boolean!
$supportSecret: String!
) {
setItemIsManuallyNc(
itemId: $itemId
isManuallyNc: $isManuallyNc
supportSecret: $supportSecret
) {
id
isNc
isManuallyNc
}
}
`);
if (
isSupportUser &&
item?.rarityIndex != null &&
item?.isManuallyNc != null
) {
// TODO: Could code-split this into a SupportOnly file...
return (
<Popover placement="bottom-start" initialFocusRef={ncRef} showArrow>
<PopoverTrigger>
<ItemKindBadge isNc={item.isNc} isPb={item.isPb} isEditButton />
</PopoverTrigger>
<Portal>
<PopoverContent padding="4">
<PopoverArrow />
<VStack spacing="2" align="flex-start">
<Flex align="center">
<Box as="span" fontWeight="600" marginRight="2">
NC:
</Box>
<Select
ref={ncRef}
size="xs"
value={item.isManuallyNc ? "true" : "false"}
onChange={(e) => {
const isManuallyNc = e.target.value === "true";
mutate({
variables: {
itemId: item.id,
isManuallyNc,
supportSecret,
},
optimisticResponse: {
setItemIsManuallyNc: {
__typename: "Item",
id: item.id,
isNc: isManuallyNc || isNcAutoDetectedFromRarity,
isManuallyNc,
},
},
}).catch((e) => {
console.error(e);
toast({
status: "error",
title:
"Could not set NC status for this item. Try again?",
});
});
}}
>
<option value="false">
Auto-detect: {isNcAutoDetectedFromRarity ? "Yes" : "No"}.{" "}
(Rarity {item.rarityIndex})
</option>
<option value="true">Manually set: Yes.</option>
</Select>
{loading && <Spinner size="sm" marginLeft="2" />}
</Flex>
<Flex align="center">
<Box as="span" fontWeight="600" marginRight="1">
PB:
</Box>
<Select size="xs" isReadOnly value="auto-detect">
<option value="auto-detect">
Auto-detect: {item.isPb ? "Yes" : "No"}. (from description)
</option>
<option style={{ fontStyle: "italic" }}>
(This cannot be manually set.)
</option>
</Select>
</Flex>
<Badge
colorScheme="pink"
alignSelf="flex-end"
marginBottom="-2"
marginRight="-2"
>
Support <span aria-hidden="true">💖</span>
</Badge>
</VStack>
</PopoverContent>
</Portal>
</Popover>
);
}
return <ItemKindBadge isNc={item?.isNc} isPb={item?.isPb} />;
}
const LinkBadge = React.forwardRef(
({ children, href, isEmbedded, ...props }, ref) => {
return (
<Badge
ref={ref}
as="a"
href={href}
display="flex"
alignItems="center"
// Normally we want to act like a normal webpage, and treat links as
// normal. But when we're on the wardrobe page, we want to avoid
// disrupting the outfit, and open in a new window instead.
target={isEmbedded ? "_blank" : undefined}
_focus={{ outline: "none", boxShadow: "outline" }}
{...props}
>
{children}
{
// We also change the icon to signal whether this will launch in a new
// window or not!
isEmbedded ? (
<ExternalLinkIcon marginLeft="1" />
) : (
<ChevronRightIcon />
)
}
</Badge>
);
},
);
const fullDateFormatter = new Intl.DateTimeFormat("en-US", {
dateStyle: "long",
});
const monthYearFormatter = new Intl.DateTimeFormat("en-US", {
month: "short",
year: "numeric",
});
const monthDayYearFormatter = new Intl.DateTimeFormat("en-US", {
month: "short",
day: "numeric",
year: "numeric",
});
function ShortTimestamp({ when }) {
const date = new Date(when);
// To find the start of last month, take today, then set its date to the 1st
// and its time to midnight (the start of this month), and subtract one
// month. (JS handles negative months and rolls them over correctly.)
const startOfLastMonth = new Date();
startOfLastMonth.setDate(1);
startOfLastMonth.setHours(0);
startOfLastMonth.setMinutes(0);
startOfLastMonth.setSeconds(0);
startOfLastMonth.setMilliseconds(0);
startOfLastMonth.setMonth(startOfLastMonth.getMonth() - 1);
const dateIsOlderThanLastMonth = date < startOfLastMonth;
return (
<Tooltip
label={`First seen on ${fullDateFormatter.format(date)}`}
placement="top"
openDelay={400}
>
{dateIsOlderThanLastMonth
? monthYearFormatter.format(date)
: monthDayYearFormatter.format(date)}
</Tooltip>
);
}
export default ItemPageLayout;

View file

@ -1,771 +1,37 @@
import React from "react"; import React from "react";
import { ClassNames } from "@emotion/react"; import { useQuery } from "@apollo/client";
import gql from "graphql-tag";
import { import {
AspectRatio, AspectRatio,
Button,
Box, Box,
HStack, Button,
IconButton,
SkeletonText,
Tooltip,
VisuallyHidden,
VStack,
useBreakpointValue,
useColorModeValue,
useTheme,
useToast,
Flex, Flex,
usePrefersReducedMotion,
Grid, Grid,
Popover, IconButton,
PopoverContent, Tooltip,
PopoverTrigger, useColorModeValue,
Checkbox, usePrefersReducedMotion,
} from "@chakra-ui/react"; } from "@chakra-ui/react";
import { import { EditIcon, WarningIcon } from "@chakra-ui/icons";
CheckIcon,
ChevronDownIcon,
ChevronRightIcon,
EditIcon,
StarIcon,
WarningIcon,
} from "@chakra-ui/icons";
import { MdPause, MdPlayArrow } from "react-icons/md"; import { MdPause, MdPlayArrow } from "react-icons/md";
import gql from "graphql-tag";
import { useQuery, useMutation } from "@apollo/client";
import ItemPageLayout, { SubtleSkeleton } from "./ItemPageLayout";
import {
Delay,
logAndCapture,
MajorErrorMessage,
useLocalStorage,
} from "./util";
import HTML5Badge, { layerUsesHTML5 } from "./components/HTML5Badge"; import HTML5Badge, { layerUsesHTML5 } from "./components/HTML5Badge";
import {
itemAppearanceFragment,
petAppearanceFragment,
} from "./components/useOutfitAppearance";
import { useOutfitPreview } from "./components/OutfitPreview";
import SpeciesColorPicker, { import SpeciesColorPicker, {
useAllValidPetPoses, useAllValidPetPoses,
getValidPoses, getValidPoses,
getClosestPose, getClosestPose,
} from "./components/SpeciesColorPicker"; } from "./components/SpeciesColorPicker";
import useCurrentUser from "./components/useCurrentUser";
import SpeciesFacesPicker, { import SpeciesFacesPicker, {
colorIsBasic, colorIsBasic,
} from "./ItemPage/SpeciesFacesPicker"; } from "./ItemPage/SpeciesFacesPicker";
import {
itemAppearanceFragment,
petAppearanceFragment,
} from "./components/useOutfitAppearance";
import { useOutfitPreview } from "./components/OutfitPreview";
import { logAndCapture, useLocalStorage } from "./util";
// Removed for the wardrobe-2020 case. function ItemPageOutfitPreview({ itemId }) {
// TODO: Refactor this stuff, do we even need ItemPageContent really?
// function ItemPage() {
// const { query } = useRouter();
// return <ItemPageContent itemId={query.itemId} />;
// }
/**
* 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 = false }) {
const { isLoggedIn } = useCurrentUser();
const { error, data } = useQuery(
gql`
query ItemPage($itemId: ID!) {
item(id: $itemId) {
id
name
isNc
isPb
thumbnailUrl
description
createdAt
ncTradeValueText
# For Support users.
rarityIndex
isManuallyNc
}
}
`,
{ variables: { itemId }, returnPartialData: true },
);
if (error) {
return <MajorErrorMessage error={error} />;
}
const item = data?.item;
return (
<>
<ItemPageLayout item={item} isEmbedded={isEmbedded}>
<VStack spacing="8" marginTop="4">
<ItemPageDescription
description={item?.description}
isEmbedded={isEmbedded}
/>
<VStack spacing="4">
<ItemPageTradeLinks itemId={itemId} isEmbedded={isEmbedded} />
{isLoggedIn && <ItemPageOwnWantButtons itemId={itemId} />}
</VStack>
{!isEmbedded && <ItemPageOutfitPreview itemId={itemId} />}
</VStack>
</ItemPageLayout>
</>
);
}
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 (
<Box width="100%" alignSelf="flex-start">
{description ? (
description
) : description === "" ? (
<i>(This item has no description.)</i>
) : (
<Box
maxWidth="40em"
minHeight={numDescriptionLines * 1.5 + "em"}
display="flex"
flexDirection="column"
alignItems="stretch"
justifyContent="center"
>
<Delay ms={500}>
<SkeletonText noOfLines={numDescriptionLines} spacing="4" />
</Delay>
</Box>
)}
</Box>
);
}
const ITEM_PAGE_OWN_WANT_BUTTONS_QUERY = gql`
query ItemPageOwnWantButtons($itemId: ID!) {
item(id: $itemId) {
id
name
currentUserOwnsThis
currentUserWantsThis
}
currentUser {
closetLists {
id
name
isDefaultList
ownsOrWantsItems
hasItem(itemId: $itemId)
}
}
}
`;
function ItemPageOwnWantButtons({ itemId }) {
const { loading, error, data } = useQuery(ITEM_PAGE_OWN_WANT_BUTTONS_QUERY, {
variables: { itemId },
context: { sendAuth: true },
});
if (error) {
return <Box color="red.400">{error.message}</Box>;
}
const closetLists = data?.currentUser?.closetLists || [];
const realLists = closetLists.filter((cl) => !cl.isDefaultList);
const ownedLists = realLists.filter((cl) => cl.ownsOrWantsItems === "OWNS");
const wantedLists = realLists.filter((cl) => cl.ownsOrWantsItems === "WANTS");
return (
<Grid
templateRows="auto auto"
templateColumns="160px 160px"
gridAutoFlow="column"
rowGap="0.5"
columnGap="4"
justifyItems="center"
>
<SubtleSkeleton isLoaded={!loading}>
<ItemPageOwnButton
itemId={itemId}
isChecked={data?.item?.currentUserOwnsThis}
/>
</SubtleSkeleton>
<ItemPageOwnWantListsDropdown
closetLists={ownedLists}
item={data?.item}
// Show the dropdown if the user owns this, and has at least one custom
// list it could belong to.
isVisible={data?.item?.currentUserOwnsThis && ownedLists.length >= 1}
popoverPlacement="bottom-end"
/>
<SubtleSkeleton isLoaded={!loading}>
<ItemPageWantButton
itemId={itemId}
isChecked={data?.item?.currentUserWantsThis}
/>
</SubtleSkeleton>
<ItemPageOwnWantListsDropdown
closetLists={wantedLists}
item={data?.item}
// Show the dropdown if the user wants this, and has at least one
// custom list it could belong to.
isVisible={data?.item?.currentUserWantsThis && wantedLists.length >= 1}
popoverPlacement="bottom-start"
/>
</Grid>
);
}
function ItemPageOwnWantListsDropdown({
closetLists,
item,
isVisible,
popoverPlacement,
}) {
return (
<Popover placement={popoverPlacement}>
<PopoverTrigger>
<ItemPageOwnWantListsDropdownButton
closetLists={closetLists}
isVisible={isVisible}
/>
</PopoverTrigger>
<PopoverContent padding="2" width="64">
<ItemPageOwnWantListsDropdownContent
closetLists={closetLists}
item={item}
/>
</PopoverContent>
</Popover>
);
}
const ItemPageOwnWantListsDropdownButton = React.forwardRef(
({ closetLists, isVisible, ...props }, ref) => {
const listsToShow = closetLists.filter((cl) => cl.hasItem);
let buttonText;
if (listsToShow.length === 1) {
buttonText = `In list: "${listsToShow[0].name}"`;
} else if (listsToShow.length > 1) {
const listNames = listsToShow.map((cl) => `"${cl.name}"`).join(", ");
buttonText = `${listsToShow.length} lists: ${listNames}`;
} else {
buttonText = "Add to list";
}
return (
<Flex
ref={ref}
as="button"
fontSize="xs"
alignItems="center"
borderRadius="sm"
width="100%"
_hover={{ textDecoration: "underline" }}
_focus={{
textDecoration: "underline",
outline: "0",
boxShadow: "outline",
}}
// Even when the button isn't visible, we still render it for layout
// purposes, but hidden and disabled.
opacity={isVisible ? 1 : 0}
aria-hidden={!isVisible}
disabled={!isVisible}
{...props}
>
{/* Flex tricks to center the text, ignoring the caret */}
<Box flex="1 0 0" />
<Box textOverflow="ellipsis" overflow="hidden" whiteSpace="nowrap">
{buttonText}
</Box>
<Flex flex="1 0 0">
<ChevronDownIcon marginLeft="1" />
</Flex>
</Flex>
);
},
);
function ItemPageOwnWantListsDropdownContent({ closetLists, item }) {
return (
<Box as="ul" listStyleType="none">
{closetLists.map((closetList) => (
<Box key={closetList.id} as="li">
<ItemPageOwnWantsListsDropdownRow
closetList={closetList}
item={item}
/>
</Box>
))}
</Box>
);
}
function ItemPageOwnWantsListsDropdownRow({ closetList, item }) {
const toast = useToast();
const [sendAddToListMutation] = useMutation(
gql`
mutation ItemPage_AddToClosetList($listId: ID!, $itemId: ID!) {
addItemToClosetList(
listId: $listId
itemId: $itemId
removeFromDefaultList: true
) {
id
hasItem(itemId: $itemId)
}
}
`,
{ context: { sendAuth: true } },
);
const [sendRemoveFromListMutation] = useMutation(
gql`
mutation ItemPage_RemoveFromClosetList($listId: ID!, $itemId: ID!) {
removeItemFromClosetList(
listId: $listId
itemId: $itemId
ensureInSomeList: true
) {
id
hasItem(itemId: $itemId)
}
}
`,
{ context: { sendAuth: true } },
);
const onChange = React.useCallback(
(e) => {
if (e.target.checked) {
sendAddToListMutation({
variables: { listId: closetList.id, itemId: item.id },
optimisticResponse: {
addItemToClosetList: {
__typename: "ClosetList",
id: closetList.id,
hasItem: true,
},
},
}).catch((error) => {
console.error(error);
toast({
status: "error",
title: `Oops, error adding "${item.name}" to "${closetList.name}!"`,
description:
"Check your connection and try again? Sorry about this!",
});
});
} else {
sendRemoveFromListMutation({
variables: { listId: closetList.id, itemId: item.id },
optimisticResponse: {
removeItemFromClosetList: {
__typename: "ClosetList",
id: closetList.id,
hasItem: false,
},
},
}).catch((error) => {
console.error(error);
toast({
status: "error",
title: `Oops, error removing "${item.name}" from "${closetList.name}!"`,
description:
"Check your connection and try again? Sorry about this!",
});
});
}
},
[
closetList,
item,
sendAddToListMutation,
sendRemoveFromListMutation,
toast,
],
);
return (
<Checkbox
size="sm"
width="100%"
value={closetList.id}
isChecked={closetList.hasItem}
onChange={onChange}
>
{closetList.name}
</Checkbox>
);
}
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,
},
},
// TODO: Refactor the mutation result to include closet lists
refetchQueries: [
{
query: ITEM_PAGE_OWN_WANT_BUTTONS_QUERY,
variables: { itemId },
context: { sendAuth: 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,
},
},
// TODO: Refactor the mutation result to include closet lists
refetchQueries: [
{
query: ITEM_PAGE_OWN_WANT_BUTTONS_QUERY,
variables: { itemId },
context: { sendAuth: true },
},
],
},
);
return (
<ClassNames>
{({ css }) => (
<Box as="label">
<VisuallyHidden
as="input"
type="checkbox"
checked={isChecked}
onChange={(e) => {
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,
});
});
}
}}
/>
<Button
as="div"
colorScheme={isChecked ? "green" : "gray"}
size="lg"
cursor="pointer"
transitionDuration="0.4s"
className={css`
input:focus + & {
box-shadow: ${theme.shadows.outline};
}
`}
>
<IconCheckbox
icon={<CheckIcon />}
isChecked={isChecked}
marginRight="0.5em"
/>
I own this
</Button>
</Box>
)}
</ClassNames>
);
}
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,
},
},
// TODO: Refactor the mutation result to include closet lists
refetchQueries: [
{
query: ITEM_PAGE_OWN_WANT_BUTTONS_QUERY,
variables: { itemId },
context: { sendAuth: 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,
},
},
// TODO: Refactor the mutation result to include closet lists
refetchQueries: [
{
query: ITEM_PAGE_OWN_WANT_BUTTONS_QUERY,
variables: { itemId },
context: { sendAuth: true },
},
],
},
);
return (
<ClassNames>
{({ css }) => (
<Box as="label">
<VisuallyHidden
as="input"
type="checkbox"
checked={isChecked}
onChange={(e) => {
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,
});
});
}
}}
/>
<Button
as="div"
colorScheme={isChecked ? "blue" : "gray"}
size="lg"
cursor="pointer"
transitionDuration="0.4s"
className={css`
input:focus + & {
box-shadow: ${theme.shadows.outline};
}
`}
>
<IconCheckbox
icon={<StarIcon />}
isChecked={isChecked}
marginRight="0.5em"
/>
I want this
</Button>
</Box>
)}
</ClassNames>
);
}
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 <Box color="red.400">{error.message}</Box>;
}
return (
<HStack spacing="2">
<Box as="header" fontSize="sm" fontWeight="bold">
Trading:
</Box>
<SubtleSkeleton isLoaded={!loading}>
<ItemPageTradeLink
href={`/items/${itemId}/trades/offering`}
count={data?.item?.numUsersOfferingThis || 0}
label="offering"
colorScheme="green"
isEmbedded={isEmbedded}
/>
</SubtleSkeleton>
<SubtleSkeleton isLoaded={!loading}>
<ItemPageTradeLink
href={`/items/${itemId}/trades/seeking`}
count={data?.item?.numUsersSeekingThis || 0}
label="seeking"
colorScheme="blue"
isEmbedded={isEmbedded}
/>
</SubtleSkeleton>
</HStack>
);
}
function ItemPageTradeLink({ href, count, label, colorScheme, isEmbedded }) {
return (
<Button
as="a"
href={href}
target={isEmbedded ? "_blank" : undefined}
size="xs"
variant="outline"
colorScheme={colorScheme}
borderRadius="full"
paddingRight="1"
>
<Box display="grid" gridTemplateAreas="single-area">
<Box gridArea="single-area" display="flex" justifyContent="center">
{count} {label} <ChevronRightIcon minHeight="1.2em" />
</Box>
<Box
gridArea="single-area"
display="flex"
justifyContent="center"
visibility="hidden"
>
888 offering <ChevronRightIcon minHeight="1.2em" />
</Box>
</Box>
</Button>
);
}
function IconCheckbox({ icon, isChecked, ...props }) {
return (
<Box display="grid" gridTemplateAreas="the-same-area" {...props}>
<Box
gridArea="the-same-area"
width="1em"
height="1em"
border="2px solid currentColor"
borderRadius="md"
opacity={isChecked ? "0" : "0.75"}
transform={isChecked ? "scale(0.75)" : "none"}
transition="all 0.4s"
/>
<Box
gridArea="the-same-area"
display="flex"
opacity={isChecked ? "1" : "0"}
transform={isChecked ? "none" : "scale(0.1)"}
transition="all 0.4s"
>
{icon}
</Box>
</Box>
);
}
export function ItemPageOutfitPreview({ itemId }) {
const idealPose = React.useMemo( const idealPose = React.useMemo(
() => (Math.random() > 0.5 ? "HAPPY_FEM" : "HAPPY_MASC"), () => (Math.random() > 0.5 ? "HAPPY_FEM" : "HAPPY_MASC"),
[], [],
@ -1260,10 +526,7 @@ function PlayPauseButton({ isPaused, onClick }) {
); );
} }
export function ItemZonesInfo({ function ItemZonesInfo({ compatibleBodiesAndTheirZones, restrictedZones }) {
compatibleBodiesAndTheirZones,
restrictedZones,
}) {
// Reorganize the body-and-zones data, into zone-and-bodies data. Also, we're // Reorganize the body-and-zones data, into zone-and-bodies data. Also, we're
// merging zones with the same label, because that's how user-facing zone UI // merging zones with the same label, because that's how user-facing zone UI
// generally works! // generally works!
@ -1434,3 +697,5 @@ function buildSortKeyForZoneLabelsAndTheirBodies({ zoneLabel, bodies }) {
return `${representsAllBodies ? "A" : "Z"}-${inverseBodyCount}-${zoneLabel}`; return `${representsAllBodies ? "A" : "Z"}-${inverseBodyCount}-${zoneLabel}`;
} }
export default ItemPageOutfitPreview;

View file

@ -24,7 +24,6 @@ import {
import SupportOnly from "./support/SupportOnly"; import SupportOnly from "./support/SupportOnly";
import useSupport from "./support/useSupport"; import useSupport from "./support/useSupport";
const LoadableItemPageDrawer = loadable(() => import("../ItemPageDrawer"));
const LoadableItemSupportDrawer = loadable(() => const LoadableItemSupportDrawer = loadable(() =>
import("./support/ItemSupportDrawer"), import("./support/ItemSupportDrawer"),
); );
@ -56,7 +55,6 @@ function Item({
onRemove, onRemove,
isDisabled = false, isDisabled = false,
}) { }) {
const [infoDrawerIsOpen, setInfoDrawerIsOpen] = React.useState(false);
const [supportDrawerIsOpen, setSupportDrawerIsOpen] = React.useState(false); const [supportDrawerIsOpen, setSupportDrawerIsOpen] = React.useState(false);
return ( return (
@ -97,24 +95,10 @@ function Item({
icon={<InfoIcon />} icon={<InfoIcon />}
label="More info" label="More info"
to={`/items/${item.id}`} to={`/items/${item.id}`}
onClick={(e) => { target="_blank"
const willProbablyOpenInNewTab =
e.metaKey || e.shiftKey || e.altKey || e.ctrlKey;
if (willProbablyOpenInNewTab) {
return;
}
setInfoDrawerIsOpen(true);
e.preventDefault();
}}
/> />
</Box> </Box>
</ItemContainer> </ItemContainer>
<LoadableItemPageDrawer
item={item}
isOpen={infoDrawerIsOpen}
onClose={() => setInfoDrawerIsOpen(false)}
/>
<SupportOnly> <SupportOnly>
<LoadableItemSupportDrawer <LoadableItemSupportDrawer
item={item} item={item}
@ -232,7 +216,7 @@ function ItemBadges({ item }) {
/** /**
* ItemActionButton is one of a list of actions a user can take for this item. * ItemActionButton is one of a list of actions a user can take for this item.
*/ */
function ItemActionButton({ icon, label, to, onClick }) { function ItemActionButton({ icon, label, to, onClick, ...props }) {
const theme = useTheme(); const theme = useTheme();
const focusBackgroundColor = useColorModeValue( const focusBackgroundColor = useColorModeValue(
@ -249,6 +233,7 @@ function ItemActionButton({ icon, label, to, onClick }) {
{({ css }) => ( {({ css }) => (
<Tooltip label={label} placement="top"> <Tooltip label={label} placement="top">
<LinkOrButton <LinkOrButton
{...props}
component={IconButton} component={IconButton}
href={to} href={to}
icon={icon} icon={icon}

View file

@ -1,5 +1,5 @@
import AppProvider from "./AppProvider"; import AppProvider from "./AppProvider";
import { ItemPageOutfitPreview } from "./ItemPage"; import ItemPageOutfitPreview from "./ItemPageOutfitPreview";
import WardrobePage from "./WardrobePage"; import WardrobePage from "./WardrobePage";
export { AppProvider, ItemPageOutfitPreview, WardrobePage }; export { AppProvider, ItemPageOutfitPreview, WardrobePage };