diff --git a/apps/ui-sharethrift/package.json b/apps/ui-sharethrift/package.json index a2f77dae6..78fc37934 100644 --- a/apps/ui-sharethrift/package.json +++ b/apps/ui-sharethrift/package.json @@ -1,63 +1,63 @@ { - "name": "@sthrift/ui-sharethrift", - "private": true, - "version": "0.0.0", - "type": "module", - "scripts": { - "start": "vite", - "build": "tsc -b && vite build", - "lint": "eslint .", - "preview": "vite preview", - "tswatch": "tsc --build --watch", - "storybook": "storybook dev -p 6006", - "build-storybook": "storybook build", - "test": "vitest run", - "test:coverage": "vitest run --coverage", - "test:watch": "vitest" - }, - "dependencies": { - "@ant-design/icons": "^6.1.0", - "@ant-design/v5-patch-for-react-19": "^1.0.3", - "@apollo/client": "^4.0.7", - "@sthrift/ui-components": "workspace:*", - "antd": "^5.27.1", - "crypto-hash": "^3.1.0", - "dayjs": "^1.11.18", - "graphql": "^16.11.0", - "lodash": "^4.17.21", - "react": "^19.1.1", - "react-dom": "^19.1.1", - "react-oidc-context": "^3.3.0", - "react-router-dom": "^7.8.0", - "rxjs": "^7.8.2" - }, - "devDependencies": { - "@cellix/typescript-config": "workspace:*", - "@cellix/vitest-config": "workspace:*", - "@chromatic-com/storybook": "^4.1.0", - "@eslint/js": "^9.30.1", - "@graphql-typed-document-node/core": "^3.2.0", - "@storybook/addon-a11y": "^9.1.17", - "@storybook/addon-docs": "^9.1.17", - "@storybook/addon-vitest": "^9.1.17", - "@storybook/react": "^9.1.17", - "@storybook/react-vite": "^9.1.17", - "@testing-library/jest-dom": "^6.9.1", - "@types/lodash": "^4.17.20", - "@types/react": "^19.1.9", - "@types/react-dom": "^19.1.7", - "@vitejs/plugin-react": "^4.7.0", - "@vitest/browser": "3.2.4", - "@vitest/coverage-v8": "^3.2.4", - "eslint": "^9.30.1", - "eslint-plugin-react-hooks": "^5.2.0", - "eslint-plugin-react-refresh": "^0.4.20", - "globals": "^16.3.0", - "storybook": "catalog:", - "typescript": "~5.8.3", - "typescript-eslint": "^8.35.1", - "vite": "^7.1.2", - "vitest": "^3.2.4" - }, - "license": "MIT" + "name": "@sthrift/ui-sharethrift", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "start": "vite", + "build": "tsc -b && vite build", + "lint": "eslint .", + "preview": "vite preview", + "tswatch": "tsc --build --watch", + "storybook": "storybook dev -p 6006", + "build-storybook": "storybook build", + "test": "vitest run", + "test:coverage": "vitest run --coverage", + "test:watch": "vitest" + }, + "dependencies": { + "@ant-design/icons": "^6.1.0", + "@ant-design/v5-patch-for-react-19": "^1.0.3", + "@apollo/client": "^4.0.7", + "@sthrift/ui-components": "workspace:*", + "antd": "^5.27.1", + "crypto-hash": "^3.1.0", + "dayjs": "^1.11.18", + "graphql": "^16.11.0", + "lodash": "^4.17.21", + "react": "^19.1.1", + "react-dom": "^19.1.1", + "react-oidc-context": "^3.3.0", + "react-router-dom": "^7.8.0", + "rxjs": "^7.8.2" + }, + "devDependencies": { + "@cellix/typescript-config": "workspace:*", + "@cellix/vitest-config": "workspace:*", + "@chromatic-com/storybook": "^4.1.0", + "@eslint/js": "^9.30.1", + "@graphql-typed-document-node/core": "^3.2.0", + "@storybook/addon-a11y": "^9.1.17", + "@storybook/addon-docs": "^9.1.17", + "@storybook/addon-vitest": "^9.1.17", + "@storybook/react": "^9.1.17", + "@storybook/react-vite": "^9.1.17", + "@testing-library/jest-dom": "^6.9.1", + "@types/lodash": "^4.17.20", + "@types/react": "^19.1.9", + "@types/react-dom": "^19.1.7", + "@vitejs/plugin-react": "^4.7.0", + "@vitest/browser": "3.2.4", + "@vitest/coverage-v8": "^3.2.4", + "eslint": "^9.30.1", + "eslint-plugin-react-hooks": "^5.2.0", + "eslint-plugin-react-refresh": "^0.4.20", + "globals": "^16.3.0", + "storybook": "catalog:", + "typescript": "~5.8.3", + "typescript-eslint": "^8.35.1", + "vite": "^7.1.2", + "vitest": "^3.2.4" + }, + "license": "MIT" } diff --git a/apps/ui-sharethrift/src/components/layouts/home/account/admin-dashboard/components/admin-listings-table/admin-listings-table.container.graphql b/apps/ui-sharethrift/src/components/layouts/home/account/admin-dashboard/components/admin-listings-table/admin-listings-table.container.graphql index ae954b342..4b0d11684 100644 --- a/apps/ui-sharethrift/src/components/layouts/home/account/admin-dashboard/components/admin-listings-table/admin-listings-table.container.graphql +++ b/apps/ui-sharethrift/src/components/layouts/home/account/admin-dashboard/components/admin-listings-table/admin-listings-table.container.graphql @@ -1,12 +1,12 @@ # Inline fragment (previously in admin-listings-table.fragment.graphql) fragment AdminListingFields on ListingAll { - id - title - state - images - createdAt - sharingPeriodStart - sharingPeriodEnd + id + title + state + images + createdAt + sharingPeriodStart + sharingPeriodEnd } query AdminListingsTableContainerAdminListings( @@ -42,5 +42,9 @@ mutation AdminListingsTableContainerDeleteListing($id: ObjectID!) { } mutation AdminListingsTableContainerUnblockListing($id: ObjectID!) { - unblockListing(id: $id) + unblockListing(id: $id) { + id + state + success + } } diff --git a/apps/ui-sharethrift/src/components/layouts/home/account/admin-dashboard/components/admin-listings-table/admin-listings-table.container.stories.tsx b/apps/ui-sharethrift/src/components/layouts/home/account/admin-dashboard/components/admin-listings-table/admin-listings-table.container.stories.tsx index 888bc83c9..e450a4561 100644 --- a/apps/ui-sharethrift/src/components/layouts/home/account/admin-dashboard/components/admin-listings-table/admin-listings-table.container.stories.tsx +++ b/apps/ui-sharethrift/src/components/layouts/home/account/admin-dashboard/components/admin-listings-table/admin-listings-table.container.stories.tsx @@ -8,7 +8,7 @@ import { import { AdminListingsTableContainerAdminListingsDocument, AdminListingsTableContainerDeleteListingDocument, - AdminListingsTableContainerUnblockListingDocument, + AdminListingsTableContainerUnblockListingDocument } from '../../../../../../../generated.tsx'; const meta: Meta = { @@ -56,10 +56,11 @@ const meta: Meta = { }, result: { data: { - unblockItemListing: { - __typename: 'MutationStatus', + unblockListing: { + __typename: 'BlockListingResult', + id: 'listing-1', + state: 'Published', success: true, - errorMessage: null, }, }, }, diff --git a/apps/ui-sharethrift/src/components/layouts/home/account/admin-dashboard/components/admin-listings-table/admin-listings-table.view-listing.stories.tsx b/apps/ui-sharethrift/src/components/layouts/home/account/admin-dashboard/components/admin-listings-table/admin-listings-table.view-listing.stories.tsx index 6da419fed..77586a8e5 100644 --- a/apps/ui-sharethrift/src/components/layouts/home/account/admin-dashboard/components/admin-listings-table/admin-listings-table.view-listing.stories.tsx +++ b/apps/ui-sharethrift/src/components/layouts/home/account/admin-dashboard/components/admin-listings-table/admin-listings-table.view-listing.stories.tsx @@ -5,7 +5,8 @@ import { withMockApolloClient, withMockRouter } from '../../../../../../../test- import { AdminListingsTableContainerAdminListingsDocument, AdminListingsTableContainerDeleteListingDocument, - AdminListingsTableContainerUnblockListingDocument, + BlockListingContainerUnblockListingDocument, + AdminListingsTableContainerUnblockListingDocument } from '../../../../../../../generated.tsx'; const meta = { @@ -49,14 +50,15 @@ const meta = { }, { request: { - query: AdminListingsTableContainerUnblockListingDocument, + query: BlockListingContainerUnblockListingDocument, }, result: { data: { - unblockItemListing: { - __typename: 'MutationStatus', + unblockListing: { + __typename: 'BlockListingResult', + id: 'listing-123', + state: 'Published', success: true, - errorMessage: null, }, }, }, diff --git a/apps/ui-sharethrift/src/components/layouts/home/account/admin-dashboard/components/admin-listings-table/admin-listings-table.view-listing.tsx b/apps/ui-sharethrift/src/components/layouts/home/account/admin-dashboard/components/admin-listings-table/admin-listings-table.view-listing.tsx index 40431241a..8781ba8a1 100644 --- a/apps/ui-sharethrift/src/components/layouts/home/account/admin-dashboard/components/admin-listings-table/admin-listings-table.view-listing.tsx +++ b/apps/ui-sharethrift/src/components/layouts/home/account/admin-dashboard/components/admin-listings-table/admin-listings-table.view-listing.tsx @@ -16,7 +16,7 @@ import { useNavigate, useParams } from 'react-router-dom'; import { AdminListingsTableContainerAdminListingsDocument, AdminListingsTableContainerDeleteListingDocument, - AdminListingsTableContainerUnblockListingDocument, + BlockListingContainerUnblockListingDocument, } from '../../../../../../../generated.tsx'; export function AdminViewListing(): ReactElement { @@ -37,7 +37,7 @@ export function AdminViewListing(): ReactElement { ); const [unblockListingMutation] = useMutation( - AdminListingsTableContainerUnblockListingDocument, + BlockListingContainerUnblockListingDocument, ); const [deleteListingMutation] = useMutation( diff --git a/apps/ui-sharethrift/src/components/layouts/home/components/view-listing/appeal-confirmation-modal.tsx b/apps/ui-sharethrift/src/components/layouts/home/components/view-listing/appeal-confirmation-modal.tsx new file mode 100644 index 000000000..999bf7212 --- /dev/null +++ b/apps/ui-sharethrift/src/components/layouts/home/components/view-listing/appeal-confirmation-modal.tsx @@ -0,0 +1,42 @@ +import { Modal, Typography } from 'antd'; +import { ExclamationCircleOutlined } from '@ant-design/icons'; +import type React from 'react'; + +const { Paragraph } = Typography; + +export interface AppealConfirmationModalProps { + visible: boolean; + onConfirm: () => void; + onCancel: () => void; + loading?: boolean; +} + +export const AppealConfirmationModal: React.FC< + AppealConfirmationModalProps +> = ({ visible, onConfirm, onCancel, loading }) => { + return ( + + + Confirm Appeal + + } + open={visible} + onOk={onConfirm} + onCancel={onCancel} + okText="Appeal" + cancelText="Cancel" + confirmLoading={loading} + okButtonProps={{ danger: true }} + > + + Before you appeal, make sure you've reviewed and updated your listing to + comply with our guidelines. + + + Appeals without changes may be blocked. Are you sure you want to proceed? + + + ); +}; diff --git a/apps/ui-sharethrift/src/components/layouts/home/components/view-listing/block-information-modal.tsx b/apps/ui-sharethrift/src/components/layouts/home/components/view-listing/block-information-modal.tsx new file mode 100644 index 000000000..3f543e01e --- /dev/null +++ b/apps/ui-sharethrift/src/components/layouts/home/components/view-listing/block-information-modal.tsx @@ -0,0 +1,88 @@ +import { Alert, Button, Modal, Typography } from 'antd'; +import { ExclamationCircleOutlined } from '@ant-design/icons'; +import type React from 'react'; + +const { Paragraph, Title } = Typography; + +export interface BlockInformationModalProps { + visible: boolean; + onClose: () => void; + onEditListing: () => void; + onAppealBlock: () => void; + blockReason?: string; + blockDescription?: string; + appealRequested?: boolean; +} + +export const BlockInformationModal: React.FC = ({ + visible, + onClose, + onEditListing, + onAppealBlock, + blockReason, + blockDescription, + appealRequested, +}) => { + return ( + + + Block Information + + } + open={visible} + onCancel={onClose} + footer={[ + , + , + , + ]} + > +
+ + Reason + + {blockReason || 'No reason provided'} +
+ +
+ + Description + + {blockDescription || 'No description provided'} +
+ + {appealRequested && ( +
+ + Status + + Appeal Requested +
+ )} + + {!appealRequested && ( + } + style={{ marginTop: 16 }} + /> + )} +
+ ); +}; diff --git a/apps/ui-sharethrift/src/components/layouts/home/components/view-listing/block-listing-modal.stories.tsx b/apps/ui-sharethrift/src/components/layouts/home/components/view-listing/block-listing-modal.stories.tsx new file mode 100644 index 000000000..4a4c72990 --- /dev/null +++ b/apps/ui-sharethrift/src/components/layouts/home/components/view-listing/block-listing-modal.stories.tsx @@ -0,0 +1,44 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { BlockListingModal } from './block-listing-modal'; + +const meta: Meta = { + title: 'Layouts/Home/View Listing/Block Listing Modal', + component: BlockListingModal, + parameters: { + layout: 'centered', + }, + tags: ['autodocs'], + argTypes: { + visible: { control: 'boolean' }, + loading: { control: 'boolean' }, + onConfirm: { action: 'confirmed' }, + onCancel: { action: 'cancelled' }, + }, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + visible: true, + listingTitle: 'City Bike', + loading: false, + }, +}; + +export const Loading: Story = { + args: { + visible: true, + listingTitle: 'City Bike', + loading: true, + }, +}; + +export const Closed: Story = { + args: { + visible: false, + listingTitle: 'City Bike', + loading: false, + }, +}; diff --git a/apps/ui-sharethrift/src/components/layouts/home/components/view-listing/block-listing-modal.tsx b/apps/ui-sharethrift/src/components/layouts/home/components/view-listing/block-listing-modal.tsx new file mode 100644 index 000000000..8fc09fc18 --- /dev/null +++ b/apps/ui-sharethrift/src/components/layouts/home/components/view-listing/block-listing-modal.tsx @@ -0,0 +1,55 @@ +import { Button, Modal } from 'antd'; + +interface BlockListingModalProps { + visible: boolean; + listingTitle: string; + onConfirm: () => void; + onCancel: () => void; + loading?: boolean; +} + +export const BlockListingModal: React.FC = ({ + visible, + listingTitle, + onConfirm, + onCancel, + loading = false, +}) => { + const handleOk = () => { + onConfirm(); + }; + + const handleCancel = () => { + onCancel(); + }; + + return ( + + Cancel + , + , + ]} + > +

