diff --git a/admin-ui/package.json b/admin-ui/package.json index 398e993bf7..1f14078c90 100644 --- a/admin-ui/package.json +++ b/admin-ui/package.json @@ -89,9 +89,6 @@ "./modules/quotation": { "import": "./dist/modules/quotation.js" }, - "./modules/token": { - "import": "./dist/modules/token.js" - }, "./modules/warehousing-providers": { "import": "./dist/modules/warehousing-providers.js" }, diff --git a/admin-ui/src/components/ui/form/ChoicesField.tsx b/admin-ui/src/components/ui/form/ChoicesField.tsx index 0cea65d950..0901d5c98b 100644 --- a/admin-ui/src/components/ui/form/ChoicesField.tsx +++ b/admin-ui/src/components/ui/form/ChoicesField.tsx @@ -23,7 +23,24 @@ const ChoicesField = ({ multiple, options, ...props }: ChoicesFieldProps) => { } = props; const mappableValue = - typeof field.value === 'string' ? [field.value] : field.value; + typeof field.value === 'string' + ? [field.value] + : Array.isArray(field.value) + ? field.value + : []; + + const handleChange = (event: React.ChangeEvent) => { + if (field.multiple) { + const clickedValue = event.target.value; + const newValues = event.target.checked + ? [...mappableValue, clickedValue] + : mappableValue.filter((v) => v !== clickedValue); + field.setValue(newValues, true); + } else { + field.onChange(event); + } + }; + const { className, hideLabel } = props; return (
{ disabled={field.disabled} id={`${field.name}-${value}`} name={field.name} - onChange={field.onChange} + onChange={handleChange} type={field.multiple ? 'checkbox' : 'radio'} /> {display} diff --git a/admin-ui/src/components/ui/index.ts b/admin-ui/src/components/ui/index.ts index c7025848ef..68305dcdcf 100644 --- a/admin-ui/src/components/ui/index.ts +++ b/admin-ui/src/components/ui/index.ts @@ -63,3 +63,6 @@ export { ChartStyle, } from './chart'; export type { ChartConfig } from './chart'; + +export { default as Table } from '../../modules/common/components/Table'; +export { default as MediaAvatar } from '../../modules/common/components/MediaAvatar'; diff --git a/admin-ui/src/modules/Auth/permissionConfig.ts b/admin-ui/src/modules/Auth/permissionConfig.ts index b8e859fde8..391a239131 100644 --- a/admin-ui/src/modules/Auth/permissionConfig.ts +++ b/admin-ui/src/modules/Auth/permissionConfig.ts @@ -81,11 +81,9 @@ export const checkAccess = ( pathname: string, ) => { if (UNRESTRICTED_PAGES.includes(pathname)) return true; + if (pathname.startsWith('/ext/') || pathname === '/ext') return true; if (!user?._id) return false; if (user?.isGuest) return false; - if (pathname.startsWith('/ext/') || pathname === '/ext') { - return !!user?._id; - } if (!ROUTE_ROLES[pathname]) { if (process.env.NODE_ENV === 'development') { console.warn( diff --git a/admin-ui/src/modules/accounts/components/LogInForm.tsx b/admin-ui/src/modules/accounts/components/LogInForm.tsx index 2bf060f0f4..b813ced388 100644 --- a/admin-ui/src/modules/accounts/components/LogInForm.tsx +++ b/admin-ui/src/modules/accounts/components/LogInForm.tsx @@ -18,6 +18,7 @@ import useLoginWithPassword from '../hooks/useLoginWithPassword'; import useLoginWithWebAuthn from '../hooks/useLoginWithWebAuthn'; import { useCallback, useState } from 'react'; import useUnchainedContext from '../../UnchainedContext/useUnchainedContext'; +import { usePlugins } from '../../plugins/PluginContext'; const GetCurrentStep = ({ step }) => { const { formatMessage } = useIntl(); @@ -75,6 +76,11 @@ const LogInForm = () => { const { logInWithPassword } = useLoginWithPassword(); const { loginWithWebAuthn } = useLoginWithWebAuthn(); const { singleSignOnURL } = useUnchainedContext(); + const { manifests } = usePlugins(); + + const pluginLinks = manifests.flatMap( + (m) => (m.slots.links || []).filter((l) => l.showOnLoginPage), + ); const [step, setStep] = useState(1); @@ -304,6 +310,19 @@ const LogInForm = () => {
+ {pluginLinks.length > 0 && ( +
+ {pluginLinks.map((link) => ( + + {link.label} → + + ))} +
+ )} diff --git a/admin-ui/src/modules/apollo/utils/createApolloClient.ts b/admin-ui/src/modules/apollo/utils/createApolloClient.ts index b0606f6ad4..5112835a1e 100644 --- a/admin-ui/src/modules/apollo/utils/createApolloClient.ts +++ b/admin-ui/src/modules/apollo/utils/createApolloClient.ts @@ -83,8 +83,8 @@ const createApolloClient = ({ const apolloClient = new ApolloClient({ defaultOptions: { watchQuery: { - fetchPolicy: 'cache-and-network', errorPolicy: 'all', + fetchPolicy: 'cache-and-network', }, }, diff --git a/admin-ui/src/modules/common/components/Layout.tsx b/admin-ui/src/modules/common/components/Layout.tsx index ee7eae7e3a..4ef97b823b 100644 --- a/admin-ui/src/modules/common/components/Layout.tsx +++ b/admin-ui/src/modules/common/components/Layout.tsx @@ -112,6 +112,7 @@ const LayoutContent = ({ skip: !hasRole(IRoleAction.ViewWorkQueue), }); + const isAuthenticated = !!currentUser?._id; const { shopInfo } = useShopInfo(); const [hideNav, setHideNav] = useState(true); const [narrowNav, setNarrowNav] = useState(false); @@ -166,7 +167,6 @@ const LayoutContent = ({ _sortOrder: page.sortOrder as number | undefined, }); }); - if (children.length === 0) return []; const nav = manifest.navigation; @@ -380,6 +380,16 @@ const LayoutContent = ({ return 0; }); + if (!isAuthenticated) { + return ( + +
+ {React.cloneElement(children)} +
+
+ ); + } + return ( diff --git a/admin-ui/src/modules/common/components/SideNav.tsx b/admin-ui/src/modules/common/components/SideNav.tsx index ee35f2da97..c789604794 100644 --- a/admin-ui/src/modules/common/components/SideNav.tsx +++ b/admin-ui/src/modules/common/components/SideNav.tsx @@ -76,25 +76,38 @@ const ChildrenNav = ({ item, hasRole, onSelected, narrowView }) => { {item.children .filter((f) => !f?.requiredRole || hasRole(f.requiredRole)) - .map((subItem) => ( - { - setIsOpen(false); - onSelected?.(); - }} - > - {subItem.name} - - ))} + .map((subItem) => { + const className = clsx( + 'block px-4 py-2 text-sm text-text-secondary hover:bg-surface-raised focus:outline-hidden focus:ring-2 focus:ring-focus-ring', + { + 'bg-surface-raised text-text-primary': + router.pathname === subItem.href, + }, + ); + const handleClick = () => { + setIsOpen(false); + onSelected?.(); + }; + return subItem.external ? ( + + {subItem.name} + + ) : ( + + {subItem.name} + + ); + })} )} @@ -135,21 +148,24 @@ const ChildrenNav = ({ item, hasRole, onSelected, narrowView }) => { {item.children .filter((f) => !f?.requiredRole || hasRole(f.requiredRole)) - .map((subItem) => ( - - {subItem.name} - - ))} + .map((subItem) => { + const className = clsx( + 'group flex w-full items-center rounded-md py-2 pl-5 pr-2 text-sm font-medium text-text-secondary hover:bg-surface-raised hover:text-text-primary focus:outline-hidden focus:ring-2 focus:ring-focus-ring', + { + 'text-text-primary bg-surface-raised': + router.pathname === subItem.href, + }, + ); + return subItem.external ? ( + + {subItem.name} + + ) : ( + + {subItem.name} + + ); + })} )} diff --git a/admin-ui/src/modules/product/hooks/useProduct.ts b/admin-ui/src/modules/product/hooks/useProduct.ts index 8932c983be..46618577d4 100644 --- a/admin-ui/src/modules/product/hooks/useProduct.ts +++ b/admin-ui/src/modules/product/hooks/useProduct.ts @@ -57,6 +57,55 @@ const GetProductQuery = (inlineFragment = '') => gql` __typename } } + ... on TokenizedProduct { + texts { + _id + title + subtitle + description + } + contractConfiguration { + ercMetadataProperties + supply + } + simulatedStocks { + quantity + } + tokensCount + tokens { + _id + tokenSerialNumber + 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/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/pages/ext/[[...slug]].tsx b/admin-ui/src/pages/ext/[[...slug]].tsx index 768f5ebb2f..05426246c1 100644 --- a/admin-ui/src/pages/ext/[[...slug]].tsx +++ b/admin-ui/src/pages/ext/[[...slug]].tsx @@ -3,6 +3,7 @@ import { usePlugins } from '../../modules/plugins/PluginContext'; import { PluginRuntimeProvider } from '../../modules/plugins/PluginRuntimeContext'; import PluginErrorBoundary from '../../modules/plugins/PluginErrorBoundary'; import useAuth from '../../modules/Auth/useAuth'; +import useCurrentUser from '../../modules/accounts/hooks/useCurrentUser'; import Loading from '@/components/ui/Loading'; const PluginEntityPage = () => { @@ -10,6 +11,8 @@ const PluginEntityPage = () => { const { slug } = router.query; const { manifests, getComponent, loading } = usePlugins(); const { hasRole } = useAuth(); + const { currentUser } = useCurrentUser(); + const isAuthenticated = !!currentUser?._id; if (loading) return ; @@ -32,6 +35,10 @@ const PluginEntityPage = () => { (e) => e.path.replace(/^\//, '') === pathStr, ); if (entity) { + if (!isAuthenticated) { + router.replace('/log-in'); + return ; + } if (entity.requiredRole && !hasRole(entity.requiredRole)) { router.replace('/403'); return ; @@ -78,9 +85,15 @@ const PluginEntityPage = () => { (p) => p.path.replace(/^\//, '') === pathStr, ); if (page) { - if (page.requiredRole && !hasRole(page.requiredRole)) { - router.replace('/403'); - return ; + if (page.requiredRole) { + if (!isAuthenticated) { + router.replace('/log-in'); + return ; + } + if (!hasRole(page.requiredRole)) { + router.replace('/403'); + return ; + } } const Component = getComponent(manifest.name, page.component); if (Component) diff --git a/admin-ui/src/pages/tokens/TokenDetailPage.tsx b/admin-ui/src/pages/tokens/TokenDetailPage.tsx index d23e767043..5bcf2be8c0 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'; @@ -30,23 +29,23 @@ 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', })} />, ); @@ -73,19 +72,21 @@ const TokenDetailPage = ({ tokenId }) => { )} - {!token.invalidatedDate && - token.isInvalidateable && - hasRole(IRoleAction.UpdateToken) ? ( - + )} + {token.isInvalidateable && !token.invalidatedDate && ( + + )} + + )} + + + + ); +}; + +export default EventTokenListItem; diff --git a/packages/ticketing/admin-plugin/src/components/GateAttendeeList.tsx b/packages/ticketing/admin-plugin/src/components/GateAttendeeList.tsx new file mode 100644 index 0000000000..5a850248e4 --- /dev/null +++ b/packages/ticketing/admin-plugin/src/components/GateAttendeeList.tsx @@ -0,0 +1,173 @@ +import { useCallback } from 'react'; +import { useIntl } from 'react-intl'; +import { toast } from 'react-toastify'; +import { Table, Badge } from '@unchainedshop/admin-ui/ui'; +import { useFormatDateTime, formatUsername } from '../utils/misc'; +import useInvalidateTicket from '../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 { + 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/packages/ticketing/admin-plugin/src/components/GateControl.tsx b/packages/ticketing/admin-plugin/src/components/GateControl.tsx new file mode 100644 index 0000000000..5dd75088d1 --- /dev/null +++ b/packages/ticketing/admin-plugin/src/components/GateControl.tsx @@ -0,0 +1,97 @@ +import { useState } from 'react'; +import { useIntl } from 'react-intl'; +import { Loading, NoData } from '@unchainedshop/admin-ui/ui'; +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 }) => { + const { formatMessage } = useIntl(); + const { clearPassCode } = useIsPassCodeValid(); + const { events, loading: eventsLoading } = useGateEvents({ + onlyInvalidateable: true, + }); + 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: 'gate_active_events', + defaultMessage: 'Active Events', + })} +

+
+ {onLogout && ( + + )} +
+ + {selectedEventId ? ( + detailLoading && !selectedEvent ? ( + + ) : selectedEvent ? ( + + ) : ( + + ) + ) : eventsLoading ? ( + + ) : events.length ? ( + setSelectedEventId(e._id)} /> + ) : ( + + )} +
+ ); +}; + +export default GateControl; diff --git a/packages/ticketing/admin-plugin/src/components/GateEventList.tsx b/packages/ticketing/admin-plugin/src/components/GateEventList.tsx new file mode 100644 index 0000000000..285511db19 --- /dev/null +++ b/packages/ticketing/admin-plugin/src/components/GateEventList.tsx @@ -0,0 +1,80 @@ +import { useIntl } from 'react-intl'; +import { Badge } from '@unchainedshop/admin-ui/ui'; +import { useFormatDateTime } from '../utils/misc'; + +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/packages/ticketing/admin-plugin/src/components/GatePassCodeForm.tsx b/packages/ticketing/admin-plugin/src/components/GatePassCodeForm.tsx new file mode 100644 index 0000000000..845f6866ce --- /dev/null +++ b/packages/ticketing/admin-plugin/src/components/GatePassCodeForm.tsx @@ -0,0 +1,71 @@ +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-border-default bg-surface-input px-4 py-2 text-sm text-text-primary placeholder-text-muted focus:border-focus-ring focus:outline-none focus:ring-1 focus:ring-focus-ring" + required + autoFocus + /> + +
+
+
+ ); +}; + +export default GatePassCodeForm; diff --git a/packages/ticketing/admin-plugin/src/components/TicketEventDetail.tsx b/packages/ticketing/admin-plugin/src/components/TicketEventDetail.tsx new file mode 100644 index 0000000000..15b9fa154e --- /dev/null +++ b/packages/ticketing/admin-plugin/src/components/TicketEventDetail.tsx @@ -0,0 +1,384 @@ +import Link from 'next/link'; +import { useCallback, useState } from 'react'; +import { useIntl } from 'react-intl'; +import { toast } from 'react-toastify'; +import { useModal, DangerMessage } from '@unchainedshop/admin-ui/modal'; +import { Badge, ImageWithFallback } from '@unchainedshop/admin-ui/ui'; +import EventTokenList from './EventTokenList'; +import useCancelTicket from '../hooks/useCancelTicket'; +import useCancelEvent from '../hooks/useCancelEvent'; +import useInvalidateTicket from '../hooks/useInvalidateTicket'; +import useSetScannerPassCode from '../hooks/useSetScannerPassCode'; +import { useFormatDateTime, generateUniqueId, defaultNextImageLoader } from '../utils/misc'; + +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 { + 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-border-default bg-surface-input px-3 py-2 text-sm text-text-primary placeholder-text-muted focus:border-focus-ring focus:outline-none focus:ring-1 focus:ring-focus-ring" + /> +
+ + {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/packages/ticketing/admin-plugin/src/components/TicketEventList.tsx b/packages/ticketing/admin-plugin/src/components/TicketEventList.tsx new file mode 100644 index 0000000000..8c5620ad71 --- /dev/null +++ b/packages/ticketing/admin-plugin/src/components/TicketEventList.tsx @@ -0,0 +1,29 @@ +import { useIntl } from 'react-intl'; +import { Table } from '@unchainedshop/admin-ui/ui'; +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/packages/ticketing/admin-plugin/src/components/TicketEventListItem.tsx b/packages/ticketing/admin-plugin/src/components/TicketEventListItem.tsx new file mode 100644 index 0000000000..ad6e3de17b --- /dev/null +++ b/packages/ticketing/admin-plugin/src/components/TicketEventListItem.tsx @@ -0,0 +1,83 @@ +import Link from 'next/link'; +import { Table, Badge, ImageWithFallback } from '@unchainedshop/admin-ui/ui'; +import { useFormatDateTime, generateUniqueId, defaultNextImageLoader } from '../utils/misc'; + +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 = `/ext/ticketing/${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/packages/ticketing/admin-plugin/src/fragments/TokenFragment.ts b/packages/ticketing/admin-plugin/src/fragments/TokenFragment.ts new file mode 100644 index 0000000000..1dffa89182 --- /dev/null +++ b/packages/ticketing/admin-plugin/src/fragments/TokenFragment.ts @@ -0,0 +1,72 @@ +import { gql } from '@apollo/client'; + +export const TokenFragment = gql` + fragment TokenFragment on Token { + _id + walletAddress + status + quantity + contractAddress + chainId + tokenSerialNumber + invalidatedDate + expiryDate + ercMetadata + accessKey + isInvalidateable + } +`; + +export const ProductBriefFragment = gql` + fragment ProductBriefFragment on Product { + texts(forceLocale: $forceLocale) { + _id + slug + title + subtitle + description + vendor + brand + labels + locale + } + _id + sequence + status + tags + sequence + updated + published + media { + _id + tags + file { + _id + url + } + } + ... on BundleProduct { + proxies { + __typename + } + } + ... on SimpleProduct { + catalogPrice { + amount + currencyCode + } + proxies { + __typename + } + } + ... on PlanProduct { + catalogPrice { + amount + currencyCode + } + proxies { + __typename + } + } + } +`; diff --git a/packages/ticketing/admin-plugin/src/hooks/useCancelEvent.ts b/packages/ticketing/admin-plugin/src/hooks/useCancelEvent.ts new file mode 100644 index 0000000000..0f77cb9bf8 --- /dev/null +++ b/packages/ticketing/admin-plugin/src/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/packages/ticketing/admin-plugin/src/hooks/useCancelTicket.ts b/packages/ticketing/admin-plugin/src/hooks/useCancelTicket.ts new file mode 100644 index 0000000000..9f462f061b --- /dev/null +++ b/packages/ticketing/admin-plugin/src/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/packages/ticketing/admin-plugin/src/hooks/useCheckGateCookie.ts b/packages/ticketing/admin-plugin/src/hooks/useCheckGateCookie.ts new file mode 100644 index 0000000000..6abe1eb3a0 --- /dev/null +++ b/packages/ticketing/admin-plugin/src/hooks/useCheckGateCookie.ts @@ -0,0 +1,22 @@ +import { gql } from '@apollo/client'; +import { useQuery } from '@apollo/client/react'; + +const CheckGateCookieQuery = gql` + query CheckGateCookie { + isPassCodeValid + } +`; + +const useCheckGateCookie = () => { + const { data, loading, refetch } = useQuery(CheckGateCookieQuery, { + fetchPolicy: 'cache-and-network', + }); + const authenticated = (data as any)?.isPassCodeValid === true; + return { + authenticated, + loading, + refetch, + }; +}; + +export default useCheckGateCookie; diff --git a/packages/ticketing/admin-plugin/src/hooks/useEventProduct.ts b/packages/ticketing/admin-plugin/src/hooks/useEventProduct.ts new file mode 100644 index 0000000000..f39e0243f2 --- /dev/null +++ b/packages/ticketing/admin-plugin/src/hooks/useEventProduct.ts @@ -0,0 +1,91 @@ +import { gql } from '@apollo/client'; +import { useQuery } from '@apollo/client/react'; +import { parseUniqueId } from '../utils/misc'; + +const TicketEventDetailQuery = gql` + query TicketEventDetail($productId: ID!) { + product(productId: $productId) { + _id + status + tags + ... on TokenizedProduct { + texts { + _id + slug + title + subtitle + description + } + media(limit: 1) { + _id + file { + _id + url + name + } + } + contractConfiguration { + ercMetadataProperties + supply + } + simulatedStocks { + quantity + } + tokensCount + isCanceled + scannerPassCode + tokens { + _id + tokenSerialNumber + invalidatedDate + isInvalidateable + isCanceled + quantity + status + walletAddress + user { + _id + username + isGuest + primaryEmail { + address + verified + } + avatar { + _id + url + } + profile { + displayName + address { + firstName + lastName + } + } + lastContact { + emailAddress + telNumber + } + } + } + } + } + } +`; + +const useEventProduct = ({ slug }: { slug: string }) => { + const productId = parseUniqueId(slug); + + const { data, loading, error } = useQuery(TicketEventDetailQuery, { + skip: !productId, + variables: { productId }, + }); + + return { + product: data?.product, + loading, + error, + }; +}; + +export default useEventProduct; diff --git a/packages/ticketing/admin-plugin/src/hooks/useEventProducts.ts b/packages/ticketing/admin-plugin/src/hooks/useEventProducts.ts new file mode 100644 index 0000000000..3740c00881 --- /dev/null +++ b/packages/ticketing/admin-plugin/src/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/packages/ticketing/admin-plugin/src/hooks/useGateEventDetail.ts b/packages/ticketing/admin-plugin/src/hooks/useGateEventDetail.ts new file mode 100644 index 0000000000..75a307bb7c --- /dev/null +++ b/packages/ticketing/admin-plugin/src/hooks/useGateEventDetail.ts @@ -0,0 +1,72 @@ +import { gql } from '@apollo/client'; +import { useQuery } from '@apollo/client/react'; + +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(GateEventDetailQuery, { + variables: { productId }, + skip: !productId, + fetchPolicy: 'cache-and-network', + pollInterval: 10000, + }); + + return { + event: (data as any)?.product || (previousData as any)?.product || null, + loading, + error, + refetch, + }; +}; + +export default useGateEventDetail; diff --git a/packages/ticketing/admin-plugin/src/hooks/useGateEvents.ts b/packages/ticketing/admin-plugin/src/hooks/useGateEvents.ts new file mode 100644 index 0000000000..533576883e --- /dev/null +++ b/packages/ticketing/admin-plugin/src/hooks/useGateEvents.ts @@ -0,0 +1,51 @@ +import { gql } from '@apollo/client'; +import { useQuery } from '@apollo/client/react'; + +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(GateEventsQuery, { + variables: { onlyInvalidateable }, + fetchPolicy: 'cache-and-network', + pollInterval: 10000, + }); + + const events = ((data as any)?.ticketEvents || (previousData as any)?.ticketEvents || []).filter( + (p: any) => p?.tokens?.length && !p.isCanceled, + ); + + return { + events, + loading, + error, + refetch, + }; +}; + +export default useGateEvents; diff --git a/packages/ticketing/admin-plugin/src/hooks/useInvalidateTicket.ts b/packages/ticketing/admin-plugin/src/hooks/useInvalidateTicket.ts new file mode 100644 index 0000000000..91c9872682 --- /dev/null +++ b/packages/ticketing/admin-plugin/src/hooks/useInvalidateTicket.ts @@ -0,0 +1,27 @@ +import { gql } from '@apollo/client'; +import { useMutation } from '@apollo/client/react'; +import { TokenFragment } from '../fragments/TokenFragment'; + +const InvalidateTokenMutation = gql` + mutation InvalidateToken($tokenId: ID!) { + invalidateToken(tokenId: $tokenId) { + ...TokenFragment + } + } + ${TokenFragment} +`; + +const useInvalidateTicket = () => { + const [invalidateTokenMutation] = useMutation(InvalidateTokenMutation); + + const invalidateTicket = async ({ tokenId }) => { + const result = await invalidateTokenMutation({ + variables: { tokenId }, + refetchQueries: ['Tokens', 'Token'], + }); + return result; + }; + return { invalidateTicket }; +}; + +export default useInvalidateTicket; diff --git a/packages/ticketing/admin-plugin/src/hooks/useIsPassCodeValid.ts b/packages/ticketing/admin-plugin/src/hooks/useIsPassCodeValid.ts new file mode 100644 index 0000000000..ea40e1f5dd --- /dev/null +++ b/packages/ticketing/admin-plugin/src/hooks/useIsPassCodeValid.ts @@ -0,0 +1,46 @@ +import { gql } from '@apollo/client'; +import { useMutation } from '@apollo/client/react'; + +const AuthenticateGateMutation = gql` + mutation AuthenticateGate($passCode: String!) { + authenticateGate(passCode: $passCode) + } +`; + +const DeauthenticateGateMutation = gql` + mutation DeauthenticateGate { + deauthenticateGate + } +`; + +const useIsPassCodeValid = () => { + const [authenticateGate, { loading: authLoading }] = useMutation(AuthenticateGateMutation); + const [deauthenticateGate, { loading: deauthLoading }] = useMutation(DeauthenticateGateMutation); + + const validatePassCode = async (passCode: string) => { + try { + const result = await authenticateGate({ + variables: { passCode }, + }); + return (result.data as any)?.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/packages/ticketing/admin-plugin/src/hooks/useSetScannerPassCode.ts b/packages/ticketing/admin-plugin/src/hooks/useSetScannerPassCode.ts new file mode 100644 index 0000000000..1315732c4f --- /dev/null +++ b/packages/ticketing/admin-plugin/src/hooks/useSetScannerPassCode.ts @@ -0,0 +1,34 @@ +import { gql } from '@apollo/client'; +import { useMutation } from '@apollo/client/react'; + +const SetEventScannerPassCodeMutation = gql` + mutation SetEventScannerPassCode($productId: ID!, $passCode: String) { + setEventScannerPassCode(productId: $productId, passCode: $passCode) { + _id + ... on TokenizedProduct { + scannerPassCode + } + } + } +`; + +const useSetScannerPassCode = () => { + const [setPassCodeMutation] = useMutation(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/packages/ticketing/admin-plugin/src/index.tsx b/packages/ticketing/admin-plugin/src/index.tsx new file mode 100644 index 0000000000..f8e35a97ac --- /dev/null +++ b/packages/ticketing/admin-plugin/src/index.tsx @@ -0,0 +1,3 @@ +export { default as TicketingPage } from './pages/TicketingPage'; +export { default as TicketEventDetailPage } from './pages/TicketEventDetailPage'; +export { default as GateControlPage } from './pages/GateControlPage'; diff --git a/packages/ticketing/admin-plugin/src/pages/GateControlPage.tsx b/packages/ticketing/admin-plugin/src/pages/GateControlPage.tsx new file mode 100644 index 0000000000..b3f6b72482 --- /dev/null +++ b/packages/ticketing/admin-plugin/src/pages/GateControlPage.tsx @@ -0,0 +1,32 @@ +import { useIntl } from 'react-intl'; +import useCheckGateCookie from '../hooks/useCheckGateCookie'; +import GatePassCodeForm from '../components/GatePassCodeForm'; +import GateControl from '../components/GateControl'; +import { Loading } from '@unchainedshop/admin-ui/ui'; + +const GateControlPage = () => { + const { formatMessage } = useIntl(); + const { authenticated, loading, refetch } = useCheckGateCookie(); + + return ( +
+
+

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

+
+ {loading ? ( + + ) : authenticated ? ( + refetch()} /> + ) : ( + refetch()} /> + )} +
+ ); +}; + +export default GateControlPage; diff --git a/packages/ticketing/admin-plugin/src/pages/TicketEventDetailPage.tsx b/packages/ticketing/admin-plugin/src/pages/TicketEventDetailPage.tsx new file mode 100644 index 0000000000..b3f0feeed3 --- /dev/null +++ b/packages/ticketing/admin-plugin/src/pages/TicketEventDetailPage.tsx @@ -0,0 +1,28 @@ +import { useIntl } from 'react-intl'; +import { Loading, PageHeader } from '@unchainedshop/admin-ui/ui'; +import TicketEventDetail from '../components/TicketEventDetail'; +import useEventProduct from '../hooks/useEventProduct'; + +const TicketEventDetailPage = ({ entityId }) => { + const { formatMessage } = useIntl(); + const { product, loading } = useEventProduct({ slug: entityId as string }); + + if (loading) return ; + + return ( + <> + + + + ); +}; + +export default TicketEventDetailPage; diff --git a/packages/ticketing/admin-plugin/src/pages/TicketingPage.tsx b/packages/ticketing/admin-plugin/src/pages/TicketingPage.tsx new file mode 100644 index 0000000000..484fcc3abc --- /dev/null +++ b/packages/ticketing/admin-plugin/src/pages/TicketingPage.tsx @@ -0,0 +1,89 @@ +import { useIntl } from 'react-intl'; +import { useRouter } from 'next/router'; +import { + Loading, + NoData, + PageHeader, + ListHeader, + AnimatedCounter, + SearchField, +} from '@unchainedshop/admin-ui/ui'; +import TicketEventList from '../components/TicketEventList'; +import useEventProducts from '../hooks/useEventProducts'; + +const TicketingPage = () => { + const { formatMessage } = useIntl(); + const { query, push } = useRouter(); + + const { queryString, ...rest } = query; + + const setQueryString = (searchString) => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + 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, + }); + + const headerText = + productsCount === 1 + ? formatMessage({ + id: 'event_header', + defaultMessage: '1 Event', + }) + : formatMessage( + { + id: 'event_count_header', + defaultMessage: '{count} Events', + }, + { count: }, + ); + + return ( + <> + +
+ +
+ +
+ {loading ? : } + {!loading && !products?.length && ( + + )} +
+ + ); +}; + +export default TicketingPage; diff --git a/packages/ticketing/admin-plugin/src/utils/misc.ts b/packages/ticketing/admin-plugin/src/utils/misc.ts new file mode 100644 index 0000000000..49e5186e06 --- /dev/null +++ b/packages/ticketing/admin-plugin/src/utils/misc.ts @@ -0,0 +1,63 @@ +export const DefaultLimit = 50; + +export const TOKEN_STATUSES = { + CENTRALIZED: 'sky', + EXPORTING: 'amber', + DECENTRALIZED: 'emerald', +}; + +export const formatUsername = (user) => { + if (!user) return null; + if (user?.username) return user.username; + if (user?.profile?.displayName) return user?.profile?.displayName; + if (user?.profile?.address?.firstName || user?.profile?.address?.lastName) + return `${user?.profile?.address?.firstName} ${user?.profile?.address?.lastName}`; + if (user?.name) return user.name; + if (user.isGuest) return user?.primaryEmail?.address?.split('.')?.[0]; + return null; +}; + +export const shortenAddress = (fullAddress) => { + return fullAddress ? `${fullAddress.substr(0, 6)}...${fullAddress.substr(-4, 4)}` : '0x0'; +}; + +export const useFormatPrice = () => { + const formatPrice = (price: { currencyCode: string; amount: number }) => { + if (!price?.currencyCode) return 'n/a'; + if (price?.amount === undefined || price?.amount === null) return ''; + const { amount, currencyCode } = price || {}; + return new Intl.NumberFormat(navigator.language, { + style: 'currency', + currency: currencyCode, + }).format(amount / 100); + }; + + return { formatPrice }; +}; + +export const useFormatDateTime = () => { + const formatDateTime = (date, options: Intl.DateTimeFormatOptions = {}) => { + if (!date || !Date.parse(date)) return 'n/a'; + + return Intl.DateTimeFormat(undefined, options).format(new Date(date).getTime()); + }; + + return { formatDateTime }; +}; + +export const generateUniqueId = (params: any = {}) => { + const { _id, texts } = params || {}; + if (!texts && !_id) return null; + return `${texts?.slug?.split('/').join('') || ''}_id_${_id}`; +}; + +export const parseUniqueId = (value) => { + if (!value) return null; + const slugAndId = value?.split('_id_'); + return slugAndId?.pop(); +}; + +export const defaultNextImageLoader = ({ src, width, quality = 75 }) => { + if (src) return `${src}?w=${width}&q=${quality}`; + return '/no-image.jpg'; +}; diff --git a/packages/ticketing/admin-plugin/tsconfig.json b/packages/ticketing/admin-plugin/tsconfig.json new file mode 100644 index 0000000000..b3f3698885 --- /dev/null +++ b/packages/ticketing/admin-plugin/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "jsx": "react-jsx", + "strict": false, + "esModuleInterop": true, + "skipLibCheck": true, + "noEmit": true + }, + "include": ["src"] +} diff --git a/packages/ticketing/admin-plugin/tsup.config.ts b/packages/ticketing/admin-plugin/tsup.config.ts new file mode 100644 index 0000000000..1e02343a61 --- /dev/null +++ b/packages/ticketing/admin-plugin/tsup.config.ts @@ -0,0 +1,3 @@ +import { definePluginConfig } from '@unchainedshop/admin-ui/plugin-build'; + +export default definePluginConfig('ticketing'); diff --git a/packages/ticketing/package.json b/packages/ticketing/package.json index d6c71d6ff9..00ee0e7830 100644 --- a/packages/ticketing/package.json +++ b/packages/ticketing/package.json @@ -5,9 +5,32 @@ "main": "lib/index.js", "types": "lib/index.d.ts", "type": "module", + "exports": { + ".": { + "import": "./lib/index.js", + "types": "./lib/index.d.ts" + }, + "./lib/*": { + "import": "./lib/*", + "types": "./lib/*" + }, + "./lib/*.js": { + "import": "./lib/*.js", + "types": "./lib/*.d.ts" + }, + "./admin-plugin": { + "import": "./lib/admin-plugin.js", + "types": "./lib/admin-plugin.d.ts" + } + }, + "files": [ + "lib", + "admin-plugin/dist" + ], "scripts": { "clean": "tsc -b --clean", - "build": "tsc -b", + "build": "tsc -b && npm run build:admin-plugin", + "build:admin-plugin": "cd admin-plugin && npx tsup", "prepublishOnly": "npm run clean && npm run build", "watch": "tsc -w", "test": "node --test", @@ -37,7 +60,10 @@ "@unchainedshop/api": "^5.0.0-alpha.1", "@unchainedshop/core": "^5.0.0-alpha.1", "@unchainedshop/core-files": "^5.0.0-alpha.1", + "@unchainedshop/core-products": "^5.0.0-alpha.1", + "@unchainedshop/core-orders": "^5.0.0-alpha.1", "@unchainedshop/core-warehousing": "^5.0.0-alpha.1", + "@unchainedshop/utils": "^5.0.0-alpha.1", "@unchainedshop/core-worker": "^5.0.0-alpha.1", "@unchainedshop/events": "^5.0.0-alpha.1", "@unchainedshop/logger": "^5.0.0-alpha.1", diff --git a/packages/ticketing/src/admin-plugin.ts b/packages/ticketing/src/admin-plugin.ts new file mode 100644 index 0000000000..7d75708aaa --- /dev/null +++ b/packages/ticketing/src/admin-plugin.ts @@ -0,0 +1,64 @@ +import { resolve, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { definePlugin, type PluginSlots } from '@unchainedshop/admin-ui/plugins'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +export const ticketingBundlePath = resolve(__dirname, '../admin-plugin/dist/index.js'); + +export const ticketingEntities = [ + { + path: '/ticketing', + label: 'Events', + icon: 'ticket', + sortOrder: 90, + components: { + list: 'TicketingPage', + detail: 'TicketEventDetailPage', + }, + }, +]; + +export const ticketingPages = [ + { + path: '/gate-control', + label: 'Gate Control', + icon: 'shield-check', + sortOrder: 92, + component: 'GateControlPage', + }, +]; + +export const ticketingLinks = [ + { + href: '/ext/gate-control', + label: 'Gate Control', + icon: 'shield-check', + showOnLoginPage: true, + }, +]; + +export const ticketingNavigation = { + label: 'Ticketing', + icon: 'ticket', + sortOrder: 90, +}; + +export function ticketingAdminPlugin(additionalSlots?: PluginSlots) { + return definePlugin({ + name: 'ticketing', + version: '1.0.0', + bundlePath: ticketingBundlePath, + navigation: ticketingNavigation, + slots: { + entities: [...ticketingEntities, ...(additionalSlots?.entities || [])], + pages: [...ticketingPages, ...(additionalSlots?.pages || [])], + links: [...ticketingLinks, ...(additionalSlots?.links || [])], + ...Object.fromEntries( + Object.entries(additionalSlots || {}).filter( + ([key]) => !['entities', 'pages', 'links'].includes(key), + ), + ), + }, + }); +} diff --git a/packages/ticketing/src/api/errors.ts b/packages/ticketing/src/api/errors.ts new file mode 100644 index 0000000000..abd74f1a62 --- /dev/null +++ b/packages/ticketing/src/api/errors.ts @@ -0,0 +1,11 @@ +import { createError } from '@unchainedshop/api'; + +export const TokenAlreadyRedeemedError = createError( + 'TokenAlreadyRedeemedError', + 'Cannot cancel a redeemed ticket', +); + +export const TicketingModuleNotFoundError = createError( + 'TicketingModuleNotFoundError', + 'Ticketing module (passes) is not available, please configure @unchainedshop/ticketing', +); diff --git a/packages/ticketing/src/api/gate-cookie.ts b/packages/ticketing/src/api/gate-cookie.ts new file mode 100644 index 0000000000..84b2dda291 --- /dev/null +++ b/packages/ticketing/src/api/gate-cookie.ts @@ -0,0 +1,33 @@ +import type { CookieOptions } from '@unchainedshop/api'; + +const { + UNCHAINED_GATE_COOKIE_NAME = 'unchained_gate_passcode', + UNCHAINED_GATE_COOKIE_MAX_AGE_SECONDS = '86400', // 24 hours + UNCHAINED_COOKIE_PATH = '/', + UNCHAINED_COOKIE_DOMAIN, + UNCHAINED_COOKIE_SAMESITE = 'lax', + UNCHAINED_COOKIE_INSECURE, +} = process.env; + +export const GATE_COOKIE_NAME = UNCHAINED_GATE_COOKIE_NAME; +export const GATE_COOKIE_MAX_AGE = parseInt(UNCHAINED_GATE_COOKIE_MAX_AGE_SECONDS, 10) * 1000; + +const resolveSameSite = (): CookieOptions['sameSite'] => + ( + ({ + none: 'none', + lax: 'lax', + strict: 'strict', + }) as Record + )[UNCHAINED_COOKIE_SAMESITE?.trim()?.toLowerCase()] || 'lax'; + +export function getGateCookieOptions(maxAge?: number): CookieOptions { + return { + domain: UNCHAINED_COOKIE_DOMAIN, + path: UNCHAINED_COOKIE_PATH, + secure: !UNCHAINED_COOKIE_INSECURE, + httpOnly: true, + sameSite: resolveSameSite(), + maxAge, + }; +} diff --git a/packages/ticketing/src/api/index.ts b/packages/ticketing/src/api/index.ts new file mode 100644 index 0000000000..2568c0c69e --- /dev/null +++ b/packages/ticketing/src/api/index.ts @@ -0,0 +1,44 @@ +import { acl } from '@unchainedshop/api'; +import ticketEvents from './resolvers/queries/ticketEvents.ts'; +import ticketEventsCount from './resolvers/queries/ticketEventsCount.ts'; +import isPassCodeValid from './resolvers/queries/isPassCodeValid.ts'; +import cancelTicket from './resolvers/mutations/cancelTicket.ts'; +import cancelEvent from './resolvers/mutations/cancelEvent.ts'; +import setEventScannerPassCode from './resolvers/mutations/setEventScannerPassCode.ts'; +import authenticateGate from './resolvers/mutations/authenticateGate.ts'; +import deauthenticateGate from './resolvers/mutations/deauthenticateGate.ts'; +import typeDefs from './schema.ts'; +import { ticketingActions, configureTicketingRoles } from './roles.ts'; + +const { checkResolver, checkAction } = acl; + +const ticketingResolvers = { + Query: { + ticketEvents: checkResolver('gateControl')(ticketEvents), + ticketEventsCount: checkResolver('gateControl')(ticketEventsCount), + isPassCodeValid: checkResolver('validatePassCode')(isPassCodeValid), + }, + Mutation: { + cancelTicket: checkResolver('updateToken')(cancelTicket), + cancelEvent: checkResolver('manageProducts')(cancelEvent), + setEventScannerPassCode: checkResolver('manageProducts')(setEventScannerPassCode), + authenticateGate: checkResolver('validatePassCode')(authenticateGate), + deauthenticateGate: checkResolver('validatePassCode')(deauthenticateGate), + }, + TokenizedProduct: { + async scannerPassCode(product: any, params: never, requestContext: any) { + await checkAction(requestContext, 'manageProducts', [undefined, params]); + return (product.meta as Record)?.scannerPassCode || null; + }, + isCanceled(product: any) { + return Boolean(product.meta?.cancelled); + }, + }, + Token: { + isCanceled(token: any) { + return Boolean(token.meta?.cancelled); + }, + }, +}; + +export { typeDefs as ticketingTypeDefs, ticketingResolvers, ticketingActions, configureTicketingRoles }; diff --git a/packages/ticketing/src/api/resolvers/mutations/authenticateGate.ts b/packages/ticketing/src/api/resolvers/mutations/authenticateGate.ts new file mode 100644 index 0000000000..55f95d0620 --- /dev/null +++ b/packages/ticketing/src/api/resolvers/mutations/authenticateGate.ts @@ -0,0 +1,32 @@ +import { log } from '@unchainedshop/logger'; +import type { Context } from '@unchainedshop/api'; +import { TicketingModuleNotFoundError } from '../../errors.ts'; +import { GATE_COOKIE_NAME, GATE_COOKIE_MAX_AGE, getGateCookieOptions } from '../../gate-cookie.ts'; + +export default async function authenticateGate( + root: never, + { passCode }: { passCode: string }, + context: Context, +) { + const { services, userId } = context; + log(`mutation authenticateGate`, { userId }); + + if (!passCode) return false; + + const ticketingServices = services as unknown as { + ticketing?: { + isPassCodeValid: (passCode: string, productId?: string) => Promise; + }; + }; + + if (!ticketingServices.ticketing?.isPassCodeValid) { + throw new TicketingModuleNotFoundError({}); + } + + const isValid = await ticketingServices.ticketing.isPassCodeValid(passCode); + if (!isValid) return false; + + context.setCookie(GATE_COOKIE_NAME, passCode, getGateCookieOptions(GATE_COOKIE_MAX_AGE)); + + return true; +} diff --git a/packages/ticketing/src/api/resolvers/mutations/cancelEvent.ts b/packages/ticketing/src/api/resolvers/mutations/cancelEvent.ts new file mode 100644 index 0000000000..06e3dcc78d --- /dev/null +++ b/packages/ticketing/src/api/resolvers/mutations/cancelEvent.ts @@ -0,0 +1,41 @@ +import type { Context } from '@unchainedshop/api'; +import { log } from '@unchainedshop/logger'; +import { ProductStatus } from '@unchainedshop/core-products'; +import { InvalidIdError, ProductNotFoundError, ProductWrongStatusError } from '@unchainedshop/api'; +import { TicketingModuleNotFoundError } from '../../errors.ts'; + +export default async function cancelEvent( + root: never, + { productId, generateDiscount }: { productId: string; generateDiscount?: boolean }, + context: Context, +) { + const { modules, services, userId, countryCode, currencyCode } = context; + log(`mutation cancelEvent ${productId}`, { userId }); + + if (!productId) throw new InvalidIdError({ productId }); + + const product = await modules.products.findProduct({ productId }); + if (!product) throw new ProductNotFoundError({ productId }); + + if (product.status !== ProductStatus.ACTIVE) { + throw new ProductWrongStatusError({ productId }); + } + + const passes = (modules as unknown as Record).passes as any; + if (!passes?.cancelTicket) { + throw new TicketingModuleNotFoundError({}); + } + + const ticketingServices = (services as unknown as any).ticketing; + if (!ticketingServices?.cancelTicketsForProduct) { + throw new TicketingModuleNotFoundError({}); + } + + const result = await ticketingServices.cancelTicketsForProduct(productId, { + generateDiscount, + countryCode, + currencyCode, + }); + + return result.cancelledCount; +} diff --git a/packages/ticketing/src/api/resolvers/mutations/cancelTicket.ts b/packages/ticketing/src/api/resolvers/mutations/cancelTicket.ts new file mode 100644 index 0000000000..9d8bfc54d1 --- /dev/null +++ b/packages/ticketing/src/api/resolvers/mutations/cancelTicket.ts @@ -0,0 +1,44 @@ +import type { Context } from '@unchainedshop/api'; +import { log } from '@unchainedshop/logger'; +import { InvalidIdError, TokenNotFoundError } from '@unchainedshop/api'; +import { TicketingModuleNotFoundError, TokenAlreadyRedeemedError } from '../../errors.ts'; + +export default async function cancelTicket( + root: never, + { tokenId, generateDiscount }: { tokenId: string; generateDiscount?: boolean }, + context: Context, +) { + const { modules, services, userId, countryCode, currencyCode } = context; + log(`mutation cancelTicket ${tokenId}`, { userId, generateDiscount }); + + if (!tokenId) throw new InvalidIdError({ tokenId }); + + const token = await modules.warehousing.findToken({ tokenId }); + if (!token) throw new TokenNotFoundError({ tokenId }); + + if (token.meta?.cancelled) { + return token; + } + + if (token.invalidatedDate) { + throw new TokenAlreadyRedeemedError({ tokenId }); + } + + const passes = (modules as unknown as Record).passes as any; + if (!passes?.cancelTicket) { + throw new TicketingModuleNotFoundError({}); + } + + const ticketingServices = (services as unknown as any).ticketing; + if (!ticketingServices?.cancelTicketWithDiscount) { + throw new TicketingModuleNotFoundError({}); + } + + const result = await ticketingServices.cancelTicketWithDiscount(tokenId, { + generateDiscount, + countryCode, + currencyCode, + }); + + return result.token; +} diff --git a/packages/ticketing/src/api/resolvers/mutations/deauthenticateGate.ts b/packages/ticketing/src/api/resolvers/mutations/deauthenticateGate.ts new file mode 100644 index 0000000000..665b091105 --- /dev/null +++ b/packages/ticketing/src/api/resolvers/mutations/deauthenticateGate.ts @@ -0,0 +1,12 @@ +import { log } from '@unchainedshop/logger'; +import type { Context } from '@unchainedshop/api'; +import { GATE_COOKIE_NAME, getGateCookieOptions } from '../../gate-cookie.ts'; + +export default async function deauthenticateGate(root: never, _: never, context: Context) { + const { userId } = context; + log(`mutation deauthenticateGate`, { userId }); + + context.clearCookie(GATE_COOKIE_NAME, getGateCookieOptions()); + + return true; +} diff --git a/packages/ticketing/src/api/resolvers/mutations/setEventScannerPassCode.ts b/packages/ticketing/src/api/resolvers/mutations/setEventScannerPassCode.ts new file mode 100644 index 0000000000..223affe278 --- /dev/null +++ b/packages/ticketing/src/api/resolvers/mutations/setEventScannerPassCode.ts @@ -0,0 +1,29 @@ +import { log } from '@unchainedshop/logger'; +import { InvalidIdError, ProductNotFoundError } from '@unchainedshop/api'; +import type { Context } from '@unchainedshop/api'; + +export default async function setEventScannerPassCode( + root: never, + { productId, passCode }: { productId: string; passCode?: string | null }, + { modules, userId }: Context, +) { + log(`mutation setEventScannerPassCode ${productId}`, { userId }); + + if (!productId) throw new InvalidIdError({ productId }); + + const product = await modules.products.findProduct({ productId }); + if (!product) throw new ProductNotFoundError({ productId }); + + const existingMeta = (product.meta as Record) || {}; + const updatedMeta = { ...existingMeta }; + + if (passCode === null || passCode === undefined) { + delete updatedMeta.scannerPassCode; + } else { + updatedMeta.scannerPassCode = passCode; + } + + await modules.products.update(productId, { meta: updatedMeta }); + + return modules.products.findProduct({ productId }); +} diff --git a/packages/ticketing/src/api/resolvers/queries/isPassCodeValid.ts b/packages/ticketing/src/api/resolvers/queries/isPassCodeValid.ts new file mode 100644 index 0000000000..5d32a41140 --- /dev/null +++ b/packages/ticketing/src/api/resolvers/queries/isPassCodeValid.ts @@ -0,0 +1,29 @@ +import { log } from '@unchainedshop/logger'; +import type { Context } from '@unchainedshop/api'; +import { TicketingModuleNotFoundError } from '../../errors.ts'; +import { GATE_COOKIE_NAME } from '../../gate-cookie.ts'; + +interface TicketingServices { + ticketing?: { + isPassCodeValid: (passCode: string, productId?: string) => Promise; + }; +} + +export default async function isPassCodeValid( + root: never, + { productId }: { productId?: string }, + context: Context, +) { + const { services, userId } = context; + log(`query isPassCodeValid`, { userId }); + + const passCode = context.getCookie(GATE_COOKIE_NAME); + if (!passCode) return false; + + const ticketingServices = services as unknown as TicketingServices; + if (!ticketingServices.ticketing?.isPassCodeValid) { + throw new TicketingModuleNotFoundError({}); + } + + return ticketingServices.ticketing.isPassCodeValid(passCode, productId); +} diff --git a/packages/ticketing/src/api/resolvers/queries/ticketEvents.ts b/packages/ticketing/src/api/resolvers/queries/ticketEvents.ts new file mode 100644 index 0000000000..a8410126c0 --- /dev/null +++ b/packages/ticketing/src/api/resolvers/queries/ticketEvents.ts @@ -0,0 +1,76 @@ +import { log } from '@unchainedshop/logger'; +import type { SortOption } from '@unchainedshop/utils'; +import type { Context } from '@unchainedshop/api'; +import { TicketingModuleNotFoundError } from '../../errors.ts'; +import { GATE_COOKIE_NAME } from '../../gate-cookie.ts'; + +export default async function ticketEvents( + root: never, + { + queryString, + limit = 50, + offset = 0, + includeDrafts = true, + sort, + onlyInvalidateable = false, + }: { + queryString?: string; + limit: number; + offset: number; + includeDrafts?: boolean; + sort?: SortOption[]; + onlyInvalidateable?: boolean; + }, + context: Context, +) { + const { modules, services, userId } = context; + log(`query ticketEvents`, { userId }); + + const passCode = context.getCookie?.(GATE_COOKIE_NAME); + const ticketingServices = (context.services as any)?.ticketing; + + let products; + + if (!userId && passCode) { + if (!ticketingServices?.productIdsForPassCode) { + throw new TicketingModuleNotFoundError({}); + } + const productIds = await ticketingServices.productIdsForPassCode(passCode); + if (!productIds.length) return []; + + const allProducts = await modules.products.findProducts({ + type: 'TOKENIZED_PRODUCT', + queryString, + includeDrafts: false, + limit, + offset, + sort, + }); + + products = allProducts.filter((p) => productIds.includes(p._id)); + } else { + products = await modules.products.findProducts({ + type: 'TOKENIZED_PRODUCT', + queryString, + includeDrafts, + limit, + offset, + sort, + }); + } + + if (onlyInvalidateable) { + const filtered = await Promise.all( + products.map(async (product) => { + const tokens = await modules.warehousing.findTokens({ productId: product._id }); + const hasInvalidateable = await Promise.all( + tokens.map((token) => services.warehousing.isTokenInvalidateable({ token })), + ); + return hasInvalidateable.some(Boolean) ? product : null; + }), + ); + return filtered.filter(Boolean); + } + + return products; +} diff --git a/packages/ticketing/src/api/resolvers/queries/ticketEventsCount.ts b/packages/ticketing/src/api/resolvers/queries/ticketEventsCount.ts new file mode 100644 index 0000000000..486a800df5 --- /dev/null +++ b/packages/ticketing/src/api/resolvers/queries/ticketEventsCount.ts @@ -0,0 +1,65 @@ +import { log } from '@unchainedshop/logger'; +import type { Context } from '@unchainedshop/api'; +import { TicketingModuleNotFoundError } from '../../errors.ts'; +import { GATE_COOKIE_NAME } from '../../gate-cookie.ts'; + +export default async function ticketEventsCount( + root: never, + { + queryString, + includeDrafts = true, + onlyInvalidateable = false, + }: { + queryString?: string; + includeDrafts?: boolean; + onlyInvalidateable?: boolean; + }, + context: Context, +) { + const { modules, services, userId } = context; + log(`query ticketEventsCount`, { userId }); + + const passCode = context.getCookie?.(GATE_COOKIE_NAME); + const ticketingServices = (context.services as any)?.ticketing; + + if (!userId && passCode) { + if (!ticketingServices?.productIdsForPassCode) { + throw new TicketingModuleNotFoundError({}); + } + const productIds = await ticketingServices.productIdsForPassCode(passCode); + if (!onlyInvalidateable) return productIds.length; + + let count = 0; + for (const productId of productIds) { + const tokens = await modules.warehousing.findTokens({ productId }); + const hasInvalidateable = await Promise.all( + tokens.map((token) => services.warehousing.isTokenInvalidateable({ token })), + ); + if (hasInvalidateable.some(Boolean)) count++; + } + return count; + } + + if (onlyInvalidateable) { + const products = await modules.products.findProducts({ + type: 'TOKENIZED_PRODUCT', + queryString, + includeDrafts, + }); + let count = 0; + for (const product of products) { + const tokens = await modules.warehousing.findTokens({ productId: product._id }); + const hasInvalidateable = await Promise.all( + tokens.map((token) => services.warehousing.isTokenInvalidateable({ token })), + ); + if (hasInvalidateable.some(Boolean)) count++; + } + return count; + } + + return modules.products.count({ + type: 'TOKENIZED_PRODUCT', + queryString, + includeDrafts, + }); +} diff --git a/packages/ticketing/src/api/roles.ts b/packages/ticketing/src/api/roles.ts new file mode 100644 index 0000000000..d4dae2d9bd --- /dev/null +++ b/packages/ticketing/src/api/roles.ts @@ -0,0 +1,49 @@ +import type { Context } from '@unchainedshop/api'; +import { roles } from '@unchainedshop/api'; +import { GATE_COOKIE_NAME } from './gate-cookie.ts'; + +export const ticketingActions = ['validatePassCode', 'gateControl']; + +export function configureTicketingRoles(_role: any, actions: Record) { + const { allRoles } = roles; + + const hasValidPassCode = async (_root: any, _params: any, context: Context) => { + const passCode = context.getCookie?.(GATE_COOKIE_NAME); + if (!passCode) return false; + const ticketingServices = (context.services as any)?.ticketing; + if (!ticketingServices?.isPassCodeValid) return false; + return ticketingServices.isPassCodeValid(passCode); + }; + + const hasValidPassCodeForProduct = async (root: any, _params: any, context: Context) => { + if (!root?._id) return false; + const passCode = context.getCookie?.(GATE_COOKIE_NAME); + if (!passCode) return false; + const ticketingServices = (context.services as any)?.ticketing; + if (!ticketingServices?.isPassCodeValid) return false; + return ticketingServices.isPassCodeValid(passCode, root._id); + }; + + const hasValidPassCodeForToken = async (_root: any, params: any, context: Context) => { + const passCode = context.getCookie?.(GATE_COOKIE_NAME); + if (!passCode) return false; + const ticketingServices = (context.services as any)?.ticketing; + if (!ticketingServices?.isPassCodeValid) return false; + const tokenId = params?.tokenId; + if (!tokenId) return false; + const token = await context.modules.warehousing.findToken({ tokenId }); + if (!token) return false; + return ticketingServices.isPassCodeValid(passCode, token.productId); + }; + + // ALL role: gate control permissions + allRoles.ALL.allow(actions.validatePassCode, () => true); + allRoles.ALL.allow(actions.gateControl, hasValidPassCode); + allRoles.ALL.allow(actions.viewTokens, hasValidPassCodeForProduct); + allRoles.ALL.allow(actions.updateToken, hasValidPassCodeForToken); + allRoles.ALL.allow(actions.viewUserPrivateInfos, hasValidPassCode); + + // LOGGEDIN role: gate control always allowed + allRoles.LOGGEDIN.allow(actions.validatePassCode, () => true); + allRoles.LOGGEDIN.allow(actions.gateControl, () => true); +} diff --git a/packages/ticketing/src/api/schema.ts b/packages/ticketing/src/api/schema.ts new file mode 100644 index 0000000000..cf30ac74b2 --- /dev/null +++ b/packages/ticketing/src/api/schema.ts @@ -0,0 +1,73 @@ +export default [ + /* GraphQL */ ` + extend type Query { + """ + List all ticket events (tokenized products), by default includes drafts + """ + ticketEvents( + queryString: String + limit: Int = 50 + offset: Int = 0 + includeDrafts: Boolean = true + sort: [SortOptionInput!] + onlyInvalidateable: Boolean = false + ): [Product!]! + + """ + Returns total number of ticket events (tokenized products) + """ + ticketEventsCount( + queryString: String + includeDrafts: Boolean = true + onlyInvalidateable: Boolean = false + ): Int! + + """ + 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(productId: ID): Boolean! + } + + extend type Mutation { + """ + Cancel a ticket (token). Sets the cancelled flag on the token metadata. + Optionally generates a discount code for reimbursement. + """ + cancelTicket(tokenId: ID!, generateDiscount: Boolean): Token! + + """ + 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(productId: ID!, generateDiscount: Boolean): Int! + + """ + Set or remove the scanner pass code for gate control on a tokenized product. + Pass null to remove the pass code. + """ + setEventScannerPassCode(productId: ID!, passCode: String): Product! + + """ + Authenticate gate control by validating a pass code and setting an HttpOnly cookie. + Returns true if the pass code is valid. + """ + authenticateGate(passCode: String!): Boolean! + + """ + Deauthenticate gate control by clearing the gate pass code cookie. + """ + deauthenticateGate: Boolean! + } + + extend type TokenizedProduct { + scannerPassCode: String @cacheControl(scope: PRIVATE, maxAge: 0) + isCanceled: Boolean + } + + extend type Token { + isCanceled: Boolean + } + `, +]; diff --git a/packages/ticketing/src/discount-codes.ts b/packages/ticketing/src/discount-codes.ts new file mode 100644 index 0000000000..e6922987b3 --- /dev/null +++ b/packages/ticketing/src/discount-codes.ts @@ -0,0 +1,108 @@ +export interface DiscountCodeHandlers { + generate: (amount: number) => Promise; + verify: (code: string) => Promise; +} + +const defaultDiscountCodeSecret = '0000000000000000000000000000000000000000000000000000000000000000'; + +async function siphash24Digest(payload: Uint8Array, key: Uint8Array): Promise { + const cryptoKey = await crypto.subtle.importKey( + 'raw', + key as ArrayBufferView, + { name: 'HMAC', hash: 'SHA-256' }, + false, + ['sign'], + ); + const signature = await crypto.subtle.sign('HMAC', cryptoKey, payload as ArrayBufferView); + return new Uint8Array(signature).slice(0, 8); +} + +function toBase58(buffer: Uint8Array): string { + const ALPHABET = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz'; + let num = 0n; + for (const byte of buffer) { + num = num * 256n + BigInt(byte); + } + if (num === 0n) return ALPHABET[0]; + let result = ''; + while (num > 0n) { + result = ALPHABET[Number(num % 58n)] + result; + num = num / 58n; + } + return result; +} + +function fromBase58(str: string): Uint8Array { + const ALPHABET = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz'; + let num = 0n; + for (const char of str) { + const index = ALPHABET.indexOf(char); + if (index === -1) throw new Error(`Invalid base58 character: ${char}`); + num = num * 58n + BigInt(index); + } + const bytes: number[] = []; + while (num > 0n) { + bytes.unshift(Number(num & 0xffn)); + num = num >> 8n; + } + return new Uint8Array(bytes); +} + +export function createDefaultDiscountCodeHandlers(): DiscountCodeHandlers { + const secret = process.env.DISCOUNT_CODE_SECRET || defaultDiscountCodeSecret; + const keyBytes = new Uint8Array(secret.match(/.{1,2}/g)!.map((byte) => parseInt(byte, 16))); + + const generate = async (priceAmount: number): Promise => { + const salt = crypto.getRandomValues(new Uint16Array(1))[0]; + const priceCents = Math.floor(priceAmount / 100); + const uint16Array = new Uint16Array([priceCents, salt]); + const payloadBuffer = new Uint8Array( + uint16Array.buffer, + uint16Array.byteOffset, + uint16Array.byteLength, + ); + + const hash = await siphash24Digest(payloadBuffer, keyBytes); + const signature = toBase58(hash); + const payload = toBase58(payloadBuffer); + + return signature.replace(/(.{4})(.{4})(.*)/, `${payload}-$1-$2-$3`); + }; + + const verify = async (dashedSignature: string): Promise => { + try { + const [payload] = dashedSignature.split('-'); + const payloadBytes = fromBase58(payload); + // Ensure we have exactly 4 bytes for two uint16 values + const padded = new Uint8Array(4); + padded.set(payloadBytes.slice(0, 4)); + const uint16Array = new Uint16Array(padded.buffer); + + const priceCents = uint16Array[0]; + const salt = uint16Array[1]; + + const priceAmount = Math.floor(priceCents * 100); + + // Need to regenerate with same salt for comparison + const saltedUint16 = new Uint16Array([priceCents, salt]); + const saltedPayload = new Uint8Array( + saltedUint16.buffer, + saltedUint16.byteOffset, + saltedUint16.byteLength, + ); + const hash = await siphash24Digest(saltedPayload, keyBytes); + const sig = toBase58(hash); + const pay = toBase58(saltedPayload); + const expected = sig.replace(/(.{4})(.{4})(.*)/, `${pay}-$1-$2-$3`); + + if (expected === dashedSignature) { + return priceAmount; + } + } catch { + /* invalid code */ + } + return null; + }; + + return { generate, verify }; +} diff --git a/packages/ticketing/src/index.ts b/packages/ticketing/src/index.ts index c7c18c800f..14112098b3 100644 --- a/packages/ticketing/src/index.ts +++ b/packages/ticketing/src/index.ts @@ -1,21 +1,41 @@ import { subscribe } from '@unchainedshop/events'; import type { RawPayloadType } from '@unchainedshop/events'; import { WorkerEventTypes, type Work } from '@unchainedshop/core-worker'; -import type { UnchainedCore } from '@unchainedshop/core'; +import { type UnchainedCore } from '@unchainedshop/core'; import { RendererTypes, registerRenderer } from './template-registry.ts'; -import ticketingModules, { type TicketingModule } from './module.ts'; +import ticketingModules, { type TicketingModule, type TicketingOptions } from './module.ts'; import setupMagicKey from './magic-key.ts'; import ticketingServices, { type TicketingServices } from './services.ts'; +import type { DiscountCodeHandlers } from './discount-codes.ts'; +import { + ticketingTypeDefs, + ticketingResolvers, + ticketingActions, + configureTicketingRoles, +} from './api/index.ts'; export type TicketingAPI = UnchainedCore & { modules: TicketingModule; services: TicketingServices; }; -export type { RendererTypes, TicketingModule, TicketingServices }; +export type { + RendererTypes, + TicketingModule, + TicketingServices, + TicketingOptions, + DiscountCodeHandlers, +}; -export { ticketingServices, ticketingModules }; +export { + ticketingServices, + ticketingModules, + ticketingTypeDefs, + ticketingResolvers, + ticketingActions, + configureTicketingRoles, +}; export function setupPDFTickets({ renderOrderPDF }: { renderOrderPDF: any }) { registerRenderer(RendererTypes.ORDER_PDF, renderOrderPDF); @@ -39,10 +59,10 @@ export default function setupTicketing( createAppleWalletPass, createGoogleWalletPass, }: { - renderOrderPDF: any; - createAppleWalletPass: any; - createGoogleWalletPass: any; - }, + renderOrderPDF?: any; + createAppleWalletPass?: any; + createGoogleWalletPass?: any; + } = {}, ) { setupPDFTickets({ renderOrderPDF, diff --git a/packages/ticketing/src/module.ts b/packages/ticketing/src/module.ts index e8d16507c2..a87ed84f4f 100644 --- a/packages/ticketing/src/module.ts +++ b/packages/ticketing/src/module.ts @@ -8,14 +8,22 @@ import type { File } from '@unchainedshop/core-files'; import { RendererTypes, getRenderer } from './template-registry.ts'; import { buildPassBinary, pushToApplePushNotificationService } from './mobile-tickets/apple-wallet.ts'; +import { type DiscountCodeHandlers, createDefaultDiscountCodeHandlers } from './discount-codes.ts'; +import { OrdersCollection, OrderStatus } from '@unchainedshop/core-orders'; export const APPLE_WALLET_PASSES_FILE_DIRECTORY = 'apple-wallet-passes'; const logger = createLogger('unchained:apple-wallet-webservice'); -const configurePasses = async ({ db }: ModuleInput>) => { +export interface TicketingOptions { + discountCode?: DiscountCodeHandlers; +} + +const configurePasses = async ({ db, options }: ModuleInput) => { + const discountCodeHandlers = options?.discountCode || createDefaultDiscountCodeHandlers(); const MediaObjects = await MediaObjectsCollection(db); const TokenSurrogates = await TokenSurrogateCollection(db); + const Orders = await OrdersCollection(db); await buildDbIndexes(MediaObjects as any, [ { index: { path: 1, 'meta.passTypeIdentifier': 1, 'meta.serialNumber': 1 } }, @@ -220,6 +228,61 @@ const configurePasses = async ({ db }: ModuleInput>) => { } return TokenSurrogates.countDocuments(selector); }; + const discountCodeUsageBalance = async (discountCode: string): Promise => { + const orders = await Orders.aggregate([ + { + $match: { + status: { + $in: [OrderStatus.CONFIRMED, OrderStatus.FULFILLED], + }, + }, + }, + { + $lookup: { + from: 'order_discounts', + localField: 'calculation.discountId', + foreignField: '_id', + as: 'discounts', + }, + }, + { + $unwind: '$discounts', + }, + { + $match: { 'discounts.code': discountCode }, + }, + { + $project: { + calculations: { + $filter: { + input: '$calculation', + as: 'calc', + cond: { + $and: [ + { $eq: ['$$calc.category', 'DISCOUNTS'] }, + { $eq: ['$$calc.discountId', '$discounts._id'] }, + ], + }, + }, + }, + }, + }, + ]).toArray(); + + return Math.round( + Math.abs( + orders.reduce((prev, { calculations }) => { + return ( + prev + + (calculations as any[]).reduce( + (p: number, { amount }: { amount: number }) => p + amount / 100, + 0, + ) + ); + }, 0), + ), + ); + }; return { upsertAppleWalletPass, @@ -233,6 +296,9 @@ const configurePasses = async ({ db }: ModuleInput>) => { cancelTicket, isTicketCancelled, getTicketsCreated, + generateDiscountCode: discountCodeHandlers.generate, + verifyDiscountCode: discountCodeHandlers.verify, + discountCodeUsageBalance, }; }; diff --git a/packages/ticketing/src/routes.ts b/packages/ticketing/src/routes.ts index ba60763db0..0a266d687b 100644 --- a/packages/ticketing/src/routes.ts +++ b/packages/ticketing/src/routes.ts @@ -6,7 +6,6 @@ import { RendererTypes, getRenderer } from './template-registry.ts'; import { createLogger } from '@unchainedshop/logger'; import { getFileAdapter } from '@unchainedshop/core'; import type { TicketingAPI } from './index.ts'; - const logger = createLogger('unchained:ticketing'); const { diff --git a/packages/ticketing/src/services.ts b/packages/ticketing/src/services.ts index f016cb0ed7..dfed6bcdc3 100644 --- a/packages/ticketing/src/services.ts +++ b/packages/ticketing/src/services.ts @@ -1,10 +1,24 @@ +import { ProductType } from '@unchainedshop/core-products'; import type { TicketingModule } from './module.ts'; import type { Bound, UnchainedCore } from '@unchainedshop/core'; +type Modules = UnchainedCore['modules']; +type TicketingModules = Modules & TicketingModule; + +interface DiscountOptions { + generateDiscount?: boolean; + countryCode?: string; + currencyCode?: string; +} + async function cancelTicketsForProduct( - this: TicketingModule & UnchainedCore['modules'], + this: Modules, productId: string, -): Promise { + options?: DiscountOptions, +): Promise<{ + cancelledCount: number; +}> { + const { passes } = this as unknown as TicketingModules; const tokensToCancel = await this.warehousing.findTokens({ productId, 'meta.cancelled': null, @@ -12,24 +26,158 @@ async function cancelTicketsForProduct( for (const token of tokensToCancel) { await this.warehousing.invalidateToken(token._id); - await this.passes.cancelTicket(token._id); + await passes.cancelTicket(token._id); } await this.products.update(productId, { - $set: { 'meta.cancelled': true }, + 'meta.cancelled': true, + }); + + const affectedUserIds = [...new Set(tokensToCancel.map((t) => t.userId).filter(Boolean))] as string[]; + + const discountByUser = new Map(); + + if (options?.generateDiscount && tokensToCancel.length > 0 && options.countryCode) { + const product = await this.products.findProduct({ productId }); + const price = + product && + (await this.products.prices.price(product, { + countryCode: options.countryCode, + currencyCode: options.currencyCode, + })); + + if (price?.amount) { + const userTokenCounts = tokensToCancel.reduce( + (acc, token) => { + if (token.userId) { + acc[token.userId] = (acc[token.userId] || 0) + 1; + } + return acc; + }, + {} as Record, + ); + + for (const [userId, quantity] of Object.entries(userTokenCounts)) { + const totalAmount = price.amount * quantity; + const discountCode = await passes.generateDiscountCode(totalAmount); + discountByUser.set(userId, { discountCode, amount: totalAmount }); + } + } + } + + await Promise.allSettled( + affectedUserIds.map(async (userId) => { + const discount = discountByUser.get(userId); + await this.worker.addWork({ + type: 'MESSAGE', + input: { + template: 'EVENT_CANCELLED', + productId, + userId, + discountCode: discount?.discountCode, + discountAmount: discount?.amount, + }, + }); + }), + ); + + return { cancelledCount: tokensToCancel.length }; +} + +async function cancelTicketWithDiscount( + this: Modules, + tokenId: string, + options?: DiscountOptions, +): Promise<{ token: any }> { + const { passes } = this as unknown as TicketingModules; + const token = await this.warehousing.findToken({ tokenId }); + await this.warehousing.invalidateToken(tokenId); + const cancelledToken = await passes.cancelTicket(tokenId); + + let discountCode: string | undefined; + let discountAmount: number | undefined; + + if (options?.generateDiscount && cancelledToken && options.countryCode) { + const product = await this.products.findProduct({ productId: cancelledToken.productId }); + const price = + product && + (await this.products.prices.price(product, { + countryCode: options.countryCode, + currencyCode: options.currencyCode, + })); + + if (price?.amount) { + discountAmount = price.amount; + discountCode = await passes.generateDiscountCode(discountAmount); + } + } + + if (token?.userId) { + await this.worker.addWork({ + type: 'MESSAGE', + input: { + template: 'TICKET_CANCELLED', + tokenId, + userId: token.userId, + discountCode, + discountAmount, + }, + }); + } + + return { token: cancelledToken }; +} + +async function isPassCodeValid(this: Modules, passCode: string, productId?: string): Promise { + if (!passCode) return false; + + const products = await this.products.findProducts({ + type: ProductType.TOKENIZED_PRODUCT, + includeDrafts: true, + }); + + const matchingProducts = productId ? products.filter((p) => p._id === productId) : products; + + return matchingProducts + .filter(Boolean) + .some( + (p) => + (p.meta as Record)?.scannerPassCode?.toLowerCase().trim() === + passCode.toLowerCase().trim(), + ); +} + +async function productIdsForPassCode(this: Modules, passCode: string): Promise { + if (!passCode) return []; + + const products = await this.products.findProducts({ + type: ProductType.TOKENIZED_PRODUCT, + includeDrafts: true, }); - return tokensToCancel.length; + return products + .filter( + (p) => + (p.meta as Record)?.scannerPassCode?.toLowerCase().trim() === + passCode.toLowerCase().trim(), + ) + .map((p) => p._id); } export default { ticketing: { cancelTicketsForProduct, + cancelTicketWithDiscount, + isPassCodeValid, + productIdsForPassCode, }, }; export interface TicketingServices { ticketing: { cancelTicketsForProduct: Bound; + cancelTicketWithDiscount: Bound; + isPassCodeValid: Bound; + productIdsForPassCode: Bound; }; } diff --git a/packages/ticketing/tsconfig.json b/packages/ticketing/tsconfig.json index 54e212e808..8f714604d9 100644 --- a/packages/ticketing/tsconfig.json +++ b/packages/ticketing/tsconfig.json @@ -5,5 +5,5 @@ "rootDir": "./src", "outDir": "./lib" }, - "exclude": ["**/*.test.ts", "**/*.test.js", "tests", "lib"] + "exclude": ["**/*.test.ts", "**/*.test.js", "tests", "lib", "admin-plugin"] }