diff --git a/admin-ui/src/gql/types.ts b/admin-ui/src/gql/types.ts index 5c8ba6fe61..bb5c305a04 100644 --- a/admin-ui/src/gql/types.ts +++ b/admin-ui/src/gql/types.ts @@ -328,8 +328,7 @@ export type IColor = { }; export type IConfigurableOrBundleProduct = - | IBundleProduct - | IConfigurableProduct; + IBundleProduct | IConfigurableProduct; /** Configurable Product (Proxy) */ export type IConfigurableProduct = IProduct & { @@ -924,35 +923,6 @@ export type IGeoPosition = { longitude: Scalars['Float']['output']; }; -export type IGlobalSearchResponse = { - counts: Array; - results: Array; -}; - -export type IGlobalSearchResult = - | IAssortment - | IBundleProduct - | IConfigurableProduct - | IEnrollment - | IFilter - | IOrder - | IPlanProduct - | IQuotation - | ISimpleProduct - | ITokenizedProduct - | IUser - | IWork; - -export type IGlobalSearchTypeCount = { - totalCount: Scalars['Int']['output']; - type: ISearchableEntity; -}; - -export type IGlobalSearchTypeLimitInput = { - limit: Scalars['Int']['input']; - type: ISearchableEntity; -}; - export type ILanguage = { _id: Scalars['ID']['output']; isActive?: Maybe; @@ -1062,12 +1032,28 @@ export type IMutation = { * Optional worker to identify the worker. */ allocateWork?: Maybe; + /** + * Authenticate gate control by validating a pass code and setting an HttpOnly cookie. + * Returns true if the pass code is valid. + */ + authenticateGate: Scalars['Boolean']['output']; /** * Toggle Bookmark state on a product as currently logged in user, * Does not work when multiple bookmarks with different explicit meta configurations exist. * In those cases please use createBookmark and removeBookmark */ bookmark: IBookmark; + /** + * Cancel all tickets for an event (tokenized product). Invalidates all non-cancelled tokens. + * Optionally generates discount codes for affected users. + * Returns the number of tickets cancelled. + */ + cancelEvent: Scalars['Int']['output']; + /** + * Cancel a ticket (token). Sets the cancelled flag on the token metadata. + * Optionally generates a discount code for reimbursement. + */ + cancelTicket: IToken; /** Change the current user's password. Must be logged in. */ changePassword?: Maybe; /** @@ -1119,6 +1105,8 @@ export type IMutation = { createWebAuthnCredentialCreationOptions?: Maybe; /** Create WebAuthn PublicKeyCredentialRequestrOptions to use for WebAuthn Login Flow */ createWebAuthnCredentialRequestOptions?: Maybe; + /** Deauthenticate gate control by clearing the gate pass code cookie. */ + deauthenticateGate: Scalars['Boolean']['output']; /** Manually mark a undelivered order as delivered */ deliverOrder: IOrder; /** @@ -1284,6 +1272,11 @@ export type IMutation = { sendEnrollmentEmail?: Maybe; /** Send an email with a link the user can use verify their email address. */ sendVerificationEmail?: Maybe; + /** + * Set or remove the scanner pass code for gate control on a tokenized product. + * Pass null to remove the pass code. + */ + setEventScannerPassCode: IProduct; /** Set a new password for a specific user */ setPassword: IUser; /** Set roles of a user */ @@ -1472,11 +1465,25 @@ export type IMutationAllocateWorkArgs = { worker?: InputMaybe; }; +export type IMutationAuthenticateGateArgs = { + passCode: Scalars['String']['input']; +}; + export type IMutationBookmarkArgs = { bookmarked?: InputMaybe; productId: Scalars['ID']['input']; }; +export type IMutationCancelEventArgs = { + generateDiscount?: InputMaybe; + productId: Scalars['ID']['input']; +}; + +export type IMutationCancelTicketArgs = { + generateDiscount?: InputMaybe; + tokenId: Scalars['ID']['input']; +}; + export type IMutationChangePasswordArgs = { newPassword: Scalars['String']['input']; oldPassword: Scalars['String']['input']; @@ -1892,6 +1899,11 @@ export type IMutationSendVerificationEmailArgs = { email?: InputMaybe; }; +export type IMutationSetEventScannerPassCodeArgs = { + passCode?: InputMaybe; + productId: Scalars['ID']['input']; +}; + export type IMutationSetPasswordArgs = { newPassword: Scalars['String']['input']; userId: Scalars['ID']['input']; @@ -2862,10 +2874,13 @@ export type IQuery = { filters: Array; /** Returns total number of filters */ filtersCount: Scalars['Int']['output']; - /** Search across multiple entity types in a single request */ - globalSearch: IGlobalSearchResponse; /** User impersonating currently logged in user */ impersonator?: Maybe; + /** + * Validates a scanner pass code for gate access. Pass code is read from the unchained_gate_passcode cookie (set via authenticateGate mutation). + * Optionally restricted to a specific product. + */ + isPassCodeValid: Scalars['Boolean']['output']; /** Get a specific language */ language?: Maybe; /** Get all languages, by default sorted by creation date (ascending) */ @@ -2925,6 +2940,10 @@ export type IQuery = { searchProducts: IProductSearchResult; /** Get shop-global data and the resolved country/language pair */ shopInfo: IShop; + /** List all ticket events (tokenized products), by default includes drafts */ + ticketEvents: Array; + /** Returns total number of ticket events (tokenized products) */ + ticketEventsCount: Scalars['Int']['output']; /** Get token */ token?: Maybe; /** Get all tokens */ @@ -3103,16 +3122,8 @@ export type IQueryFiltersCountArgs = { queryString?: InputMaybe; }; -export type IQueryGlobalSearchArgs = { - includeCarts?: InputMaybe; - includeDraftProducts?: InputMaybe; - includeGuestUsers?: InputMaybe; - includeInactiveAssortments?: InputMaybe; - includeInactiveFilters?: InputMaybe; - limit?: InputMaybe; - query: Scalars['String']['input']; - typeLimits?: InputMaybe>; - types?: InputMaybe>; +export type IQueryIsPassCodeValidArgs = { + productId?: InputMaybe; }; export type IQueryLanguageArgs = { @@ -3209,6 +3220,7 @@ export type IQueryProductsArgs = { slugs?: InputMaybe>; sort?: InputMaybe>; tags?: InputMaybe>; + type?: InputMaybe; }; export type IQueryProductsCountArgs = { @@ -3216,6 +3228,7 @@ export type IQueryProductsCountArgs = { queryString?: InputMaybe; slugs?: InputMaybe>; tags?: InputMaybe>; + type?: InputMaybe; }; export type IQueryQuotationArgs = { @@ -3249,6 +3262,21 @@ export type IQuerySearchProductsArgs = { queryString?: InputMaybe; }; +export type IQueryTicketEventsArgs = { + includeDrafts?: InputMaybe; + limit?: InputMaybe; + offset?: InputMaybe; + onlyInvalidateable?: InputMaybe; + queryString?: InputMaybe; + sort?: InputMaybe>; +}; + +export type IQueryTicketEventsCountArgs = { + includeDrafts?: InputMaybe; + onlyInvalidateable?: InputMaybe; + queryString?: InputMaybe; +}; + export type IQueryTokenArgs = { tokenId: Scalars['ID']['input']; }; @@ -3431,18 +3459,21 @@ export type IReorderProductMediaInput = { }; export enum IRoleAction { + AddCartQuotation = 'addCartQuotation', AnswerQuotation = 'answerQuotation', BookmarkProduct = 'bookmarkProduct', BulkImport = 'bulkImport', ChangePassword = 'changePassword', CheckoutCart = 'checkoutCart', ConfirmMediaUpload = 'confirmMediaUpload', + CreateBookmark = 'createBookmark', CreateCart = 'createCart', CreateEnrollment = 'createEnrollment', CreateUser = 'createUser', DownloadFile = 'downloadFile', EnrollUser = 'enrollUser', ForgotPassword = 'forgotPassword', + GateControl = 'gateControl', Heartbeat = 'heartbeat', Impersonate = 'impersonate', LoginAsGuest = 'loginAsGuest', @@ -3492,6 +3523,7 @@ export enum IRoleAction { UploadTempFile = 'uploadTempFile', UploadUserAvatar = 'uploadUserAvatar', UseWebAuthn = 'useWebAuthn', + ValidatePassCode = 'validatePassCode', VerifyEmail = 'verifyEmail', ViewAssortment = 'viewAssortment', ViewAssortments = 'viewAssortments', @@ -3562,17 +3594,6 @@ export type ISearchResultProductsArgs = { offset?: InputMaybe; }; -export enum ISearchableEntity { - Assortment = 'ASSORTMENT', - Enrollment = 'ENROLLMENT', - Filter = 'FILTER', - Order = 'ORDER', - Product = 'PRODUCT', - Quotation = 'QUOTATION', - User = 'USER', - Work = 'WORK', -} - export type IShop = { _id: Scalars['ID']['output']; adminUiConfig: IAdminUiConfig; @@ -3711,6 +3732,7 @@ export type IToken = { ercMetadata?: Maybe; expiryDate?: Maybe; invalidatedDate?: Maybe; + isCanceled?: Maybe; isInvalidateable: Scalars['Boolean']['output']; product: ITokenizedProduct; quantity: Scalars['Int']['output']; @@ -3739,12 +3761,14 @@ export type ITokenizedProduct = IProduct & { contractConfiguration?: Maybe; contractStandard?: Maybe; created?: Maybe; + isCanceled?: Maybe; leveledCatalogPrices: Array; media: Array; proxies: Array; published?: Maybe; reviews: Array; reviewsCount: Scalars['Int']['output']; + scannerPassCode?: Maybe; sequence: Scalars['Int']['output']; siblings: Array; simulatedPrice?: Maybe; @@ -11776,6 +11800,7 @@ export type ITokenFragment = { ercMetadata?: any | null; accessKey: string; isInvalidateable: boolean; + isCanceled?: boolean | null; }; export type ITokenFragmentVariables = Exact<{ [key: string]: never }>; @@ -12227,6 +12252,7 @@ export type IExportTokenMutation = { ercMetadata?: any | null; accessKey: string; isInvalidateable: boolean; + isCanceled?: boolean | null; }; }; @@ -12518,6 +12544,9 @@ export type IProductQuery = { >; } | { + tokensCount: number; + isCanceled?: boolean | null; + scannerPassCode?: string | null; _id: string; sequence: number; status: IProductStatus; @@ -12531,6 +12560,39 @@ export type IProductQuery = { subtitle?: string | null; description?: string | null; } | null; + contractConfiguration?: { + ercMetadataProperties?: any | null; + supply: number; + } | null; + simulatedStocks?: Array<{ quantity?: number | null }> | null; + tokens: Array<{ + _id: string; + tokenSerialNumber?: string | null; + isCanceled?: boolean | null; + invalidatedDate?: any | null; + isInvalidateable: boolean; + quantity: number; + status: ITokenExportStatus; + walletAddress?: string | null; + user?: { + _id: string; + username?: string | null; + isGuest: boolean; + primaryEmail?: { address: string; verified: boolean } | null; + avatar?: { _id: string; url?: string | null } | null; + profile?: { + displayName?: string | null; + address?: { + firstName?: string | null; + lastName?: string | null; + } | null; + } | null; + lastContact?: { + emailAddress?: string | null; + telNumber?: string | null; + } | null; + } | null; + }>; media: Array<{ _id: string; tags?: Array | null; @@ -14895,6 +14957,7 @@ export type IUserTokensQuery = { ercMetadata?: any | null; accessKey: string; isInvalidateable: boolean; + isCanceled?: boolean | null; product: { _id: string; sequence: number; @@ -15770,6 +15833,211 @@ export type IVerifyQuotationMutation = { }; }; +export type ICancelEventMutationVariables = Exact<{ + productId: Scalars['ID']['input']; + generateDiscount?: InputMaybe; +}>; + +export type ICancelEventMutation = { cancelEvent: number }; + +export type ICancelTicketMutationVariables = Exact<{ + tokenId: Scalars['ID']['input']; + generateDiscount?: InputMaybe; +}>; + +export type ICancelTicketMutation = { + cancelTicket: { + _id: string; + isCanceled?: boolean | null; + invalidatedDate?: any | null; + isInvalidateable: boolean; + tokenSerialNumber?: string | null; + }; +}; + +export type ICheckGateCookieQueryVariables = Exact<{ [key: string]: never }>; + +export type ICheckGateCookieQuery = { isPassCodeValid: boolean }; + +export type ITicketEventsQueryVariables = Exact<{ + queryString?: InputMaybe; + limit?: InputMaybe; + offset?: InputMaybe; + includeDrafts?: InputMaybe; + forceLocale?: InputMaybe; +}>; + +export type ITicketEventsQuery = { + ticketEventsCount: number; + ticketEvents: Array< + | { + _id: string; + status: IProductStatus; + tags?: Array | null; + updated?: any | null; + published?: any | null; + } + | { + _id: string; + status: IProductStatus; + tags?: Array | null; + updated?: any | null; + published?: any | null; + } + | { + _id: string; + status: IProductStatus; + tags?: Array | null; + updated?: any | null; + published?: any | null; + } + | { + _id: string; + status: IProductStatus; + tags?: Array | null; + updated?: any | null; + published?: any | null; + } + | { + tokensCount: number; + isCanceled?: boolean | null; + _id: string; + status: IProductStatus; + tags?: Array | null; + updated?: any | null; + published?: any | null; + texts?: { + _id: string; + slug?: string | null; + title?: string | null; + subtitle?: string | null; + description?: string | null; + } | null; + media: Array<{ + _id: string; + file?: { _id: string; url?: string | null; name: string } | null; + }>; + contractConfiguration?: { + ercMetadataProperties?: any | null; + supply: number; + } | null; + simulatedStocks?: Array<{ quantity?: number | null }> | null; + } + >; +}; + +export type IGateEventDetailQueryVariables = Exact<{ + productId: Scalars['ID']['input']; +}>; + +export type IGateEventDetailQuery = { + product?: + | { _id: string } + | { _id: string } + | { _id: string } + | { _id: string } + | { + isCanceled?: boolean | null; + _id: string; + texts?: { + _id: string; + title?: string | null; + subtitle?: string | null; + } | null; + contractConfiguration?: { + ercMetadataProperties?: any | null; + supply: number; + } | null; + tokens: Array<{ + _id: string; + tokenSerialNumber?: string | null; + isCanceled?: boolean | null; + invalidatedDate?: any | null; + isInvalidateable: boolean; + ercMetadata?: any | null; + user?: { + _id: string; + username?: string | null; + isGuest: boolean; + primaryEmail?: { address: string; verified: boolean } | null; + avatar?: { _id: string; url?: string | null } | null; + profile?: { + displayName?: string | null; + address?: { + firstName?: string | null; + lastName?: string | null; + } | null; + } | null; + lastContact?: { + emailAddress?: string | null; + telNumber?: string | null; + } | null; + } | null; + }>; + } + | null; +}; + +export type IGateEventsQueryVariables = Exact<{ + onlyInvalidateable: Scalars['Boolean']['input']; +}>; + +export type IGateEventsQuery = { + ticketEvents: Array< + | { _id: string; status: IProductStatus } + | { _id: string; status: IProductStatus } + | { _id: string; status: IProductStatus } + | { _id: string; status: IProductStatus } + | { + isCanceled?: boolean | null; + _id: string; + status: IProductStatus; + texts?: { + _id: string; + title?: string | null; + subtitle?: string | null; + } | null; + contractConfiguration?: { + ercMetadataProperties?: any | null; + supply: number; + } | null; + tokens: Array<{ + _id: string; + tokenSerialNumber?: string | null; + isCanceled?: boolean | null; + invalidatedDate?: any | null; + isInvalidateable: boolean; + }>; + } + >; +}; + +export type IAuthenticateGateMutationVariables = Exact<{ + passCode: Scalars['String']['input']; +}>; + +export type IAuthenticateGateMutation = { authenticateGate: boolean }; + +export type IDeauthenticateGateMutationVariables = Exact<{ + [key: string]: never; +}>; + +export type IDeauthenticateGateMutation = { deauthenticateGate: boolean }; + +export type ISetEventScannerPassCodeMutationVariables = Exact<{ + productId: Scalars['ID']['input']; + passCode?: InputMaybe; +}>; + +export type ISetEventScannerPassCodeMutation = { + setEventScannerPassCode: + | { _id: string } + | { _id: string } + | { _id: string } + | { _id: string } + | { scannerPassCode?: string | null; _id: string }; +}; + export type IInvalidateTokenMutationVariables = Exact<{ tokenId: Scalars['ID']['input']; }>; @@ -15788,6 +16056,7 @@ export type IInvalidateTokenMutation = { ercMetadata?: any | null; accessKey: string; isInvalidateable: boolean; + isCanceled?: boolean | null; }; }; @@ -15810,6 +16079,7 @@ export type ITokenQuery = { ercMetadata?: any | null; accessKey: string; isInvalidateable: boolean; + isCanceled?: boolean | null; product: { _id: string; sequence: number; @@ -15879,6 +16149,7 @@ export type ITokensQuery = { ercMetadata?: any | null; accessKey: string; isInvalidateable: boolean; + isCanceled?: boolean | null; product: { _id: string; sequence: number; diff --git a/admin-ui/src/modules/Auth/permissionConfig.ts b/admin-ui/src/modules/Auth/permissionConfig.ts index b8e859fde8..1da7969eaf 100644 --- a/admin-ui/src/modules/Auth/permissionConfig.ts +++ b/admin-ui/src/modules/Auth/permissionConfig.ts @@ -51,6 +51,7 @@ const UNRESTRICTED_PAGES = [ '/403', '/500', '/external', + '/ticketing/gate', ]; const PUBLIC_ONLY_PAGES = [ diff --git a/admin-ui/src/modules/accounts/components/LogInForm.tsx b/admin-ui/src/modules/accounts/components/LogInForm.tsx index 6e7dbec4f4..ffc3b173d8 100644 --- a/admin-ui/src/modules/accounts/components/LogInForm.tsx +++ b/admin-ui/src/modules/accounts/components/LogInForm.tsx @@ -302,6 +302,17 @@ const LogInForm = () => { })} +
+ + {intl.formatMessage({ + id: 'open_gate_control', + defaultMessage: 'Open Gate Control', + })} + +
diff --git a/admin-ui/src/modules/apollo/utils/createApolloClient.ts b/admin-ui/src/modules/apollo/utils/createApolloClient.ts index b0606f6ad4..246bfc56d7 100644 --- a/admin-ui/src/modules/apollo/utils/createApolloClient.ts +++ b/admin-ui/src/modules/apollo/utils/createApolloClient.ts @@ -84,7 +84,6 @@ const createApolloClient = ({ defaultOptions: { watchQuery: { fetchPolicy: 'cache-and-network', - errorPolicy: 'all', }, }, diff --git a/admin-ui/src/modules/common/components/Layout.tsx b/admin-ui/src/modules/common/components/Layout.tsx index 70b749b5ef..fda1e0a1be 100644 --- a/admin-ui/src/modules/common/components/Layout.tsx +++ b/admin-ui/src/modules/common/components/Layout.tsx @@ -15,6 +15,7 @@ import { CubeIcon, DocumentTextIcon, FolderArrowDownIcon, + TicketIcon, } from '@heroicons/react/24/outline'; import Link from 'next/link'; import React, { useState } from 'react'; @@ -236,6 +237,12 @@ const Layout = ({ href: '/tokens', requiredRole: 'viewTokens', }, + isSystemReady && { + name: formatMessage({ id: 'ticketing', defaultMessage: 'Ticketing' }), + icon: TicketIcon, + href: '/ticketing', + requiredRole: 'viewTokens', + }, { name: formatMessage({ id: 'system', defaultMessage: 'System settings' }), icon: Cog8ToothIcon, diff --git a/admin-ui/src/modules/order/components/OrderStatusBadge.tsx b/admin-ui/src/modules/order/components/OrderStatusBadge.tsx index 790cb28b89..04f3558350 100644 --- a/admin-ui/src/modules/order/components/OrderStatusBadge.tsx +++ b/admin-ui/src/modules/order/components/OrderStatusBadge.tsx @@ -4,12 +4,7 @@ import Badge from '@/components/ui/Badge'; import { ORDER_STATUSES } from '../../common/data/miscellaneous'; type OrderStatus = - | 'PENDING' - | 'CONFIRMED' - | 'OPEN' - | 'FULFILLED' - | 'REJECTED' - | string; + 'PENDING' | 'CONFIRMED' | 'OPEN' | 'FULFILLED' | 'REJECTED' | string; type OrderStatusBadgeProps = { status: OrderStatus; diff --git a/admin-ui/src/modules/product/fragments/TokenFragment.ts b/admin-ui/src/modules/product/fragments/TokenFragment.ts index ce0b9415e1..142262b9af 100644 --- a/admin-ui/src/modules/product/fragments/TokenFragment.ts +++ b/admin-ui/src/modules/product/fragments/TokenFragment.ts @@ -14,6 +14,7 @@ const TokenFragment = gql` ercMetadata accessKey isInvalidateable + isCanceled } `; diff --git a/admin-ui/src/modules/product/hooks/useProduct.ts b/admin-ui/src/modules/product/hooks/useProduct.ts index 8932c983be..b723771427 100644 --- a/admin-ui/src/modules/product/hooks/useProduct.ts +++ b/admin-ui/src/modules/product/hooks/useProduct.ts @@ -57,6 +57,58 @@ const GetProductQuery = (inlineFragment = '') => gql` __typename } } + ... on TokenizedProduct { + texts { + _id + title + subtitle + description + } + contractConfiguration { + ercMetadataProperties + supply + } + simulatedStocks { + quantity + } + tokensCount + isCanceled + scannerPassCode + tokens { + _id + tokenSerialNumber + isCanceled + invalidatedDate + isInvalidateable + quantity + status + walletAddress + user { + _id + username + isGuest + primaryEmail { + address + verified + } + avatar { + _id + url + } + profile { + displayName + address { + firstName + lastName + } + } + lastContact { + emailAddress + telNumber + } + } + } + } } } ${ProductDetailFragment} diff --git a/admin-ui/src/modules/ticketing/components/EventTokenList.tsx b/admin-ui/src/modules/ticketing/components/EventTokenList.tsx new file mode 100644 index 0000000000..78ced8f7bd --- /dev/null +++ b/admin-ui/src/modules/ticketing/components/EventTokenList.tsx @@ -0,0 +1,71 @@ +import { useIntl } from 'react-intl'; +import Table from '../../common/components/Table'; +import EventTokenListItem from './EventTokenListItem'; + +const EventTokenList = ({ tokens, onCancelTicket, onInvalidateTicket }) => { + const { formatMessage } = useIntl(); + + if (!tokens?.length) { + return ( +

+ {formatMessage({ + id: 'no_tickets_issued', + defaultMessage: 'No tickets have been issued yet.', + })} +

+ ); + } + + return ( + + + + {formatMessage({ + id: 'ticket_number', + defaultMessage: 'Ticket #', + })} + + + {formatMessage({ + id: 'attendee', + defaultMessage: 'Attendee', + })} + + + {formatMessage({ + id: 'email', + defaultMessage: 'E-Mail', + })} + + + {formatMessage({ + id: 'phone', + defaultMessage: 'Phone', + })} + + + {formatMessage({ + id: 'redeemed', + defaultMessage: 'Redeemed', + })} + + + {formatMessage({ + id: 'actions', + defaultMessage: 'Actions', + })} + + + {tokens.map((token) => ( + + ))} +
+ ); +}; + +export default EventTokenList; diff --git a/admin-ui/src/modules/ticketing/components/EventTokenListItem.tsx b/admin-ui/src/modules/ticketing/components/EventTokenListItem.tsx new file mode 100644 index 0000000000..a7c82b3bed --- /dev/null +++ b/admin-ui/src/modules/ticketing/components/EventTokenListItem.tsx @@ -0,0 +1,105 @@ +import Link from 'next/link'; +import { useIntl } from 'react-intl'; +import Table from '../../common/components/Table'; +import Badge from '../../../components/ui/Badge'; +import useFormatDateTime from '../../common/utils/useFormatDateTime'; +import formatUsername from '../../common/utils/formatUsername'; +import MediaAvatar from '../../common/components/MediaAvatar'; + +const EventTokenListItem = ({ token, onCancelTicket, onInvalidateTicket }) => { + const { formatMessage } = useIntl(); + const { formatDateTime } = useFormatDateTime(); + + return ( + + + + {token.tokenSerialNumber || token._id?.slice(-8)} + + + + {token.user && ( + + + {formatUsername(token.user)} + + )} + + + + {token.user?.lastContact?.emailAddress || + token.user?.primaryEmail?.address || + '-'} + + + + + {token.user?.lastContact?.telNumber || '-'} + + + + {token.invalidatedDate ? ( + + ) : ( + - + )} + + +
+ {token.isCanceled ? ( + + ) : ( + <> + {!token.invalidatedDate && ( + + )} + {token.isInvalidateable && !token.invalidatedDate && ( + + )} + + )} +
+
+
+ ); +}; + +export default EventTokenListItem; diff --git a/admin-ui/src/modules/ticketing/components/GateAttendeeList.tsx b/admin-ui/src/modules/ticketing/components/GateAttendeeList.tsx new file mode 100644 index 0000000000..b94d2c28dd --- /dev/null +++ b/admin-ui/src/modules/ticketing/components/GateAttendeeList.tsx @@ -0,0 +1,180 @@ +import { useCallback } from 'react'; +import { useIntl } from 'react-intl'; +import { toast } from 'react-toastify'; +import Table from '../../common/components/Table'; +import Badge from '../../../components/ui/Badge'; +import useFormatDateTime from '../../common/utils/useFormatDateTime'; +import formatUsername from '../../common/utils/formatUsername'; +import useInvalidateTicket from '../../token/hooks/useInvalidateTicket'; + +const GateAttendeeList = ({ event, onRefetch }) => { + const { formatMessage } = useIntl(); + const { formatDateTime } = useFormatDateTime(); + const { invalidateTicket } = useInvalidateTicket(); + + const slot = event?.contractConfiguration?.ercMetadataProperties?.slot; + const tokens = event?.tokens || []; + const activeTokens = tokens.filter((t) => !t.isCanceled); + const redeemedCount = activeTokens.filter((t) => t.invalidatedDate).length; + + const onRedeem = useCallback(async (tokenId: string) => { + try { + await invalidateTicket({ tokenId }); + toast.success( + formatMessage({ + id: 'gate_ticket_redeemed', + defaultMessage: 'Ticket redeemed successfully', + }), + ); + onRefetch?.(); + } catch (e) { + toast.error( + formatMessage({ + id: 'gate_redeem_error', + defaultMessage: + 'Could not redeem ticket. It may already be redeemed or not yet redeemable.', + }), + ); + } + }, []); + + return ( +
+
+
+ {slot && ( +

+ {formatDateTime(slot, { + dateStyle: 'full', + timeStyle: 'short', + })} +

+ )} +
+
+ + {redeemedCount} + + / {activeTokens.length} +

+ {formatMessage({ + id: 'gate_redeemed', + defaultMessage: 'redeemed', + })} +

+
+
+ + {!activeTokens.length ? ( +

+ {formatMessage({ + id: 'gate_no_tickets', + defaultMessage: 'No tickets for this event.', + })} +

+ ) : ( +
+ + + + {formatMessage({ + id: 'ticket_number', + defaultMessage: 'Ticket #', + })} + + + {formatMessage({ + id: 'attendee', + defaultMessage: 'Attendee', + })} + + + {formatMessage({ + id: 'email', + defaultMessage: 'E-Mail', + })} + + + {formatMessage({ + id: 'status', + defaultMessage: 'Status', + })} + + + {formatMessage({ + id: 'actions', + defaultMessage: 'Actions', + })} + + + {activeTokens.map((token) => ( + + + + {token.tokenSerialNumber || token._id?.slice(-8)} + + + + + {token.user ? formatUsername(token.user) : '-'} + + + + + {token.user?.lastContact?.emailAddress || + token.user?.primaryEmail?.address || + '-'} + + + + {token.invalidatedDate ? ( + + ) : ( + + )} + + + {!token.invalidatedDate && token.isInvalidateable && ( + + )} + {token.invalidatedDate && ( + + {formatMessage({ + id: 'gate_checked_in', + defaultMessage: 'Checked in', + })} + + )} + + + ))} +
+
+ )} +
+ ); +}; + +export default GateAttendeeList; diff --git a/admin-ui/src/modules/ticketing/components/GateControl.tsx b/admin-ui/src/modules/ticketing/components/GateControl.tsx new file mode 100644 index 0000000000..f2ef69cd5f --- /dev/null +++ b/admin-ui/src/modules/ticketing/components/GateControl.tsx @@ -0,0 +1,110 @@ +import { useState } from 'react'; +import { useIntl } from 'react-intl'; +import Loading from '../../../components/ui/Loading'; +import NoData from '../../../components/ui/NoData'; +import useGateEvents from '../hooks/useGateEvents'; +import useGateEventDetail from '../hooks/useGateEventDetail'; +import useIsPassCodeValid from '../hooks/useIsPassCodeValid'; +import GateEventList from './GateEventList'; +import GateAttendeeList from './GateAttendeeList'; + +const GateControl = ({ onLogout, isAdmin = false }) => { + const { formatMessage } = useIntl(); + const { clearPassCode } = useIsPassCodeValid(); + const { events, loading: eventsLoading } = useGateEvents({ + onlyInvalidateable: !isAdmin, + }); + const [selectedEventId, setSelectedEventId] = useState(null); + const { + event: selectedEvent, + loading: detailLoading, + refetch, + } = useGateEventDetail(selectedEventId); + + const selectedEventFromList: any = selectedEventId + ? events.find((e: any) => e._id === selectedEventId) + : null; + + const title = + selectedEventFromList?.texts?.title || (selectedEvent as any)?.texts?.title; + + return ( +
+
+
+ {selectedEventId && ( + + )} +

+ {selectedEventId + ? title + : formatMessage({ + id: isAdmin ? 'gate_all_events' : 'gate_active_events', + defaultMessage: isAdmin ? 'All Events' : 'Active Events', + })} +

+
+ {onLogout && ( + + )} +
+ + {selectedEventId ? ( + detailLoading && !selectedEvent ? ( + + ) : selectedEvent ? ( + + ) : ( + + ) + ) : eventsLoading ? ( + + ) : events.length ? ( + setSelectedEventId(e._id)} + /> + ) : ( + + )} +
+ ); +}; + +export default GateControl; diff --git a/admin-ui/src/modules/ticketing/components/GateEventList.tsx b/admin-ui/src/modules/ticketing/components/GateEventList.tsx new file mode 100644 index 0000000000..4215f0710e --- /dev/null +++ b/admin-ui/src/modules/ticketing/components/GateEventList.tsx @@ -0,0 +1,93 @@ +import { useIntl } from 'react-intl'; +import useFormatDateTime from '../../common/utils/useFormatDateTime'; +import Badge from '../../../components/ui/Badge'; + +const GateEventList = ({ events, onSelectEvent }) => { + const { formatMessage } = useIntl(); + const { formatDateTime } = useFormatDateTime(); + + return ( +
+ {events.map((event: any) => { + const slot = event?.contractConfiguration?.ercMetadataProperties?.slot; + const tokens = event?.tokens || []; + const activeTokens = tokens.filter((t) => !t.isCanceled); + const redeemedCount = activeTokens.filter( + (t) => t.invalidatedDate, + ).length; + const invalidateableCount = activeTokens.filter( + (t) => t.isInvalidateable && !t.invalidatedDate, + ).length; + + return ( + + ); + })} +
+ ); +}; + +export default GateEventList; diff --git a/admin-ui/src/modules/ticketing/components/GatePassCodeForm.tsx b/admin-ui/src/modules/ticketing/components/GatePassCodeForm.tsx new file mode 100644 index 0000000000..002f393e8b --- /dev/null +++ b/admin-ui/src/modules/ticketing/components/GatePassCodeForm.tsx @@ -0,0 +1,72 @@ +import { useState } from 'react'; +import { useIntl } from 'react-intl'; +import { toast } from 'react-toastify'; +import useIsPassCodeValid from '../hooks/useIsPassCodeValid'; + +const GatePassCodeForm = ({ onAuthenticated }) => { + const { formatMessage } = useIntl(); + const { validatePassCode, loading } = useIsPassCodeValid(); + const [passCode, setPassCode] = useState(''); + + const handleSubmit = async (e) => { + e.preventDefault(); + if (!passCode.trim()) return; + + const valid = await validatePassCode(passCode.trim()); + if (valid) { + onAuthenticated(); + } else { + toast.error( + formatMessage({ + id: 'gate_invalid_passcode', + defaultMessage: 'Invalid pass code. Please try again.', + }), + ); + } + }; + + return ( +
+
+

+ {formatMessage({ + id: 'gate_enter_passcode', + defaultMessage: + 'Enter the scanner pass code to activate the gate control.', + })} +

+
+ setPassCode(e.target.value)} + placeholder={formatMessage({ + id: 'gate_passcode_placeholder', + defaultMessage: 'Pass code', + })} + className="mb-4 block w-full rounded-md border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-700 px-4 py-2 text-sm text-slate-900 dark:text-slate-100 placeholder-slate-400 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500" + required + autoFocus + /> + +
+
+
+ ); +}; + +export default GatePassCodeForm; diff --git a/admin-ui/src/modules/ticketing/components/TicketEventDetail.tsx b/admin-ui/src/modules/ticketing/components/TicketEventDetail.tsx new file mode 100644 index 0000000000..2504cbb4cc --- /dev/null +++ b/admin-ui/src/modules/ticketing/components/TicketEventDetail.tsx @@ -0,0 +1,411 @@ +import Link from 'next/link'; +import { useCallback, useState } from 'react'; +import { useIntl } from 'react-intl'; +import { toast } from 'react-toastify'; +import useModal from '../../modal/hooks/useModal'; +import DangerMessage from '../../modal/components/DangerMessage'; +import useFormatDateTime from '../../common/utils/useFormatDateTime'; +import ImageWithFallback from '../../../components/ui/ImageWithFallback'; +import defaultNextImageLoader from '../../common/utils/defaultNextImageLoader'; +import Badge from '../../../components/ui/Badge'; +import EventTokenList from './EventTokenList'; +import useCancelTicket from '../hooks/useCancelTicket'; +import useCancelEvent from '../hooks/useCancelEvent'; +import useInvalidateTicket from '../../token/hooks/useInvalidateTicket'; +import useSetScannerPassCode from '../hooks/useSetScannerPassCode'; +import generateUniqueId from '../../common/utils/getUniqueId'; + +const TicketEventDetail = ({ product }) => { + const { formatMessage } = useIntl(); + const { formatDateTime } = useFormatDateTime(); + const { setModal } = useModal(); + const { cancelTicket } = useCancelTicket(); + const { cancelEvent } = useCancelEvent(); + const { invalidateTicket } = useInvalidateTicket(); + const { setScannerPassCode } = useSetScannerPassCode(); + const [passCodeInput, setPassCodeInput] = useState(''); + + const slot = product?.contractConfiguration?.ercMetadataProperties?.slot; + const supply = product?.contractConfiguration?.supply || 0; + const remaining = + product?.simulatedStocks?.reduce((acc, cur) => acc + cur.quantity, 0) || 0; + const sold = supply - remaining; + + const activeTokens = product?.tokens?.filter((t) => !t.isCanceled) || []; + const redeemedTokens = activeTokens.filter((t) => t.invalidatedDate); + + const onCancelEvent = useCallback(async () => { + let generateDiscount = false; + await setModal( + setModal('')} + message={ + <> + {formatMessage({ + id: 'cancel_event_confirmation', + defaultMessage: + 'Are you sure you want to cancel this event? All tickets will be cancelled.', + })} + + + } + onOkClick={async () => { + setModal(''); + try { + await cancelEvent({ productId: product._id, generateDiscount }); + toast.success( + formatMessage({ + id: 'event_cancelled', + defaultMessage: 'Event cancelled successfully', + }), + ); + } catch (e) { + toast.error(e.message); + } + }} + okText={formatMessage({ + id: 'cancel_event', + defaultMessage: 'Cancel Event', + })} + />, + ); + }, [product?._id]); + + const onCancelTicket = useCallback(async (tokenId: string) => { + let generateDiscount = false; + await setModal( + setModal('')} + message={ + <> + {formatMessage({ + id: 'cancel_ticket_confirmation', + defaultMessage: 'Are you sure you want to cancel this ticket?', + })} + + + } + onOkClick={async () => { + setModal(''); + try { + await cancelTicket({ tokenId, generateDiscount }); + toast.success( + formatMessage({ + id: 'ticket_cancelled', + defaultMessage: 'Ticket cancelled successfully', + }), + ); + } catch (e) { + toast.error(e.message); + } + }} + okText={formatMessage({ + id: 'cancel_ticket', + defaultMessage: 'Cancel Ticket', + })} + />, + ); + }, []); + + const onInvalidateTicket = useCallback(async (tokenId: string) => { + try { + await invalidateTicket({ tokenId }); + toast.success( + formatMessage({ + id: 'ticket_redeemed', + defaultMessage: 'Ticket redeemed successfully', + }), + ); + } catch (e) { + toast.error( + formatMessage({ + id: 'ticket_redeem_error', + defaultMessage: + 'Ticket already redeemed or not redeemable at this time', + }), + ); + } + }, []); + + if (!product) return null; + + return ( +
+
+
+
+ + + +
+
+

+ {product?.texts?.title} +

+ {product?.texts?.subtitle && ( +

+ {product.texts.subtitle} +

+ )} + {product?.texts?.description && ( +

+ {product.texts.description} +

+ )} + +
+
+ + {formatMessage({ + id: 'event_date', + defaultMessage: 'Event Date', + })} + +

+ {slot + ? formatDateTime(slot, { + dateStyle: 'full', + timeStyle: 'short', + }) + : '-'} +

+
+
+ + {formatMessage({ + id: 'status', + defaultMessage: 'Status', + })} + +
+ +
+
+
+ + {formatMessage({ + id: 'tickets_sold', + defaultMessage: 'Tickets Sold', + })} + +

+ {sold} + / {supply} +

+
+
+ + {formatMessage({ + id: 'tickets_redeemed', + defaultMessage: 'Tickets Redeemed', + })} + +

+ + {redeemedTokens.length} + + + {' '} + / {activeTokens.length} + +

+
+
+ + {product.status === 'ACTIVE' && !product.isCanceled && ( +
+ +
+ )} +
+
+
+ +
+

+ {formatMessage({ + id: 'gate_control_settings', + defaultMessage: 'Gate Control', + })} +

+

+ {formatMessage({ + id: 'gate_control_description', + defaultMessage: + 'Set a scanner pass code to enable gate control for this event. Share this code with gate operators.', + })} +

+
+
+ + setPassCodeInput(e.target.value)} + placeholder={ + product?.scannerPassCode + ? formatMessage({ + id: 'scanner_pass_code_set', + defaultMessage: + 'Pass code is set (enter new value to change)', + }) + : formatMessage({ + id: 'scanner_pass_code_placeholder', + defaultMessage: 'Enter a pass code for gate operators', + }) + } + className="block w-full rounded-md border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-700 px-3 py-2 text-sm text-slate-900 dark:text-slate-100 placeholder-slate-400 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500" + /> +
+ + {product?.scannerPassCode && ( + + )} +
+ {product?.scannerPassCode && ( +

+ {formatMessage({ + id: 'scanner_pass_code_active', + defaultMessage: 'Gate control is active for this event.', + })} +

+ )} +
+ +
+

+ {formatMessage( + { + id: 'attendee_list', + defaultMessage: 'Attendees ({count})', + }, + { count: product?.tokens?.length || 0 }, + )} +

+ +
+
+ ); +}; + +export default TicketEventDetail; diff --git a/admin-ui/src/modules/ticketing/components/TicketEventList.tsx b/admin-ui/src/modules/ticketing/components/TicketEventList.tsx new file mode 100644 index 0000000000..58047c1adb --- /dev/null +++ b/admin-ui/src/modules/ticketing/components/TicketEventList.tsx @@ -0,0 +1,38 @@ +import { useIntl } from 'react-intl'; +import Table from '../../common/components/Table'; +import TicketEventListItem from './TicketEventListItem'; + +const TicketEventList = ({ products }) => { + const { formatMessage } = useIntl(); + + return ( + + + + + {formatMessage({ id: 'title', defaultMessage: 'Title' })} + + + {formatMessage({ + id: 'event_date', + defaultMessage: 'Event Date', + })} + + + {formatMessage({ + id: 'tickets_sold', + defaultMessage: 'Tickets Sold', + })} + + + {formatMessage({ id: 'status', defaultMessage: 'Status' })} + + + {(products || []).map((product) => ( + + ))} +
+ ); +}; + +export default TicketEventList; diff --git a/admin-ui/src/modules/ticketing/components/TicketEventListItem.tsx b/admin-ui/src/modules/ticketing/components/TicketEventListItem.tsx new file mode 100644 index 0000000000..e7062d5d16 --- /dev/null +++ b/admin-ui/src/modules/ticketing/components/TicketEventListItem.tsx @@ -0,0 +1,101 @@ +import Link from 'next/link'; +import Table from '../../common/components/Table'; +import Badge from '../../../components/ui/Badge'; +import useFormatDateTime from '../../common/utils/useFormatDateTime'; +import ImageWithFallback from '../../../components/ui/ImageWithFallback'; +import defaultNextImageLoader from '../../common/utils/defaultNextImageLoader'; +import generateUniqueId from '../../common/utils/getUniqueId'; + +const EVENT_STATUSES = { + ACTIVE: 'emerald', + DRAFT: 'amber', + DELETED: 'rose', +}; + +const TicketEventListItem = ({ product }) => { + const { formatDateTime } = useFormatDateTime(); + const slot = product?.contractConfiguration?.ercMetadataProperties?.slot; + + const supply = product?.contractConfiguration?.supply || 0; + const remaining = + product?.simulatedStocks?.reduce((acc, cur) => acc + cur.quantity, 0) || 0; + const sold = supply - remaining; + const ticketUrl = `/ticketing?slug=${generateUniqueId(product)}`; + return ( + + + + + + + + + {product?.texts?.title || 'Untitled'} + {product?.texts?.subtitle && ( + + {product.texts.subtitle} + + )} + + + +
+ {slot + ? formatDateTime(slot, { + month: 'short', + year: 'numeric', + day: 'numeric', + hour: 'numeric', + minute: 'numeric', + }) + : '-'} +
+
+ +
+ + {sold} + + / + {supply} + {supply > 0 && ( +
+
+
+ )} +
+ + + + + + ); +}; + +export default TicketEventListItem; diff --git a/admin-ui/src/modules/ticketing/hooks/useCancelEvent.ts b/admin-ui/src/modules/ticketing/hooks/useCancelEvent.ts new file mode 100644 index 0000000000..0f77cb9bf8 --- /dev/null +++ b/admin-ui/src/modules/ticketing/hooks/useCancelEvent.ts @@ -0,0 +1,30 @@ +import { gql } from '@apollo/client'; +import { useMutation } from '@apollo/client/react'; + +const CancelEventMutation = gql` + mutation CancelEvent($productId: ID!, $generateDiscount: Boolean) { + cancelEvent(productId: $productId, generateDiscount: $generateDiscount) + } +`; + +const useCancelEvent = () => { + const [cancelEventMutation] = useMutation(CancelEventMutation); + + const cancelEvent = async ({ + productId, + generateDiscount, + }: { + productId: string; + generateDiscount?: boolean; + }) => { + const result = await cancelEventMutation({ + variables: { productId, generateDiscount }, + refetchQueries: ['Product', 'TicketEvents', 'Tokens'], + }); + return result; + }; + + return { cancelEvent }; +}; + +export default useCancelEvent; diff --git a/admin-ui/src/modules/ticketing/hooks/useCancelTicket.ts b/admin-ui/src/modules/ticketing/hooks/useCancelTicket.ts new file mode 100644 index 0000000000..9f462f061b --- /dev/null +++ b/admin-ui/src/modules/ticketing/hooks/useCancelTicket.ts @@ -0,0 +1,36 @@ +import { gql } from '@apollo/client'; +import { useMutation } from '@apollo/client/react'; + +const CancelTicketMutation = gql` + mutation CancelTicket($tokenId: ID!, $generateDiscount: Boolean) { + cancelTicket(tokenId: $tokenId, generateDiscount: $generateDiscount) { + _id + isCanceled + invalidatedDate + isInvalidateable + tokenSerialNumber + } + } +`; + +const useCancelTicket = () => { + const [cancelTicketMutation] = useMutation(CancelTicketMutation); + + const cancelTicket = async ({ + tokenId, + generateDiscount, + }: { + tokenId: string; + generateDiscount?: boolean; + }) => { + const result = await cancelTicketMutation({ + variables: { tokenId, generateDiscount }, + refetchQueries: ['Product', 'TicketEvents', 'Tokens', 'Token'], + }); + return result; + }; + + return { cancelTicket }; +}; + +export default useCancelTicket; diff --git a/admin-ui/src/modules/ticketing/hooks/useCheckGateCookie.ts b/admin-ui/src/modules/ticketing/hooks/useCheckGateCookie.ts new file mode 100644 index 0000000000..afeac321f2 --- /dev/null +++ b/admin-ui/src/modules/ticketing/hooks/useCheckGateCookie.ts @@ -0,0 +1,32 @@ +import { gql } from '@apollo/client'; +import { useQuery } from '@apollo/client/react'; +import { useCurrentUser } from '../../accounts'; +import { + ICheckGateCookieQuery, + ICheckGateCookieQueryVariables, +} from '../../../gql/types'; + +const CheckGateCookieQuery = gql` + query CheckGateCookie { + isPassCodeValid + } +`; +const useCheckGateCookie = () => { + const { currentUser } = useCurrentUser(); + const isAdmin = Boolean(currentUser?._id); + const { data, loading, refetch } = useQuery< + ICheckGateCookieQuery, + ICheckGateCookieQueryVariables + >(CheckGateCookieQuery, { + fetchPolicy: 'cache-and-network', + skip: isAdmin, + }); + const authenticated = isAdmin || data?.isPassCodeValid === true; + return { + authenticated, + loading, + refetch, + }; +}; + +export default useCheckGateCookie; diff --git a/admin-ui/src/modules/ticketing/hooks/useEventProducts.ts b/admin-ui/src/modules/ticketing/hooks/useEventProducts.ts new file mode 100644 index 0000000000..3740c00881 --- /dev/null +++ b/admin-ui/src/modules/ticketing/hooks/useEventProducts.ts @@ -0,0 +1,75 @@ +import { gql } from '@apollo/client'; +import { useQuery } from '@apollo/client/react'; + +const TicketEventsQuery = gql` + query TicketEvents( + $queryString: String + $limit: Int + $offset: Int + $includeDrafts: Boolean = true + $forceLocale: Locale + ) { + ticketEvents( + queryString: $queryString + limit: $limit + offset: $offset + includeDrafts: $includeDrafts + ) { + _id + status + tags + updated + published + ... on TokenizedProduct { + texts(forceLocale: $forceLocale) { + _id + slug + title + subtitle + description + } + media(limit: 1) { + _id + file { + _id + url + name + } + } + contractConfiguration { + ercMetadataProperties + supply + } + simulatedStocks { + quantity + } + tokensCount + isCanceled + } + } + ticketEventsCount(includeDrafts: $includeDrafts, queryString: $queryString) + } +`; + +const useEventProducts = ({ + queryString = null, + limit = 50, + offset = 0, +}: { + queryString?: string; + limit?: number; + offset?: number; +}) => { + const { data, loading, error } = useQuery(TicketEventsQuery, { + variables: { queryString, limit, offset }, + }); + + return { + products: data?.ticketEvents || [], + productsCount: data?.ticketEventsCount || 0, + loading, + error, + }; +}; + +export default useEventProducts; diff --git a/admin-ui/src/modules/ticketing/hooks/useGateEventDetail.ts b/admin-ui/src/modules/ticketing/hooks/useGateEventDetail.ts new file mode 100644 index 0000000000..b641dc87f1 --- /dev/null +++ b/admin-ui/src/modules/ticketing/hooks/useGateEventDetail.ts @@ -0,0 +1,79 @@ +import { gql } from '@apollo/client'; +import { useQuery } from '@apollo/client/react'; +import { + IGateEventDetailQuery, + IGateEventDetailQueryVariables, +} from '../../../gql/types'; + +const GateEventDetailQuery = gql` + query GateEventDetail($productId: ID!) { + product(productId: $productId) { + _id + ... on TokenizedProduct { + texts { + _id + title + subtitle + } + contractConfiguration { + ercMetadataProperties + supply + } + isCanceled + tokens { + _id + tokenSerialNumber + isCanceled + invalidatedDate + isInvalidateable + ercMetadata + user { + _id + username + isGuest + primaryEmail { + address + verified + } + avatar { + _id + url + } + profile { + displayName + address { + firstName + lastName + } + } + lastContact { + emailAddress + telNumber + } + } + } + } + } + } +`; + +const useGateEventDetail = (productId: string | null) => { + const { data, loading, error, refetch, previousData } = useQuery< + IGateEventDetailQuery, + IGateEventDetailQueryVariables + >(GateEventDetailQuery, { + variables: { productId }, + skip: !productId, + fetchPolicy: 'cache-and-network', + pollInterval: 10000, + }); + + return { + event: data?.product || previousData?.product || null, + loading, + error, + refetch, + }; +}; + +export default useGateEventDetail; diff --git a/admin-ui/src/modules/ticketing/hooks/useGateEvents.ts b/admin-ui/src/modules/ticketing/hooks/useGateEvents.ts new file mode 100644 index 0000000000..ce5e52f079 --- /dev/null +++ b/admin-ui/src/modules/ticketing/hooks/useGateEvents.ts @@ -0,0 +1,66 @@ +import { gql } from '@apollo/client'; +import { useQuery } from '@apollo/client/react'; +import { + IGateEventsQuery, + IGateEventsQueryVariables, +} from '../../../gql/types'; + +const GateEventsQuery = gql` + query GateEvents($onlyInvalidateable: Boolean!) { + ticketEvents( + limit: 100 + includeDrafts: false + onlyInvalidateable: $onlyInvalidateable + ) { + _id + status + ... on TokenizedProduct { + texts { + _id + title + subtitle + } + contractConfiguration { + ercMetadataProperties + supply + } + isCanceled + tokens { + _id + tokenSerialNumber + isCanceled + invalidatedDate + isInvalidateable + } + } + } + } +`; + +const useGateEvents = ({ + onlyInvalidateable = false, +}: { onlyInvalidateable?: boolean } = {}) => { + const { data, loading, error, refetch, previousData } = useQuery< + IGateEventsQuery, + IGateEventsQueryVariables + >(GateEventsQuery, { + variables: { onlyInvalidateable }, + fetchPolicy: 'cache-and-network', + pollInterval: 10000, + }); + + const events = ( + data?.ticketEvents || + previousData?.ticketEvents || + [] + ).filter((p: any) => p?.tokens?.length && !p.isCanceled); + + return { + events, + loading, + error, + refetch, + }; +}; + +export default useGateEvents; diff --git a/admin-ui/src/modules/ticketing/hooks/useIsPassCodeValid.ts b/admin-ui/src/modules/ticketing/hooks/useIsPassCodeValid.ts new file mode 100644 index 0000000000..d6ea4e66f4 --- /dev/null +++ b/admin-ui/src/modules/ticketing/hooks/useIsPassCodeValid.ts @@ -0,0 +1,58 @@ +import { gql } from '@apollo/client'; +import { useMutation } from '@apollo/client/react'; +import { + IAuthenticateGateMutation, + IAuthenticateGateMutationVariables, + IDeauthenticateGateMutation, + IDeauthenticateGateMutationVariables, +} from '../../../gql/types'; + +const AuthenticateGateMutation = gql` + mutation AuthenticateGate($passCode: String!) { + authenticateGate(passCode: $passCode) + } +`; + +const DeauthenticateGateMutation = gql` + mutation DeauthenticateGate { + deauthenticateGate + } +`; + +const useIsPassCodeValid = () => { + const [authenticateGate, { loading: authLoading }] = useMutation< + IAuthenticateGateMutation, + IAuthenticateGateMutationVariables + >(AuthenticateGateMutation); + const [deauthenticateGate, { loading: deauthLoading }] = useMutation< + IDeauthenticateGateMutation, + IDeauthenticateGateMutationVariables + >(DeauthenticateGateMutation); + + const validatePassCode = async (passCode: string) => { + try { + const result = await authenticateGate({ + variables: { passCode }, + }); + return result.data?.authenticateGate || false; + } catch { + return false; + } + }; + + const clearPassCode = async () => { + try { + await deauthenticateGate(); + } catch { + // ignore + } + }; + + return { + validatePassCode, + clearPassCode, + loading: authLoading || deauthLoading, + }; +}; + +export default useIsPassCodeValid; diff --git a/admin-ui/src/modules/ticketing/hooks/useSetScannerPassCode.ts b/admin-ui/src/modules/ticketing/hooks/useSetScannerPassCode.ts new file mode 100644 index 0000000000..9494306616 --- /dev/null +++ b/admin-ui/src/modules/ticketing/hooks/useSetScannerPassCode.ts @@ -0,0 +1,41 @@ +import { gql } from '@apollo/client'; +import { useMutation } from '@apollo/client/react'; +import { + ISetEventScannerPassCodeMutation, + ISetEventScannerPassCodeMutationVariables, +} from '../../../gql/types'; + +const SetEventScannerPassCodeMutation = gql` + mutation SetEventScannerPassCode($productId: ID!, $passCode: String) { + setEventScannerPassCode(productId: $productId, passCode: $passCode) { + _id + ... on TokenizedProduct { + scannerPassCode + } + } + } +`; + +const useSetScannerPassCode = () => { + const [setPassCodeMutation] = useMutation< + ISetEventScannerPassCodeMutation, + ISetEventScannerPassCodeMutationVariables + >(SetEventScannerPassCodeMutation); + + const setScannerPassCode = async ({ + productId, + passCode, + }: { + productId: string; + passCode: string | null; + }) => { + return setPassCodeMutation({ + variables: { productId, passCode }, + refetchQueries: ['Product'], + }); + }; + + return { setScannerPassCode }; +}; + +export default useSetScannerPassCode; diff --git a/admin-ui/src/modules/ticketing/index.ts b/admin-ui/src/modules/ticketing/index.ts new file mode 100644 index 0000000000..d672fe521b --- /dev/null +++ b/admin-ui/src/modules/ticketing/index.ts @@ -0,0 +1,6 @@ +export { default as useEventProducts } from './hooks/useEventProducts'; +export { default as useCancelTicket } from './hooks/useCancelTicket'; +export { default as useCancelEvent } from './hooks/useCancelEvent'; +export { default as useIsPassCodeValid } from './hooks/useIsPassCodeValid'; +export { default as useGateEvents } from './hooks/useGateEvents'; +export { default as useSetScannerPassCode } from './hooks/useSetScannerPassCode'; diff --git a/admin-ui/src/modules/token/components/TokenDetail.tsx b/admin-ui/src/modules/token/components/TokenDetail.tsx index b59b4caf2c..57d0627df0 100644 --- a/admin-ui/src/modules/token/components/TokenDetail.tsx +++ b/admin-ui/src/modules/token/components/TokenDetail.tsx @@ -120,6 +120,19 @@ const TokenDetail = ({ token }) => {

+ {token?.isCanceled && ( +
+ + {formatMessage({ + id: 'cancelled_status', + defaultMessage: 'Cancelled', + })} + : + + +
+ )} + {token?.walletAddress && (
diff --git a/admin-ui/src/modules/token/components/TokenList.tsx b/admin-ui/src/modules/token/components/TokenList.tsx index 0234239ca2..74aa0ed18c 100644 --- a/admin-ui/src/modules/token/components/TokenList.tsx +++ b/admin-ui/src/modules/token/components/TokenList.tsx @@ -33,6 +33,12 @@ const TokenList = ({ tokens }) => { defaultMessage: 'Invalidated', })} + + {formatMessage({ + id: 'token_cancelled', + defaultMessage: 'Cancelled', + })} + {(tokens || []).map((token) => ( diff --git a/admin-ui/src/modules/token/components/TokenListItem.tsx b/admin-ui/src/modules/token/components/TokenListItem.tsx index 0addb783f2..6f4fefedaf 100644 --- a/admin-ui/src/modules/token/components/TokenListItem.tsx +++ b/admin-ui/src/modules/token/components/TokenListItem.tsx @@ -69,6 +69,11 @@ const TokenListItem = ({ token }) => { : null}
+ + {token.isCanceled ? ( + + ) : null} +
); }; diff --git a/admin-ui/src/pages/ticketing/TicketEventDetailPage.tsx b/admin-ui/src/pages/ticketing/TicketEventDetailPage.tsx new file mode 100644 index 0000000000..c048a71417 --- /dev/null +++ b/admin-ui/src/pages/ticketing/TicketEventDetailPage.tsx @@ -0,0 +1,33 @@ +import { useIntl } from 'react-intl'; +import BreadCrumbs from '../../components/ui/BreadCrumbs'; +import PageHeader from '../../components/ui/PageHeader'; +import Loading from '../../components/ui/Loading'; +import TicketEventDetail from '../../modules/ticketing/components/TicketEventDetail'; +import useProduct from '../../modules/product/hooks/useProduct'; + +const TicketEventDetailPage = ({ slug }) => { + const { formatMessage } = useIntl(); + const { product, loading } = useProduct({ slug: slug as string }); + + if (loading) return ; + + return ( + <> + +
+ +
+ + + ); +}; + +export default TicketEventDetailPage; diff --git a/admin-ui/src/pages/ticketing/gate.tsx b/admin-ui/src/pages/ticketing/gate.tsx new file mode 100644 index 0000000000..04b1c0cc89 --- /dev/null +++ b/admin-ui/src/pages/ticketing/gate.tsx @@ -0,0 +1,56 @@ +import Link from 'next/link'; +import { useIntl } from 'react-intl'; +import { gql } from '@apollo/client'; +import useCurrentUser from '../../modules/accounts/hooks/useCurrentUser'; +import GatePassCodeForm from '../../modules/ticketing/components/GatePassCodeForm'; +import GateControl from '../../modules/ticketing/components/GateControl'; +import useCheckGateCookie from '../../modules/ticketing/hooks/useCheckGateCookie'; + +const CheckGateCookieQuery = gql` + query CheckGateCookie { + isPassCodeValid + } +`; + +const GateControlPage = () => { + const { formatMessage } = useIntl(); + const { currentUser } = useCurrentUser(); + const isAdmin = Boolean(currentUser?._id); + const { authenticated, loading, refetch } = useCheckGateCookie(); + + return ( +
+
+

+ {formatMessage({ + id: 'gate_control_header', + defaultMessage: 'Gate Control', + })} +

+
+
+ + {formatMessage({ + id: currentUser?._id ? 'back_to_admin' : 'back_to_login', + defaultMessage: currentUser?._id ? 'Back to Admin' : 'Log in', + })} + +
+ {authenticated ? ( + refetch() : undefined} + isAdmin={isAdmin} + /> + ) : loading ? null : ( + refetch()} /> + )} +
+ ); +}; + +GateControlPage.getLayout = (page) => page; + +export default GateControlPage; diff --git a/admin-ui/src/pages/ticketing/index.tsx b/admin-ui/src/pages/ticketing/index.tsx new file mode 100644 index 0000000000..523c6be53f --- /dev/null +++ b/admin-ui/src/pages/ticketing/index.tsx @@ -0,0 +1,109 @@ +import Link from 'next/link'; +import { useIntl } from 'react-intl'; +import { useRouter } from 'next/router'; + +import BreadCrumbs from '../../components/ui/BreadCrumbs'; +import PageHeader from '../../components/ui/PageHeader'; +import Loading from '../../components/ui/Loading'; +import NoData from '../../components/ui/NoData'; +import ListHeader from '../../components/ui/ListHeader'; +import SearchWithTags from '../../modules/common/components/SearchWithTags'; +import AnimatedCounter from '../../components/ui/AnimatedCounter'; +import TicketEventList from '../../modules/ticketing/components/TicketEventList'; +import useEventProducts from '../../modules/ticketing/hooks/useEventProducts'; +import TicketEventDetailPage from './TicketEventDetailPage'; + +const TicketingPage = () => { + const { formatMessage } = useIntl(); + const { query, push } = useRouter(); + + const { queryString, slug, ...rest } = query; + + const setQueryString = (searchString) => { + const { skip, ...withoutSkip } = rest; + if (searchString) + push({ + query: { + ...withoutSkip, + queryString: searchString, + }, + }); + else + push({ + query: { + ...rest, + }, + }); + }; + + const { products, productsCount, loading } = useEventProducts({ + limit: 0, + offset: 0, + queryString: queryString as string, + }); + + if (slug) return ; + + const headerText = + productsCount === 1 + ? formatMessage({ + id: 'event_header', + defaultMessage: '1 Event', + }) + : formatMessage( + { + id: 'event_count_header', + defaultMessage: '{count} Events', + }, + { count: }, + ); + + return ( + <> + +
+ + + {formatMessage({ + id: 'gate_control', + defaultMessage: 'Gate Control', + })} + +
+
+ + + + <> + {loading ? : } + {!loading && !products?.length && ( + + )} + + +
+ + ); +}; + +export default TicketingPage; diff --git a/admin-ui/src/pages/tokens/TokenDetailPage.tsx b/admin-ui/src/pages/tokens/TokenDetailPage.tsx index d23e767043..74874ad05f 100644 --- a/admin-ui/src/pages/tokens/TokenDetailPage.tsx +++ b/admin-ui/src/pages/tokens/TokenDetailPage.tsx @@ -1,4 +1,3 @@ -import { useRouter } from 'next/router'; import { IRoleAction } from '../../gql/types'; import useToken from '../../modules/token/hooks/useToken'; @@ -13,6 +12,7 @@ import { XMarkIcon } from '@heroicons/react/24/outline'; import DangerMessage from '../../modules/modal/components/DangerMessage'; import useModal from '../../modules/modal/hooks/useModal'; import useInvalidateTicket from '../../modules/token/hooks/useInvalidateTicket'; +import useCancelTicket from '../../modules/ticketing/hooks/useCancelTicket'; import { toast } from 'react-toastify'; import { useCallback } from 'react'; import useAuth from '../../modules/Auth/useAuth'; @@ -21,8 +21,10 @@ const TokenDetailPage = ({ tokenId }) => { const { formatMessage } = useIntl(); const { setModal } = useModal(); - const { token, loading } = useToken({ tokenId: tokenId as string }); + const { token: rawToken, loading } = useToken({ tokenId: tokenId as string }); + const token = rawToken as typeof rawToken & { isCanceled?: boolean }; const { invalidateTicket } = useInvalidateTicket(); + const { cancelTicket } = useCancelTicket(); const { hasRole } = useAuth(); const onInvalidateToken = useCallback(async () => { @@ -30,23 +32,72 @@ const TokenDetailPage = ({ tokenId }) => { setModal('')} message={formatMessage({ - id: 'delete_paymentProvider_confirmation', + id: 'invalidate_token_confirmation', defaultMessage: - 'This action might cause inconsistencies with other data that relates to it. Are you sure you want to delete this Payment provider? ', + 'Are you sure you want to invalidate this token? This marks it as redeemed.', })} onOkClick={async () => { setModal(''); await invalidateTicket({ tokenId }); toast.success( formatMessage({ - id: 'payment_provider_deleted', - defaultMessage: 'Payment provider deleted successfully', + id: 'token_invalidated', + defaultMessage: 'Token invalidated successfully', }), ); }} okText={formatMessage({ - id: 'delete_payment_provider', - defaultMessage: 'Delete payment provider', + id: 'invalidate_token', + defaultMessage: 'Invalidate', + })} + />, + ); + }, [tokenId]); + + const onCancelToken = useCallback(async () => { + let generateDiscount = false; + await setModal( + setModal('')} + message={ + <> + {formatMessage({ + id: 'cancel_ticket_confirmation', + defaultMessage: + 'Are you sure you want to cancel this ticket? This action cannot be undone.', + })} + + + } + onOkClick={async () => { + setModal(''); + try { + await cancelTicket({ tokenId, generateDiscount }); + toast.success( + formatMessage({ + id: 'ticket_cancelled', + defaultMessage: 'Ticket cancelled successfully', + }), + ); + } catch (e) { + toast.error(e.message); + } + }} + okText={formatMessage({ + id: 'cancel_ticket', + defaultMessage: 'Cancel Ticket', })} />, ); @@ -73,19 +124,33 @@ const TokenDetailPage = ({ tokenId }) => { )} - {!token.invalidatedDate && - token.isInvalidateable && - hasRole(IRoleAction.UpdateToken) ? ( -