+ You are about to block the listing: {listingTitle} +

+

+ Are you sure you want to block this listing? This will make it + unavailable to all users except administrators. +

+
+ ); +}; diff --git a/apps/ui-sharethrift/src/components/layouts/home/components/view-listing/block-listing.container.graphql b/apps/ui-sharethrift/src/components/layouts/home/components/view-listing/block-listing.container.graphql new file mode 100644 index 000000000..8c3970b16 --- /dev/null +++ b/apps/ui-sharethrift/src/components/layouts/home/components/view-listing/block-listing.container.graphql @@ -0,0 +1,15 @@ +mutation BlockListingContainerBlockListing($id: ObjectID!) { + blockListing(id: $id) { + id + state + success + } +} + +mutation BlockListingContainerUnblockListing($id: ObjectID!) { + unblockListing(id: $id) { + id + state + success + } +} diff --git a/apps/ui-sharethrift/src/components/layouts/home/components/view-listing/block-listing.container.stories.tsx b/apps/ui-sharethrift/src/components/layouts/home/components/view-listing/block-listing.container.stories.tsx new file mode 100644 index 000000000..071e4bf3c --- /dev/null +++ b/apps/ui-sharethrift/src/components/layouts/home/components/view-listing/block-listing.container.stories.tsx @@ -0,0 +1,144 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { + BlockListingContainerBlockListingDocument, + BlockListingContainerUnblockListingDocument, + ViewListingDocument, +} from '../../../../../generated.tsx'; +import { withMockApolloClient } from '../../../../../test-utils/storybook-decorators.tsx'; +import { BlockListingButton } from './block-listing.container.tsx'; + +const meta: Meta = { + title: 'Containers/BlockListingButton', + component: BlockListingButton, + parameters: { + layout: 'centered', + apolloClient: { + mocks: [ + { + request: { + query: BlockListingContainerBlockListingDocument, + variables: { id: 'listing-1' }, + }, + result: { + data: { + blockListing: { + __typename: 'ItemListing', + id: 'listing-1', + isBlocked: true, + }, + }, + }, + }, + { + request: { + query: BlockListingContainerUnblockListingDocument, + variables: { id: 'listing-1' }, + }, + result: { + data: { + unblockListing: { + __typename: 'ItemListing', + id: 'listing-1', + isBlocked: false, + }, + }, + }, + }, + { + request: { + query: ViewListingDocument, + variables: { id: 'listing-1' }, + }, + result: { + data: { + itemListing: { + __typename: 'ItemListing', + id: 'listing-1', + title: 'City Bike', + description: 'A great city bike', + category: 'Sports & Outdoors', + location: 'Toronto, ON', + state: 'Published', + images: [], + sharingPeriodStart: '2025-01-01', + sharingPeriodEnd: '2025-12-31', + createdAt: '2025-01-01T00:00:00Z', + updatedAt: '2025-01-01T00:00:00Z', + isBlocked: false, + sharer: { + __typename: 'PersonalUser', + id: 'user-1', + profile: { + firstName: 'John', + lastName: 'Doe', + }, + }, + }, + }, + }, + }, + ], + }, + }, + decorators: [withMockApolloClient], + tags: ['autodocs'], + argTypes: { + listingId: { control: 'text' }, + listingTitle: { control: 'text' }, + isBlocked: { control: 'boolean' }, + sharerName: { control: 'text' }, + renderModals: { control: 'boolean' }, + }, +}; + +export default meta; +type Story = StoryObj; + +export const BlockButton: Story = { + args: { + listingId: 'listing-1', + listingTitle: 'City Bike', + isBlocked: false, + sharerName: 'user-1', + renderModals: true, + }, +}; + +export const UnblockButton: Story = { + args: { + listingId: 'listing-1', + listingTitle: 'City Bike', + isBlocked: true, + sharerName: 'user-1', + renderModals: true, + }, +}; + +export const BlockButtonWithoutModals: Story = { + args: { + listingId: 'listing-1', + listingTitle: 'City Bike', + isBlocked: false, + sharerName: 'user-1', + renderModals: false, + }, +}; + +export const UnblockButtonWithoutModals: Story = { + args: { + listingId: 'listing-1', + listingTitle: 'City Bike', + isBlocked: true, + sharerName: 'user-1', + renderModals: false, + }, +}; + +export const WithDefaultSharer: Story = { + args: { + listingId: 'listing-1', + listingTitle: 'Professional Camera', + isBlocked: false, + renderModals: true, + }, +}; diff --git a/apps/ui-sharethrift/src/components/layouts/home/components/view-listing/block-listing.container.tsx b/apps/ui-sharethrift/src/components/layouts/home/components/view-listing/block-listing.container.tsx new file mode 100644 index 000000000..816c6426c --- /dev/null +++ b/apps/ui-sharethrift/src/components/layouts/home/components/view-listing/block-listing.container.tsx @@ -0,0 +1,119 @@ +import { useMutation } from '@apollo/client/react'; +import { message } from 'antd'; +import { + BlockListingContainerBlockListingDocument, + BlockListingContainerUnblockListingDocument, + ViewListingDocument, +} from '../../../../../generated.tsx'; +import { BlockListingModal } from './block-listing-modal.tsx'; +import { UnblockListingModal } from './unblock-listing-modal.tsx'; +import { useState } from 'react'; +import { Button } from 'antd'; + +interface BlockListingButtonProps { + listingId: string; + listingTitle: string; + isBlocked: boolean; + sharerName?: string; + renderModals?: boolean; +} + +export const BlockListingButton: React.FC = ({ + listingId, + listingTitle, + isBlocked, + sharerName = 'Unknown', + renderModals = true, +}) => { + const [blockModalVisible, setBlockModalVisible] = useState(false); + const [unblockModalVisible, setUnblockModalVisible] = useState(false); + + const [blockListing, { loading: blockLoading }] = useMutation( + BlockListingContainerBlockListingDocument, + { + onCompleted: () => { + message.success('Listing blocked successfully'); + }, + onError: (error) => { + message.error(`Failed to block listing: ${error.message}`); + }, + refetchQueries: [ + { + query: ViewListingDocument, + variables: { id: listingId }, + }, + ], + }, + ); + + const [unblockListing, { loading: unblockLoading }] = useMutation( + BlockListingContainerUnblockListingDocument, + { + onCompleted: () => { + message.success('Listing unblocked successfully'); + }, + onError: (error) => { + message.error(`Failed to unblock listing: ${error.message}`); + }, + refetchQueries: [ + { + query: ViewListingDocument, + variables: { id: listingId }, + }, + ], + }, + ); + + const handleBlockConfirm = async () => { + await blockListing({ variables: { id: listingId } }); + setBlockModalVisible(false); + }; + + const handleUnblockConfirm = async () => { + await unblockListing({ variables: { id: listingId } }); + setUnblockModalVisible(false); + }; + + return ( + <> + {isBlocked ? ( + + ) : ( + + )} + {renderModals && ( + <> + setBlockModalVisible(false)} + loading={blockLoading} + /> + setUnblockModalVisible(false)} + loading={unblockLoading} + /> + + )} + + ); +}; diff --git a/apps/ui-sharethrift/src/components/layouts/home/components/view-listing/listing-information/listing-information.container.graphql b/apps/ui-sharethrift/src/components/layouts/home/components/view-listing/listing-information/listing-information.container.graphql index e35cc1c6a..6cd91c86a 100644 --- a/apps/ui-sharethrift/src/components/layouts/home/components/view-listing/listing-information/listing-information.container.graphql +++ b/apps/ui-sharethrift/src/components/layouts/home/components/view-listing/listing-information/listing-information.container.graphql @@ -42,3 +42,21 @@ mutation HomeListingInformationCreateReservationRequest( updatedAt } } + +mutation HomeListingInformationCreateListingAppealRequest( + $input: CreateListingAppealRequestInput! +) { + createListingAppealRequest(input: $input) { + id + state + reason + } +} + +query ViewListingAppealRequestByListingId($listingId: ObjectID!) { + getListingAppealRequestByListingId(listingId: $listingId) { + id + state + reason + } +} diff --git a/apps/ui-sharethrift/src/components/layouts/home/components/view-listing/sharer-information/sharer-information.container.tsx b/apps/ui-sharethrift/src/components/layouts/home/components/view-listing/sharer-information/sharer-information.container.tsx index d27e33e07..888f42acc 100644 --- a/apps/ui-sharethrift/src/components/layouts/home/components/view-listing/sharer-information/sharer-information.container.tsx +++ b/apps/ui-sharethrift/src/components/layouts/home/components/view-listing/sharer-information/sharer-information.container.tsx @@ -10,11 +10,15 @@ interface SharerInformationContainerProps { className?: string; showIconOnly?: boolean; currentUserId?: string | null; + isAdmin?: boolean; + isBlocked?: boolean; + sharerName?: string; + listingTitle?: string; } export const SharerInformationContainer: React.FC< SharerInformationContainerProps -> = ({ sharerId, listingId, isOwner, sharedTimeAgo, className, currentUserId }) => { +> = ({ sharerId, listingId, isOwner, sharedTimeAgo, className, currentUserId, isAdmin, isBlocked, sharerName, listingTitle }) => { const { data, loading, error } = useQuery( SharerInformationContainerDocument, { @@ -41,6 +45,7 @@ export const SharerInformationContainer: React.FC< sharedTimeAgo={sharedTimeAgo} className={className} currentUserId={currentUserId} + isAdmin={isAdmin} /> ); } @@ -66,6 +71,10 @@ export const SharerInformationContainer: React.FC< sharedTimeAgo={sharedTimeAgo} className={className} currentUserId={currentUserId} + isAdmin={isAdmin} + isBlocked={isBlocked} + sharerName={sharerName} + listingTitle={listingTitle} /> ); }; diff --git a/apps/ui-sharethrift/src/components/layouts/home/components/view-listing/sharer-information/sharer-information.stories.tsx b/apps/ui-sharethrift/src/components/layouts/home/components/view-listing/sharer-information/sharer-information.stories.tsx index 3772aa7e2..63862ac9e 100644 --- a/apps/ui-sharethrift/src/components/layouts/home/components/view-listing/sharer-information/sharer-information.stories.tsx +++ b/apps/ui-sharethrift/src/components/layouts/home/components/view-listing/sharer-information/sharer-information.stories.tsx @@ -170,6 +170,28 @@ export const MessageButtonWithError: Story = { }, }; +export const AdminView: Story = { + args: { + isAdmin: true, + isBlocked: false, + sharerName: 'John Doe', + }, + play: async ({ canvasElement }) => { + await expect(canvasElement).toBeTruthy(); + }, +}; + +export const AdminViewWithBlockedListing: Story = { + args: { + isAdmin: true, + isBlocked: true, + sharerName: 'John Doe', + }, + play: async ({ canvasElement }) => { + await expect(canvasElement).toBeTruthy(); + }, +}; + export const MobileView: Story = { parameters: { viewport: { diff --git a/apps/ui-sharethrift/src/components/layouts/home/components/view-listing/sharer-information/sharer-information.tsx b/apps/ui-sharethrift/src/components/layouts/home/components/view-listing/sharer-information/sharer-information.tsx index bd3835be9..3d5c38218 100644 --- a/apps/ui-sharethrift/src/components/layouts/home/components/view-listing/sharer-information/sharer-information.tsx +++ b/apps/ui-sharethrift/src/components/layouts/home/components/view-listing/sharer-information/sharer-information.tsx @@ -8,6 +8,7 @@ import type { CreateConversationMutation, CreateConversationMutationVariables, } from '../../../../../../generated.tsx'; +import { BlockListingButton } from '../block-listing.container.tsx'; type Sharer = { id: string; @@ -22,6 +23,10 @@ interface SharerInformationProps { sharedTimeAgo?: string; className?: string; currentUserId?: string | null; + isAdmin?: boolean; + isBlocked?: boolean; + sharerName?: string; + listingTitle?: string; } export const SharerInformation: React.FC = ({ @@ -31,6 +36,11 @@ export const SharerInformation: React.FC = ({ sharedTimeAgo = '2 days ago', className = '', currentUserId, + isAdmin, + isBlocked = false, + sharerName, + listingTitle = '' + }) => { const [isMobile, setIsMobile] = useState(false); const navigate = useNavigate(); @@ -153,6 +163,17 @@ export const SharerInformation: React.FC = ({ )} + + {isAdmin && ( + + )} + ); }; diff --git a/apps/ui-sharethrift/src/components/layouts/home/components/view-listing/unblock-listing-modal.stories.tsx b/apps/ui-sharethrift/src/components/layouts/home/components/view-listing/unblock-listing-modal.stories.tsx new file mode 100644 index 000000000..a16e43f97 --- /dev/null +++ b/apps/ui-sharethrift/src/components/layouts/home/components/view-listing/unblock-listing-modal.stories.tsx @@ -0,0 +1,59 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { UnblockListingModal } from './unblock-listing-modal'; + +const meta: Meta = { + title: 'Layouts/Home/View Listing/Unblock Listing Modal', + component: UnblockListingModal, + parameters: { + layout: 'centered', + }, + tags: ['autodocs'], + argTypes: { + visible: { control: 'boolean' }, + loading: { control: 'boolean' }, + onConfirm: { action: 'confirmed' }, + onCancel: { action: 'cancelled' }, + }, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + visible: true, + listingTitle: 'City Bike', + listingSharer: 'Patrick G.', + loading: false, + }, +}; + +export const WithBlockInfo: Story = { + args: { + visible: true, + listingTitle: 'City Bike', + listingSharer: 'Patrick G.', + blockReason: 'Profanity', + blockDescription: + 'Your listing has been blocked due to profanity in the description. In order to have your listing unblocked, please update your listing to comply with our guidelines and submit an appeal.', + loading: false, + }, +}; + +export const Loading: Story = { + args: { + visible: true, + listingTitle: 'City Bike', + listingSharer: 'Patrick G.', + loading: true, + }, +}; + +export const Closed: Story = { + args: { + visible: false, + listingTitle: 'City Bike', + listingSharer: 'Patrick G.', + loading: false, + }, +}; diff --git a/apps/ui-sharethrift/src/components/layouts/home/components/view-listing/unblock-listing-modal.tsx b/apps/ui-sharethrift/src/components/layouts/home/components/view-listing/unblock-listing-modal.tsx new file mode 100644 index 000000000..8539df49e --- /dev/null +++ b/apps/ui-sharethrift/src/components/layouts/home/components/view-listing/unblock-listing-modal.tsx @@ -0,0 +1,40 @@ +import { Modal } from 'antd'; + +interface UnblockListingModalProps { + visible: boolean; + listingTitle: string; + listingSharer: string; + blockReason?: string; + blockDescription?: string; + onConfirm: () => void; + onCancel: () => void; + loading?: boolean; +} + +export const UnblockListingModal: React.FC = ({ + visible, + listingTitle, + listingSharer, + onConfirm, + onCancel, + loading = false, +}) => { + return ( + +
+

+ Are you sure you want to unblock {listingTitle}{' '} + posted by {listingSharer}? +

+
+
+ ); +}; diff --git a/apps/ui-sharethrift/src/components/layouts/home/components/view-listing/view-listing.container.graphql b/apps/ui-sharethrift/src/components/layouts/home/components/view-listing/view-listing.container.graphql index 7d6c9e054..d2d2bc3bd 100644 --- a/apps/ui-sharethrift/src/components/layouts/home/components/view-listing/view-listing.container.graphql +++ b/apps/ui-sharethrift/src/components/layouts/home/components/view-listing/view-listing.container.graphql @@ -1,62 +1,76 @@ # View Listing Query (backend-aligned) fragment ViewListingContainerListingFields on ItemListing { - id - title - description - category - listingType - location - sharingPeriodStart - sharingPeriodEnd - state - images - createdAt - updatedAt - reports - sharingHistory - schemaVersion - sharer { - ... on PersonalUser { - id - } - ... on AdminUser { - id - } - } + id + title + description + category + listingType + location + sharingPeriodStart + sharingPeriodEnd + state + images + createdAt + updatedAt + reports + sharingHistory + schemaVersion + sharer { + ... on PersonalUser { + id + account { + profile { + firstName + lastName + } + } + } + ... on AdminUser { + id + account { + profile { + firstName + lastName + } + } + } + } } query ViewListing($id: ObjectID!) { - itemListing(id: $id) { - ...ViewListingContainerListingFields - } + itemListing(id: $id) { + ...ViewListingContainerListingFields + } } query ViewListingCurrentUser { - currentUser { - ... on PersonalUser { - id - userType - } - ... on AdminUser { - id - userType - } - } + currentUser { + ... on PersonalUser { + id + userType + userIsAdmin + } + ... on AdminUser { + id + userType + userIsAdmin + } + } } fragment ViewListingContainerReservationFields on ReservationRequest { - id - state - reservationPeriodStart - reservationPeriodEnd + id + state + reservationPeriodStart + reservationPeriodEnd } query ViewListingActiveReservationRequestForListing( - $listingId: ObjectID! - $reserverId: ObjectID! + $listingId: ObjectID! + $reserverId: ObjectID! ) { - myActiveReservationForListing(listingId: $listingId, userId: $reserverId) { - ...ViewListingContainerReservationFields - } + myActiveReservationForListing(listingId: $listingId, userId: $reserverId) { + ...ViewListingContainerReservationFields + } } diff --git a/apps/ui-sharethrift/src/components/layouts/home/components/view-listing/view-listing.container.stories.tsx b/apps/ui-sharethrift/src/components/layouts/home/components/view-listing/view-listing.container.stories.tsx index 49001e521..34bb68cdf 100644 --- a/apps/ui-sharethrift/src/components/layouts/home/components/view-listing/view-listing.container.stories.tsx +++ b/apps/ui-sharethrift/src/components/layouts/home/components/view-listing/view-listing.container.stories.tsx @@ -1,15 +1,17 @@ import type { Meta, StoryObj } from '@storybook/react'; +import { + BlockListingContainerBlockListingDocument, + BlockListingContainerUnblockListingDocument, + ViewListingActiveReservationRequestForListingDocument, + ViewListingCurrentUserDocument, + ViewListingDocument, +} from '../../../../../generated.tsx'; import { expect, within, waitFor } from 'storybook/test'; -import { ViewListingContainer } from './view-listing.container.tsx'; import { withMockApolloClient, withMockRouter, } from '../../../../../test-utils/storybook-decorators.tsx'; -import { - ViewListingDocument, - ViewListingCurrentUserDocument, - ViewListingActiveReservationRequestForListingDocument, -} from '../../../../../generated.tsx'; +import { ViewListingContainer } from './view-listing.container.tsx'; const mockListing = { __typename: 'ItemListing', @@ -37,6 +39,7 @@ const mockListing = { const mockCurrentUser = { __typename: 'PersonalUser', id: 'user-2', + userIsAdmin: false, }; const meta: Meta = { @@ -78,6 +81,36 @@ const meta: Meta = { }, }, }, + { + request: { + query: BlockListingContainerBlockListingDocument, + variables: { id: '1' }, + }, + result: { + data: { + blockListing: { + id: '1', + state: 'Blocked', + success: true, + }, + }, + }, + }, + { + request: { + query: BlockListingContainerUnblockListingDocument, + variables: { id: '1' }, + }, + result: { + data: { + unblockListing: { + id: '1', + state: 'Published', + success: true, + }, + }, + }, + }, ], }, }, @@ -158,3 +191,173 @@ export const Loading: Story = { expect(loadingSpinner ?? canvasElement).toBeTruthy(); }, }; + +export const AdminUser: Story = { + args: { + isAuthenticated: true, + }, + parameters: { + apolloClient: { + mocks: [ + { + request: { + query: ViewListingDocument, + variables: { id: '1' }, + }, + result: { + data: { + itemListing: mockListing, + }, + }, + }, + { + request: { + query: ViewListingCurrentUserDocument, + }, + result: { + data: { + currentUser: { + ...mockCurrentUser, + userIsAdmin: true, + }, + }, + }, + }, + { + request: { + query: ViewListingActiveReservationRequestForListingDocument, + variables: { listingId: '1', reserverId: 'user-2' }, + }, + result: { + data: { + myActiveReservationForListing: null, + }, + }, + }, + { + request: { + query: BlockListingContainerBlockListingDocument, + variables: { id: '1' }, + }, + result: { + data: { + blockListing: { + id: '1', + state: 'Blocked', + }, + }, + }, + }, + ], + }, + }, + play: async ({ canvasElement }) => { + await expect(canvasElement).toBeTruthy(); + }, +}; + +export const BlockedListing: Story = { + args: { + isAuthenticated: true, + }, + parameters: { + apolloClient: { + mocks: [ + { + request: { + query: ViewListingDocument, + variables: { id: '1' }, + }, + result: { + data: { + itemListing: { + ...mockListing, + state: 'Blocked', + }, + }, + }, + }, + { + request: { + query: ViewListingCurrentUserDocument, + }, + result: { + data: { + currentUser: mockCurrentUser, + }, + }, + }, + ], + }, + }, + play: async ({ canvasElement }) => { + await expect(canvasElement).toBeTruthy(); + }, +}; + +export const BlockedListingAsAdmin: Story = { + args: { + isAuthenticated: true, + }, + parameters: { + apolloClient: { + mocks: [ + { + request: { + query: ViewListingDocument, + variables: { id: '1' }, + }, + result: { + data: { + itemListing: { + ...mockListing, + state: 'Blocked', + }, + }, + }, + }, + { + request: { + query: ViewListingCurrentUserDocument, + }, + result: { + data: { + currentUser: { + ...mockCurrentUser, + userIsAdmin: true, + }, + }, + }, + }, + { + request: { + query: ViewListingActiveReservationRequestForListingDocument, + variables: { listingId: '1', reserverId: 'user-2' }, + }, + result: { + data: { + myActiveReservationForListing: null, + }, + }, + }, + { + request: { + query: BlockListingContainerUnblockListingDocument, + variables: { id: '1' }, + }, + result: { + data: { + unblockListing: { + id: '1', + state: 'Published', + }, + }, + }, + }, + ], + }, + }, + play: async ({ canvasElement }) => { + await expect(canvasElement).toBeTruthy(); + }, +}; diff --git a/apps/ui-sharethrift/src/components/layouts/home/components/view-listing/view-listing.container.tsx b/apps/ui-sharethrift/src/components/layouts/home/components/view-listing/view-listing.container.tsx index 13213e5ad..9f17f0dda 100644 --- a/apps/ui-sharethrift/src/components/layouts/home/components/view-listing/view-listing.container.tsx +++ b/apps/ui-sharethrift/src/components/layouts/home/components/view-listing/view-listing.container.tsx @@ -6,6 +6,7 @@ import { ViewListingActiveReservationRequestForListingDocument, ViewListingCurrentUserDocument, ViewListingDocument, + ViewListingAppealRequestByListingIdDocument, } from '../../../../../generated.tsx'; import { ViewListing } from './view-listing'; @@ -41,22 +42,31 @@ export const ViewListingContainer: React.FC = ( fetchPolicy: 'cache-first', }); - const { - data: currentUserData, - loading: currentUserLoading, - } = useQuery(ViewListingCurrentUserDocument, { - skip: !props.isAuthenticated, // Skip if not authenticated - }); + const { data: currentUserData, loading: currentUserLoading } = useQuery( + ViewListingCurrentUserDocument, + { + skip: !props.isAuthenticated, // Skip if not authenticated + }, + ); const reserverId = currentUserData?.currentUser?.id ?? ''; const skip = !reserverId || !listingId; + const { data: userReservationData, loading: userReservationLoading } = + useQuery(ViewListingActiveReservationRequestForListingDocument, { + variables: { listingId: listingId ?? '', reserverId }, + skip, + }); + + // Fetch appeal request for blocked listing const { - data: userReservationData, - loading: userReservationLoading, - } = useQuery(ViewListingActiveReservationRequestForListingDocument, { - variables: { listingId: listingId ?? '', reserverId }, - skip, + data: appealRequestData, + loading: appealRequestLoading, + refetch: refetchAppealRequest, + } = useQuery(ViewListingAppealRequestByListingIdDocument, { + variables: { listingId: listingId ?? '' }, + skip: !listingId, + fetchPolicy: 'network-only', }); const sharedTimeAgo = listingData?.itemListing?.createdAt @@ -64,12 +74,33 @@ export const ViewListingContainer: React.FC = ( : undefined; const userIsSharer = false; + const isAdmin = currentUserData?.currentUser?.userIsAdmin ?? false; + + // Check if listing is blocked and user is not admin + const isBlocked = listingData?.itemListing?.state === 'Blocked'; + const cannotViewBlockedListing = isBlocked && !isAdmin; + return ( Error loading listing.} - hasData={listingData?.itemListing} + hasData={cannotViewBlockedListing ? null : listingData?.itemListing} + noDataComponent={ + cannotViewBlockedListing ? ( +
+

Listing Not Available

+

This listing is currently not available.

+
+ ) : undefined + } hasDataComponent={ = ( userReservationRequest={ userReservationData?.myActiveReservationForListing } + appealRequest={appealRequestData?.getListingAppealRequestByListingId} + onAppealRequestSuccess={refetchAppealRequest} + isAdmin={isAdmin} /> } /> diff --git a/apps/ui-sharethrift/src/components/layouts/home/components/view-listing/view-listing.handlers.test.tsx b/apps/ui-sharethrift/src/components/layouts/home/components/view-listing/view-listing.handlers.test.tsx new file mode 100644 index 000000000..86c567c04 --- /dev/null +++ b/apps/ui-sharethrift/src/components/layouts/home/components/view-listing/view-listing.handlers.test.tsx @@ -0,0 +1,759 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +/** + * ViewListing Component - Handler Functions & UI Elements Tests + * + * Tests for: + * - handleBack(): Navigation to home + * - handleBlockConfirm(): Block listing with modal management + * - handleUnblockConfirm(): Unblock listing with modal management + * - Block/Unblock UI rendering and interactions + * - Alert component for blocked listings + * - Admin-only button visibility and state management + */ + +// Mock window.location +Object.defineProperty(window, 'location', { + value: { href: '' }, + writable: true, +}); + +describe('ViewListing - Handler Functions & UI Elements', () => { + beforeEach(() => { + vi.clearAllMocks(); + window.location.href = ''; + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + // ==================== handleBack() Tests ==================== + describe('handleBack() Function', () => { + it('should navigate to home when handleBack is called', () => { + const handleBack = () => { + window.location.href = '/'; + }; + + handleBack(); + expect(window.location.href).toBe('/'); + }); + + it('should set window.location.href to root path', () => { + const handleBack = () => { + window.location.href = '/'; + }; + + handleBack(); + expect(window.location.href).toEqual('/'); + }); + + it('should be called when back button is clicked', () => { + const handleBack = vi.fn(() => { + window.location.href = '/'; + }); + + handleBack(); + expect(handleBack).toHaveBeenCalledTimes(1); + expect(window.location.href).toBe('/'); + }); + + it('should only navigate once per click', () => { + const handleBack = vi.fn(() => { + window.location.href = '/'; + }); + + handleBack(); + handleBack(); + expect(handleBack).toHaveBeenCalledTimes(2); + }); + + it('should handle rapid consecutive calls', () => { + const handleBack = vi.fn(() => { + window.location.href = '/'; + }); + + handleBack(); + handleBack(); + handleBack(); + + expect(handleBack).toHaveBeenCalledTimes(3); + expect(window.location.href).toBe('/'); + }); + }); + + // ==================== handleBlockConfirm() Tests ==================== + describe('handleBlockConfirm() Function', () => { + it('should call onBlockListing when handleBlockConfirm is executed', async () => { + const onBlockListing = vi.fn(async () => Promise.resolve()); + const setBlockModalVisible: (visible: boolean) => void = vi.fn(); + + const handleBlockConfirm = async () => { + await onBlockListing(); + setBlockModalVisible(false); + }; + + await handleBlockConfirm(); + + expect(onBlockListing).toHaveBeenCalledTimes(1); + }); + + it('should close block modal after successful block', async () => { + const onBlockListing = vi.fn(async () => Promise.resolve()); + const setBlockModalVisible: (visible: boolean) => void = vi.fn(); + + const handleBlockConfirm = async () => { + await onBlockListing(); + setBlockModalVisible(false); + }; + + await handleBlockConfirm(); + + expect(setBlockModalVisible).toHaveBeenCalledWith(false); + expect(setBlockModalVisible).toHaveBeenCalledTimes(1); + }); + + it('should handle onBlockListing promise completion', async () => { + const onBlockListing = vi.fn( + () => new Promise((resolve) => setTimeout(resolve, 100)), + ); + const setBlockModalVisible: (visible: boolean) => void = vi.fn(); + + const handleBlockConfirm = async () => { + await onBlockListing(); + setBlockModalVisible(false); + }; + + await handleBlockConfirm(); + + expect(onBlockListing).toHaveBeenCalled(); + expect(setBlockModalVisible).toHaveBeenCalledWith(false); + }); + + it('should execute modal close after onBlockListing completes', async () => { + const callOrder: string[] = []; + const onBlockListing = vi.fn(async () => { + callOrder.push('onBlockListing'); + return Promise.resolve(); + }); + const setBlockModalVisible: (visible: boolean) => void = vi.fn(() => { + callOrder.push('setBlockModalVisible'); + }); + + const handleBlockConfirm = async () => { + await onBlockListing(); + setBlockModalVisible(false); + }; + + await handleBlockConfirm(); + + expect(callOrder).toEqual(['onBlockListing', 'setBlockModalVisible']); + }); + + it('should not close modal if onBlockListing throws error', async () => { + const onBlockListing = vi.fn(async () => { + throw new Error('Block failed'); + }); + const setBlockModalVisible: (visible: boolean) => void = vi.fn(); + + const handleBlockConfirm = async () => { + try { + await onBlockListing(); + setBlockModalVisible(false); + } catch (error) { + // Error handling + } + }; + + await handleBlockConfirm(); + + expect(onBlockListing).toHaveBeenCalled(); + // Modal should not close on error + expect(setBlockModalVisible).not.toHaveBeenCalled(); + }); + + it('should handle async operation with loading state', async () => { + const onBlockListing = vi.fn( + () => + new Promise((resolve) => { + setTimeout(resolve, 50); + }), + ); + const setBlockModalVisible: (visible: boolean) => void = vi.fn(); + + const handleBlockConfirm = async () => { + await onBlockListing(); + setBlockModalVisible(false); + }; + + const promise = handleBlockConfirm(); + expect(onBlockListing).toHaveBeenCalled(); + + await promise; + expect(setBlockModalVisible).toHaveBeenCalledWith(false); + }); + }); + + // ==================== handleUnblockConfirm() Tests ==================== + describe('handleUnblockConfirm() Function', () => { + it('should call onUnblockListing when handleUnblockConfirm is executed', async () => { + const onUnblockListing = vi.fn(async () => Promise.resolve()); + const setUnblockModalVisible: (visible: boolean) => void = vi.fn(); + + const handleUnblockConfirm = async () => { + await onUnblockListing(); + setUnblockModalVisible(false); + }; + + await handleUnblockConfirm(); + + expect(onUnblockListing).toHaveBeenCalledTimes(1); + }); + + it('should close unblock modal after successful unblock', async () => { + const onUnblockListing = vi.fn(async () => Promise.resolve()); + const setUnblockModalVisible: (visible: boolean) => void = vi.fn(); + + const handleUnblockConfirm = async () => { + await onUnblockListing(); + setUnblockModalVisible(false); + }; + + await handleUnblockConfirm(); + + expect(setUnblockModalVisible).toHaveBeenCalledWith(false); + expect(setUnblockModalVisible).toHaveBeenCalledTimes(1); + }); + + it('should handle onUnblockListing promise completion', async () => { + const onUnblockListing = vi.fn( + () => new Promise((resolve) => setTimeout(resolve, 100)), + ); + const setUnblockModalVisible: (visible: boolean) => void = vi.fn(); + + const handleUnblockConfirm = async () => { + await onUnblockListing(); + setUnblockModalVisible(false); + }; + + await handleUnblockConfirm(); + + expect(onUnblockListing).toHaveBeenCalled(); + expect(setUnblockModalVisible).toHaveBeenCalledWith(false); + }); + + it('should execute modal close after onUnblockListing completes', async () => { + const callOrder: string[] = []; + const onUnblockListing = vi.fn(async () => { + callOrder.push('onUnblockListing'); + return Promise.resolve(); + }); + const setUnblockModalVisible: (visible: boolean) => void = vi.fn(() => { + callOrder.push('setUnblockModalVisible'); + }); + + const handleUnblockConfirm = async () => { + await onUnblockListing(); + setUnblockModalVisible(false); + }; + + await handleUnblockConfirm(); + + expect(callOrder).toEqual(['onUnblockListing', 'setUnblockModalVisible']); + }); + + it('should not close modal if onUnblockListing throws error', async () => { + const onUnblockListing = vi.fn(async () => { + throw new Error('Unblock failed'); + }); + const setUnblockModalVisible: (visible: boolean) => void = vi.fn(); + + const handleUnblockConfirm = async () => { + try { + await onUnblockListing(); + setUnblockModalVisible(false); + } catch (error) { + // Error handling + } + }; + + await handleUnblockConfirm(); + + expect(onUnblockListing).toHaveBeenCalled(); + // Modal should not close on error + expect(setUnblockModalVisible).not.toHaveBeenCalled(); + }); + }); + + // ==================== Container Styling Tests ==================== + describe('Container Styling', () => { + const containerStyles = { + paddingLeft: 100, + paddingRight: 100, + paddingTop: 50, + paddingBottom: 75, + boxSizing: 'border-box' as const, + width: '100%', + }; + + it('should have correct padding values', () => { + expect(containerStyles.paddingLeft).toBe(100); + expect(containerStyles.paddingRight).toBe(100); + expect(containerStyles.paddingTop).toBe(50); + expect(containerStyles.paddingBottom).toBe(75); + }); + + it('should use border-box sizing', () => { + expect(containerStyles.boxSizing).toBe('border-box'); + }); + + it('should span full width', () => { + expect(containerStyles.width).toBe('100%'); + }); + + it('should apply opacity when listing is blocked and user is not admin', () => { + const isBlocked = true; + const isAdmin = false; + + const opacity = isBlocked && !isAdmin ? 0.5 : 1; + expect(opacity).toBe(0.5); + }); + + it('should apply full opacity when listing is not blocked', () => { + const isBlocked = false; + const isAdmin = false; + + const opacity = isBlocked && !isAdmin ? 0.5 : 1; + expect(opacity).toBe(1); + }); + + it('should apply full opacity when user is admin', () => { + const isBlocked = true; + const isAdmin = true; + + const opacity = isBlocked && !isAdmin ? 0.5 : 1; + expect(opacity).toBe(1); + }); + + it('should disable pointer events when listing is blocked and user is not admin', () => { + const isBlocked = true; + const isAdmin = false; + + const pointerEvents = isBlocked && !isAdmin ? 'none' : 'auto'; + expect(pointerEvents).toBe('none'); + }); + + it('should enable pointer events when listing is not blocked', () => { + const isBlocked = false; + const isAdmin = false; + + const pointerEvents = isBlocked && !isAdmin ? 'none' : 'auto'; + expect(pointerEvents).toBe('auto'); + }); + + it('should enable pointer events when user is admin', () => { + const isBlocked = true; + const isAdmin = true; + + const pointerEvents = isBlocked && !isAdmin ? 'none' : 'auto'; + expect(pointerEvents).toBe('auto'); + }); + }); + + // ==================== Blocked Listing Alert Tests ==================== + describe('Blocked Listing Alert Component', () => { + it('should render alert when listing is blocked', () => { + const isBlocked = true; + + expect(isBlocked).toBe(true); + }); + + it('should not render alert when listing is not blocked', () => { + const isBlocked = false; + + expect(isBlocked).toBe(false); + }); + + it('alert should display correct message', () => { + const alertMessage = 'This listing is currently blocked'; + expect(alertMessage).toBe('This listing is currently blocked'); + }); + + it('alert should display correct description', () => { + const alertDescription = + 'This listing has been blocked by an administrator and is not visible to regular users.'; + expect(alertDescription).toContain('blocked by an administrator'); + }); + + it('alert should have error type styling', () => { + const alertType = 'error'; + expect(alertType).toBe('error'); + }); + + it('alert should show icon', () => { + const showIcon = true; + expect(showIcon).toBe(true); + }); + + it('should render in Col with span={24}', () => { + const colSpan = 24; + expect(colSpan).toBe(24); + }); + }); + + // ==================== Admin Block/Unblock Buttons Tests ==================== + describe('Admin Block/Unblock Buttons', () => { + it('should only render when isAdmin is true', () => { + const isAdmin = true; + expect(isAdmin).toBe(true); + }); + + it('should not render when isAdmin is false', () => { + const isAdmin = false; + expect(isAdmin).toBe(false); + }); + + it('should render unblock button when listing is blocked', () => { + const isBlocked = true; + const isAdmin = true; + + const shouldRenderUnblock = isAdmin && isBlocked; + expect(shouldRenderUnblock).toBe(true); + }); + + it('should render block button when listing is not blocked', () => { + const isBlocked = false; + const isAdmin = true; + + const shouldRenderBlock = isAdmin && !isBlocked; + expect(shouldRenderBlock).toBe(true); + }); + + it('should not render both buttons simultaneously', () => { + const isBlocked = true; + const isAdmin = true; + + const showBlock = isAdmin && !isBlocked; + const showUnblock = isAdmin && isBlocked; + + expect(showBlock && showUnblock).toBe(false); + }); + }); + + // ==================== Block Button Tests ==================== + describe('Block Button', () => { + it('should have danger styling', () => { + const buttonType = 'danger'; + expect(buttonType).toBe('danger'); + }); + + it('should display correct label', () => { + const buttonLabel = 'Block Listing'; + expect(buttonLabel).toBe('Block Listing'); + }); + + it('should show loading state when blockLoading is true', () => { + const blockLoading = true; + expect(blockLoading).toBe(true); + }); + + it('should not show loading state when blockLoading is false', () => { + const blockLoading = false; + expect(blockLoading).toBe(false); + }); + + it('should trigger setBlockModalVisible when clicked', () => { + const setBlockModalVisible = vi.fn(); + const handleClick = () => setBlockModalVisible(true); + + handleClick(); + + expect(setBlockModalVisible).toHaveBeenCalledWith(true); + expect(setBlockModalVisible).toHaveBeenCalledTimes(1); + }); + + it('should show loading indicator when block operation is in progress', () => { + const blockLoading = true; + + const isLoadingExpected = blockLoading === true; + expect(isLoadingExpected).toBe(true); + }); + + it('should be disabled when blockLoading is true', () => { + const blockLoading = true; + const isDisabled = blockLoading; + + expect(isDisabled).toBe(true); + }); + }); + + // ==================== Unblock Button Tests ==================== + describe('Unblock Button', () => { + it('should have primary styling', () => { + const buttonType = 'primary'; + expect(buttonType).toBe('primary'); + }); + + it('should display correct label', () => { + const buttonLabel = 'Unblock Listing'; + expect(buttonLabel).toBe('Unblock Listing'); + }); + + it('should show loading state when unblockLoading is true', () => { + const unblockLoading = true; + expect(unblockLoading).toBe(true); + }); + + it('should not show loading state when unblockLoading is false', () => { + const unblockLoading = false; + expect(unblockLoading).toBe(false); + }); + + it('should trigger setUnblockModalVisible when clicked', () => { + const setUnblockModalVisible = vi.fn(); + const handleClick = () => setUnblockModalVisible(true); + + handleClick(); + + expect(setUnblockModalVisible).toHaveBeenCalledWith(true); + expect(setUnblockModalVisible).toHaveBeenCalledTimes(1); + }); + + it('should show loading indicator when unblock operation is in progress', () => { + const unblockLoading = true; + + const isLoadingExpected = unblockLoading === true; + expect(isLoadingExpected).toBe(true); + }); + + it('should be disabled when unblockLoading is true', () => { + const unblockLoading = true; + const isDisabled = unblockLoading; + + expect(isDisabled).toBe(true); + }); + }); + + // ==================== Button Layout Tests ==================== + describe('Block/Unblock Buttons Layout', () => { + it('should be in a flex container', () => { + const display = 'flex'; + expect(display).toBe('flex'); + }); + + it('should have correct gap between buttons', () => { + const gap = 8; + expect(gap).toBe(8); + }); + + it('should justify content to flex-end (right align)', () => { + const justifyContent = 'flex-end'; + expect(justifyContent).toBe('flex-end'); + }); + + it('should be full width column (span={24})', () => { + const colSpan = 24; + expect(colSpan).toBe(24); + }); + }); + + // ==================== State Management Tests ==================== + describe('Modal State Management', () => { + it('should initialize blockModalVisible to false', () => { + const blockModalVisible = false; + expect(blockModalVisible).toBe(false); + }); + + it('should initialize unblockModalVisible to false', () => { + const unblockModalVisible = false; + expect(unblockModalVisible).toBe(false); + }); + + it('should toggle blockModalVisible to true on block button click', () => { + const setBlockModalVisible: (visible: boolean) => void = vi.fn(); + setBlockModalVisible(true); + + expect(setBlockModalVisible).toHaveBeenCalledWith(true); + }); + + it('should toggle unblockModalVisible to true on unblock button click', () => { + const setUnblockModalVisible: (visible: boolean) => void = vi.fn(); + setUnblockModalVisible(true); + + expect(setUnblockModalVisible).toHaveBeenCalledWith(true); + }); + + it('should close blockModalVisible after handleBlockConfirm', async () => { + const setBlockModalVisible: (visible: boolean) => void = vi.fn(); + const onBlockListing = vi.fn(async () => Promise.resolve()); + + const handleBlockConfirm = async () => { + await onBlockListing(); + setBlockModalVisible(false); + }; + + await handleBlockConfirm(); + + expect(setBlockModalVisible).toHaveBeenCalledWith(false); + }); + + it('should close unblockModalVisible after handleUnblockConfirm', async () => { + const setUnblockModalVisible: (visible: boolean) => void = vi.fn(); + const onUnblockListing = vi.fn(async () => Promise.resolve()); + + const handleUnblockConfirm = async () => { + await onUnblockListing(); + setUnblockModalVisible(false); + }; + + await handleUnblockConfirm(); + + expect(setUnblockModalVisible).toHaveBeenCalledWith(false); + }); + }); + + // ==================== Edge Cases Tests ==================== + describe('Edge Cases', () => { + it('should handle null onBlockListing gracefully', async () => { + const onBlockListing: (() => Promise) | null = null; + const setBlockModalVisible: (visible: boolean) => void = vi.fn(); + + const handleBlockConfirm = async () => { + if (onBlockListing !== null) { + await (onBlockListing as () => Promise)(); + } + setBlockModalVisible(false); + }; + + await handleBlockConfirm(); + expect(setBlockModalVisible).toHaveBeenCalledWith(false); + }); + + it('should handle null onUnblockListing gracefully', async () => { + const onUnblockListing: (() => Promise) | null = null; + const setUnblockModalVisible: (visible: boolean) => void = vi.fn(); + + const handleUnblockConfirm = async () => { + if (onUnblockListing !== null) { + await (onUnblockListing as () => Promise)(); + } + setUnblockModalVisible(false); + }; + + await handleUnblockConfirm(); + expect(setUnblockModalVisible).toHaveBeenCalledWith(false); + }); + + it('should handle rapid modal open/close cycles', () => { + const setBlockModalVisible: (visible: boolean) => void = vi.fn(); + + setBlockModalVisible(true); + setBlockModalVisible(false); + setBlockModalVisible(true); + setBlockModalVisible(false); + + expect(setBlockModalVisible).toHaveBeenCalledTimes(4); + }); + + it('should maintain state consistency when isBlocked changes', () => { + const states = [ + { isBlocked: false, isAdmin: true, showUnblock: false }, + { isBlocked: true, isAdmin: true, showUnblock: true }, + { isBlocked: false, isAdmin: true, showUnblock: false }, + ]; + + states.forEach((state) => { + const shouldShowUnblock = state.isAdmin && state.isBlocked; + expect(shouldShowUnblock).toBe(state.showUnblock); + }); + }); + + it('should handle simultaneous block and unblock events', async () => { + const onBlockListing = vi.fn(async () => Promise.resolve()); + const onUnblockListing = vi.fn(async () => Promise.resolve()); + + await Promise.all([ + onBlockListing(), + onUnblockListing(), + ]); + + expect(onBlockListing).toHaveBeenCalledTimes(1); + expect(onUnblockListing).toHaveBeenCalledTimes(1); + }); + }); + + // ==================== Integration Tests ==================== + describe('Integration Scenarios', () => { + it('should complete full block flow: click button -> modal opens -> confirm -> modal closes', async () => { + const setBlockModalVisible: (visible: boolean) => void = vi.fn(); + const onBlockListing = vi.fn(async () => Promise.resolve()); + + // Step 1: Click block button + setBlockModalVisible(true); + expect(setBlockModalVisible).toHaveBeenCalledWith(true); + + // Step 2: Confirm in modal + const handleBlockConfirm = async () => { + await onBlockListing(); + setBlockModalVisible(false); + }; + + await handleBlockConfirm(); + + // Step 3: Modal closes + expect(setBlockModalVisible).toHaveBeenLastCalledWith(false); + expect(onBlockListing).toHaveBeenCalledTimes(1); + }); + + it('should complete full unblock flow: click button -> modal opens -> confirm -> modal closes', async () => { + const setUnblockModalVisible: (visible: boolean) => void = vi.fn(); + const onUnblockListing = vi.fn(async () => Promise.resolve()); + + // Step 1: Click unblock button + setUnblockModalVisible(true); + expect(setUnblockModalVisible).toHaveBeenCalledWith(true); + + // Step 2: Confirm in modal + const handleUnblockConfirm = async () => { + await onUnblockListing(); + setUnblockModalVisible(false); + }; + + await handleUnblockConfirm(); + + // Step 3: Modal closes + expect(setUnblockModalVisible).toHaveBeenLastCalledWith(false); + expect(onUnblockListing).toHaveBeenCalledTimes(1); + }); + + it('should show blocked listing UI to non-admin users', () => { + const isBlocked = true; + const isAdmin = false; + + const opacity = isBlocked && !isAdmin ? 0.5 : 1; + const pointerEvents = isBlocked && !isAdmin ? 'none' : 'auto'; + const shouldShowAlert = isBlocked; + const shouldShowButtons = isAdmin; + + expect(opacity).toBe(0.5); + expect(pointerEvents).toBe('none'); + expect(shouldShowAlert).toBe(true); + expect(shouldShowButtons).toBe(false); + }); + + it('should show full access UI to admin users on blocked listing', () => { + const isBlocked = true; + const isAdmin = true; + + const opacity = isBlocked && !isAdmin ? 0.5 : 1; + const pointerEvents = isBlocked && !isAdmin ? 'none' : 'auto'; + const shouldShowAlert = isBlocked; + const shouldShowUnblockButton = isAdmin && isBlocked; + + expect(opacity).toBe(1); + expect(pointerEvents).toBe('auto'); + expect(shouldShowAlert).toBe(true); + expect(shouldShowUnblockButton).toBe(true); + }); + }); +}); diff --git a/apps/ui-sharethrift/src/components/layouts/home/components/view-listing/view-listing.stories.tsx b/apps/ui-sharethrift/src/components/layouts/home/components/view-listing/view-listing.stories.tsx index 953709315..8a83024c9 100644 --- a/apps/ui-sharethrift/src/components/layouts/home/components/view-listing/view-listing.stories.tsx +++ b/apps/ui-sharethrift/src/components/layouts/home/components/view-listing/view-listing.stories.tsx @@ -1,5 +1,4 @@ import type { Meta, StoryObj } from '@storybook/react'; -import { ViewListing } from './view-listing.tsx'; import { type ItemListing, ViewListingImageGalleryGetImagesDocument, @@ -9,6 +8,8 @@ import { withMockApolloClient, withMockRouter, } from '../../../../../test-utils/storybook-decorators.tsx'; +import { ViewListing } from './view-listing.tsx'; + // Local mock listing data (removed dependency on DUMMY_LISTINGS) const baseListingId = 'mock-listing-id-1'; const MOCK_LISTING_BASE: ItemListing = { @@ -46,7 +47,7 @@ const MOCK_LISTING_BASE: ItemListing = { schemaVersion: '1.0', userType: 'personal-user', }, - listingType: 'item-listing', + listingType: 'item-listing', }; const mocks = [ @@ -107,20 +108,37 @@ type Story = StoryObj; const baseListing = MOCK_LISTING_BASE; +// Shared admin configuration +const adminBaseArgs = { + isAdmin: true, + isAuthenticated: true, + currentUserId: 'mock-admin-id', +}; + +// Blocked listing variant +const adminBlockedListing = { + ...baseListing, + state: 'Blocked' as string, +}; + export const Default: Story = { args: { listing: baseListing, userIsSharer: false, isAuthenticated: false, + currentUserId: '', userReservationRequest: null, sharedTimeAgo: '2 days ago', - }}; + isAdmin: false, + }, +}; export const AsReserver: Story = { args: { ...Default.args, userIsSharer: false, isAuthenticated: true, + currentUserId: 'mock-reserver-id', userReservationRequest: null, }, }; @@ -130,6 +148,39 @@ export const AsOwner: Story = { ...Default.args, userIsSharer: true, isAuthenticated: true, + currentUserId: 'mock-sharer-id', userReservationRequest: null, }, }; + +export const AsAdmin: Story = { + args: { + ...Default.args, + ...adminBaseArgs, + listing: baseListing, + }, +}; + +export const BlockedListingAsAdmin: Story = { + args: { + ...Default.args, + ...adminBaseArgs, + listing: adminBlockedListing, + }, +}; + +export const BlockListing: Story = { + args: { + ...Default.args, + ...adminBaseArgs, + listing: baseListing, + }, +}; + +export const UnblockListing: Story = { + args: { + ...Default.args, + ...adminBaseArgs, + listing: adminBlockedListing, + }, +}; diff --git a/apps/ui-sharethrift/src/components/layouts/home/components/view-listing/view-listing.tsx b/apps/ui-sharethrift/src/components/layouts/home/components/view-listing/view-listing.tsx index 225bd910b..8dd69377d 100644 --- a/apps/ui-sharethrift/src/components/layouts/home/components/view-listing/view-listing.tsx +++ b/apps/ui-sharethrift/src/components/layouts/home/components/view-listing/view-listing.tsx @@ -1,12 +1,20 @@ -import { Row, Col, Button } from 'antd'; +import { Alert, Button, Col, Row } from 'antd'; import { LeftOutlined } from '@ant-design/icons'; +import { useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { useMutation } from '@apollo/client/react'; +import { message } from 'antd'; import { ListingImageGalleryContainer } from './listing-image-gallery/listing-image-gallery.container.tsx'; import { SharerInformationContainer } from './sharer-information/sharer-information.container.tsx'; import { ListingInformationContainer } from './listing-information/listing-information.container.tsx'; +import { BlockInformationModal } from './block-information-modal.tsx'; +import { AppealConfirmationModal } from './appeal-confirmation-modal.tsx'; import type { ItemListing, ViewListingActiveReservationRequestForListingQuery, + ViewListingAppealRequestByListingIdQuery, } from '../../../../../generated.tsx'; +import { HomeListingInformationCreateListingAppealRequestDocument } from '../../../../../generated.tsx'; interface ViewListingProps { listing: ItemListing; @@ -16,7 +24,13 @@ interface ViewListingProps { userReservationRequest: | ViewListingActiveReservationRequestForListingQuery['myActiveReservationForListing'] | null; + appealRequest: + | ViewListingAppealRequestByListingIdQuery['getListingAppealRequestByListingId'] + | null + | undefined; + onAppealRequestSuccess?: () => void; sharedTimeAgo?: string; + isAdmin: boolean; } export const ViewListing: React.FC = ({ @@ -25,15 +39,74 @@ export const ViewListing: React.FC = ({ isAuthenticated, currentUserId, userReservationRequest, + appealRequest, + onAppealRequestSuccess, sharedTimeAgo, + isAdmin, }) => { - // Mock sharer info (since ItemListing.sharer is just an ID) - const sharer = listing.sharer; + const navigate = useNavigate(); + const [blockInfoModalVisible, setBlockInfoModalVisible] = useState(false); + const [appealConfirmModalVisible, setAppealConfirmModalVisible] = + useState(false); + const [appealSuccess, setAppealSuccess] = useState(false); + + const [createAppealRequest, { loading: appealLoading }] = useMutation( + HomeListingInformationCreateListingAppealRequestDocument, + { + onCompleted: () => { + setAppealSuccess(true); + setAppealConfirmModalVisible(false); + setBlockInfoModalVisible(false); + message.success('Appeal requested successfully.'); + onAppealRequestSuccess?.(); + }, + onError: (error) => { + message.error(`Failed to submit appeal: ${error.message}`); + }, + }, + ); + + const { sharer } = listing; + + const isBlocked = listing.state === 'Blocked'; + const appealRequested = appealRequest?.state === 'REQUESTED'; const handleBack = () => { window.location.href = '/'; }; + const handleEditListing = () => { + navigate(`/create-listing/${listing.id}`); + }; + + const handleAppealBlock = () => { + setBlockInfoModalVisible(false); + setAppealConfirmModalVisible(true); + }; + + const handleAppealConfirm = async () => { + if (!currentUserId || !listing.id) { + message.error('Unable to submit appeal. Please try again.'); + return; + } + + // TODO: SECURITY - Get actual blocker ID from listing block metadata + // For now, using a placeholder. The blocker should be tracked when blocking occurs. + const blockerId = listing.sharer?.id || currentUserId; + + await createAppealRequest({ + variables: { + input: { + userId: currentUserId, + listingId: listing.id, + reason: 'User is appealing the block on this listing', + blockerId, + }, + }, + }); + }; + + return ( <>