diff --git a/.DS_Store b/.DS_Store index 642f8191..a093827d 100644 Binary files a/.DS_Store and b/.DS_Store differ diff --git a/.eslintrc.js b/.eslintrc.js index 9e59648c..e44ff01d 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -15,6 +15,7 @@ module.exports = { 'prettier', 'plugin:prettier/recommended', ], + overrides: [], parser: '@typescript-eslint/parser', parserOptions: { @@ -33,6 +34,13 @@ module.exports = { '@typescript-eslint', ], rules: { + 'prettier/prettier': [ + 'error', + { + endOfLine: 'auto', + }, + ], + endOfLine: 'off', 'react/react-in-jsx-scope': 'off', 'react/jsx-sort-props': [ 2, diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..6fa132a9 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +*.vcproj eol=lf +*.sh eol=lf \ No newline at end of file diff --git a/.prettierrc b/.prettierrc index ad87e5cc..151a3b4b 100644 --- a/.prettierrc +++ b/.prettierrc @@ -4,5 +4,6 @@ "singleQuote": true, "jsxSingleQuote": false, "semi": true, - "printWidth": 100 + "printWidth": 100, + "endOfLine": "auto" } diff --git a/.vscode/settings.json b/.vscode/settings.json index 8e1c2c34..784e5f5f 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,12 +1,7 @@ { // eslint extension options "eslint.enable": true, - "eslint.validate": [ - "javascript", - "javascriptreact", - "typescript", - "typescriptreact" - ], + "eslint.validate": ["javascript", "javascriptreact", "typescript", "typescriptreact"], // prettier extension setting "editor.formatOnSave": true, "[javascript]": { @@ -23,7 +18,7 @@ }, "editor.rulers": [80], "editor.codeActionsOnSave": { - "source.fixAll.eslint": true + "source.fixAll.eslint": "explicit" }, "cSpell.words": ["bgcolor"], "typescript.tsdk": "node_modules/typescript/lib" diff --git a/package.json b/package.json index 11b2a90b..5883eb05 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,7 @@ "scripts": { "clean": "rm -rf dist .parcel-cache", "dev": "yarn clean && yarn static && parcel src/index.html -p 3000", + "devwindows": "del dist && xcopy .\\public .\\dist /s /e /h /I && parcel src/index.html -p 3000", "storybook": "yarn clean && yarn static && IS_STORYBOOK_VIEW=true parcel src/index.html -p 3000", "dev-mainnet": "yarn clean && yarn static && REACT_APP_CONTRACT_ID=v2.keypom.near REACT_APP_NETWORK_ID=mainnet parcel src/index.html -p 3000", "build": "yarn static && parcel build src/index.html --public-url ./ --no-cache --no-source-maps", @@ -36,17 +37,21 @@ "idb-keyval": "^6.2.0", "ipfs-car": "^0.9.2", "keypom-js": "^1.4.7", + "luxon": "^3.4.4", "mathjs": "^11.5.1", "near-api-js": "^1.1.0", "react": "^18.2.0", + "react-datepicker": "^6.2.0", "react-dom": "^18.2.0", "react-hook-form": "^7.43.0", + "react-input-mask": "^2.0.4", "react-qr-code": "^2.0.11", - "react-qr-reader": "3.0.0-beta-1", + "react-qr-reader": "^3.0.0-beta-1", "react-router-dom": "^6.8.0", "rxjs": "^7.8.0", "string_decoder": "^1.3.0", "swr": "^2.0.3", + "tweetnacl-util": "^0.15.1", "zod": "^3.20.2" }, "devDependencies": { @@ -83,9 +88,10 @@ "stream-browserify": "^3.0.0", "tslint-config-prettier": "^1.18.0", "typescript": "^4.9.5", - "util": "^0.12.3" + "util": "^0.12.3", + "vm-browserify": "^1.1.2" }, "browserslist": [ "last 2 Chrome versions" ] -} \ No newline at end of file +} diff --git a/public/assets/alcohol.webp b/public/assets/alcohol.webp new file mode 100644 index 00000000..ea49cb2c Binary files /dev/null and b/public/assets/alcohol.webp differ diff --git a/public/assets/purchase_with_stripe.webp b/public/assets/purchase_with_stripe.webp new file mode 100644 index 00000000..2cf7402a Binary files /dev/null and b/public/assets/purchase_with_stripe.webp differ diff --git a/public/img/Ticket1.png b/public/img/Ticket1.png new file mode 100644 index 00000000..5a543813 Binary files /dev/null and b/public/img/Ticket1.png differ diff --git a/public/img/Ticket2.png b/public/img/Ticket2.png new file mode 100644 index 00000000..8f6a625a Binary files /dev/null and b/public/img/Ticket2.png differ diff --git a/public/img/Ticket3.png b/public/img/Ticket3.png new file mode 100644 index 00000000..a5d7b793 Binary files /dev/null and b/public/img/Ticket3.png differ diff --git a/public/img/alcohol.webp b/public/img/alcohol.webp new file mode 100644 index 00000000..ea49cb2c Binary files /dev/null and b/public/img/alcohol.webp differ diff --git a/public/img/ballet.png b/public/img/ballet.png new file mode 100644 index 00000000..c17dbd15 Binary files /dev/null and b/public/img/ballet.png differ diff --git a/public/img/basketball.png b/public/img/basketball.png new file mode 100644 index 00000000..bcff2df7 Binary files /dev/null and b/public/img/basketball.png differ diff --git a/public/img/car.png b/public/img/car.png new file mode 100644 index 00000000..60b9b54e Binary files /dev/null and b/public/img/car.png differ diff --git a/public/img/friends.png b/public/img/friends.png new file mode 100644 index 00000000..cebfce24 Binary files /dev/null and b/public/img/friends.png differ diff --git a/public/img/hockeygame.png b/public/img/hockeygame.png new file mode 100644 index 00000000..beb97749 Binary files /dev/null and b/public/img/hockeygame.png differ diff --git a/public/img/hockeyticket.png b/public/img/hockeyticket.png new file mode 100644 index 00000000..c13efe32 Binary files /dev/null and b/public/img/hockeyticket.png differ diff --git a/public/img/monalisa.png b/public/img/monalisa.png new file mode 100644 index 00000000..69efad57 Binary files /dev/null and b/public/img/monalisa.png differ diff --git a/public/img/music.png b/public/img/music.png new file mode 100644 index 00000000..e598bfef Binary files /dev/null and b/public/img/music.png differ diff --git a/public/img/paintticket.png b/public/img/paintticket.png new file mode 100644 index 00000000..2d824549 Binary files /dev/null and b/public/img/paintticket.png differ diff --git a/src/App.tsx b/src/App.tsx index adadf1af..8958008d 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -3,6 +3,7 @@ import { RouterProvider } from 'react-router-dom'; import { ChakraProvider } from '@chakra-ui/react'; import { theme } from '@/theme'; +import './components/DateRangePicker/DatePickerStyle.css'; // Path to your custom CSS import { router } from '@/router'; import { router as storybookRouter } from '@/storybook-router'; import { Loading } from '@/components/Loading'; diff --git a/src/components/AppModal/AppModal.tsx b/src/components/AppModal/AppModal.tsx index 21b59f46..1f6c3b57 100644 --- a/src/components/AppModal/AppModal.tsx +++ b/src/components/AppModal/AppModal.tsx @@ -7,13 +7,13 @@ import { ModalContent, ModalFooter, ModalHeader, - ModalOverlay, Input, Box, Center, Spinner, Text, ModalCloseButton, + ModalOverlay, } from '@chakra-ui/react'; import { CheckIcon, CloseIcon } from '@chakra-ui/icons'; @@ -28,107 +28,114 @@ export const AppModal = () => { const [values, setValues] = useState({}); const [loading, setLoading] = useState(false); + const canClose = appModal.canClose !== undefined ? appModal.canClose : true; return ( { - setAppModal({ - isOpen: false, - }); + if (canClose) { + setAppModal({ isOpen: false }); + } }} > - - - - {appModal.header &&

{appModal.header}

} -
- {appModal.closeButtonVisible && } - - {appModal.isLoading && ( -
- -
- )} + + {appModal.modalContent !== undefined ? ( + appModal.modalContent + ) : ( + + + {appModal.header &&

{appModal.header}

} +
+ {appModal.closeButtonVisible && } + + {appModal.isLoading && ( +
+ +
+ )} - {appModal.isSuccess && ( -
- } mb="6" /> -
- )} + {appModal.isSuccess && ( +
+ } mb="6" /> +
+ )} - {appModal.isError && ( -
- } mb="6" /> -
- )} + {appModal.isError && ( +
+ } mb="6" /> +
+ )} - {appModal.message && {appModal.message}} + {appModal.message && {appModal.message}} - {appModal.bodyComponent !== undefined && {appModal.bodyComponent}} + {appModal.bodyComponent !== undefined && {appModal.bodyComponent}} - {appModal.inputs && appModal.inputs.length > 0 && ( - <> - {appModal.inputs.map(({ placeholder, valueKey }, i) => ( - { - setValues({ - ...values, - ...{ [valueKey]: e.target.value }, - }); - }} - /> - ))} - - )} -
+ {appModal.inputs && appModal.inputs.length > 0 && ( + <> + {appModal.inputs.map(({ placeholder, valueKey }, i) => ( + { + setValues({ + ...values, + ...{ [valueKey]: e.target.value }, + }); + }} + /> + ))} + + )} +
- {appModal.options && appModal.options.length > 0 && ( - - - {appModal.options.map(({ label, func, buttonProps, lazy }, i) => ( - - ))} - - - )} -
+ setLoading(false); + setAppModal({ isOpen: false }); + }} + {...buttonProps} + > + {label} + + ))} + + + )} + + )}
); }; diff --git a/src/components/AppModal/CompletionModal.tsx b/src/components/AppModal/CompletionModal.tsx new file mode 100644 index 00000000..8f17a269 --- /dev/null +++ b/src/components/AppModal/CompletionModal.tsx @@ -0,0 +1,26 @@ +import { ModalContent, VStack, Text, Progress, Button } from '@chakra-ui/react'; + +interface CompletionModalContentProps { + onClose: () => void; + completionMessage?: string; +} + +const CompletionModalContent = ({ + onClose, + completionMessage = 'Deletion complete.', +}: CompletionModalContentProps) => ( + + + + Deletion Complete + + + {completionMessage} + + + +); + +export default CompletionModalContent; diff --git a/src/components/AppModal/ConfirmDeletionModal.tsx b/src/components/AppModal/ConfirmDeletionModal.tsx new file mode 100644 index 00000000..dd276abe --- /dev/null +++ b/src/components/AppModal/ConfirmDeletionModal.tsx @@ -0,0 +1,32 @@ +import { ModalContent, VStack, Text, HStack, Button } from '@chakra-ui/react'; + +interface ConfirmDeletionModalProps { + onConfirm: () => void; + onCancel: () => void; + confirmMessage?: string; +} + +const ConfirmDeletionModal = ({ + onConfirm, + onCancel, + confirmMessage = 'Are you sure you want to delete this item? This action cannot be undone.', +}: ConfirmDeletionModalProps) => ( + + + + Confirm Deletion + + {confirmMessage} + + + + + + +); + +export default ConfirmDeletionModal; diff --git a/src/components/AppModal/PerformDeletion.tsx b/src/components/AppModal/PerformDeletion.tsx new file mode 100644 index 00000000..d398d389 --- /dev/null +++ b/src/components/AppModal/PerformDeletion.tsx @@ -0,0 +1,191 @@ +import { type Wallet } from '@near-wallet-selector/core'; +import { Button, ModalContent, Text, VStack } from '@chakra-ui/react'; + +import keypomInstance from '@/lib/keypom'; +import { KEYPOM_EVENTS_CONTRACT } from '@/constants/common'; + +import ProgressModalContent from './ProgessModalContent'; +import CompletionModalContent from './CompletionModal'; + +export const performDeletionLogic = async ({ + wallet, + accountId, + deleteAll, + eventId, + ticketData, + setAppModal, +}: { + wallet: Wallet; + deleteAll: boolean; + accountId: string; + eventId: string; + ticketData: any; + setAppModal: any; +}) => { + if (!wallet) return; + + try { + let totalSupplyTickets = 0; + const ticketSupplies: number[] = []; + for (let i = 0; i < ticketData.length; i++) { + const dropId = ticketData[i].drop_id; + const supplyForTicket: number = await keypomInstance.getKeySupplyForTicket(dropId); + + ticketSupplies.push(supplyForTicket); + totalSupplyTickets += supplyForTicket; + } + + let totalDeleted = 0; + for (let i = 0; i < ticketData.length; i++) { + const curTicketData = ticketData[i]; + const dropId = curTicketData.drop_id; + const supplyForTicket = ticketSupplies[i]; + const meta = JSON.parse(curTicketData.drop_config.metadata); + + let deletedForTicket = 0; + const deleteLimit = 50; + + if (supplyForTicket === 0) { + // Update Progress Modal + setAppModal({ + isOpen: true, + size: 'xl', + canClose: false, + modalContent: ( + + ), + }); + await wallet.signAndSendTransaction({ + signerId: accountId, + receiverId: KEYPOM_EVENTS_CONTRACT, + actions: [ + { + type: 'FunctionCall', + params: { + methodName: 'delete_keys', + args: { drop_id: dropId }, + gas: '300000000000000', + deposit: '0', + }, + }, + ], + }); + } + + for (let j = 0; j < supplyForTicket; j += deleteLimit) { + const toDelete = Math.min(deleteLimit, supplyForTicket - deletedForTicket); + + // Update Progress Modal + setAppModal({ + isOpen: true, + size: 'xl', + canClose: false, + modalContent: ( + + ), + }); + + await wallet.signAndSendTransaction({ + signerId: accountId, + receiverId: KEYPOM_EVENTS_CONTRACT, + actions: [ + { + type: 'FunctionCall', + params: { + methodName: 'delete_keys', + args: { drop_id: dropId, limit: toDelete }, + gas: '300000000000000', + deposit: '0', + }, + }, + ], + }); + + totalDeleted += toDelete; + deletedForTicket += toDelete; + } + + keypomInstance.deleteTicketFromCache({ dropId }); + } + + if (deleteAll) { + setAppModal({ + isOpen: true, + size: 'xl', + canClose: false, + modalContent: ( + + ), + }); + await keypomInstance.deleteEventFromFunderMetadata({ + accountId, + eventId, + wallet, + }); + keypomInstance.deleteEventFromCache({ eventId }); + } + + // Completion Modal + setAppModal({ + isOpen: true, + size: 'xl', + modalContent: ( + { + setAppModal({ isOpen: false }); + }} + /> + ), + }); + } catch (error) { + console.error('Error during deletion:', error); + // Error Modal + setAppModal({ + isOpen: true, + size: 'xl', + modalContent: ( + + + + Error + + There was an error deleting the Tickets. Please try again. + + + + ), + }); + } +}; diff --git a/src/components/AppModal/ProgessModalContent.tsx b/src/components/AppModal/ProgessModalContent.tsx new file mode 100644 index 00000000..91ddd009 --- /dev/null +++ b/src/components/AppModal/ProgessModalContent.tsx @@ -0,0 +1,32 @@ +import { ModalContent, VStack, Text, Progress, Center, Spinner } from '@chakra-ui/react'; + +interface ProgressModalContentProps { + title: string; + progress: number; + message: string; +} + +const ProgressModalContent = ({ title, progress, message }: ProgressModalContentProps) => ( + + + + {title} + + +
+ +
+ {message} + + Do not close this window + +
+
+); + +export default ProgressModalContent; diff --git a/src/components/AppModal/useDeletion.tsx b/src/components/AppModal/useDeletion.tsx new file mode 100644 index 00000000..93c546c2 --- /dev/null +++ b/src/components/AppModal/useDeletion.tsx @@ -0,0 +1,25 @@ +import ConfirmDeletionModal from './ConfirmDeletionModal'; + +const useDeletion = ({ setAppModal }) => { + const openConfirmationModal = (dropId, customMessage, onConfirmCallback) => { + setAppModal({ + isOpen: true, + canClose: false, + size: 'xl', + modalContent: ( + setAppModal({ isOpen: false })} + onConfirm={() => { + setAppModal({ isOpen: false }); // Close confirmation modal + onConfirmCallback(dropId); // Execute the deletion process + }} + /> + ), + }); + }; + + return { openConfirmationModal }; +}; + +export default useDeletion; diff --git a/src/components/DateRangePicker/DatePickerStyle.css b/src/components/DateRangePicker/DatePickerStyle.css new file mode 100644 index 00000000..05c5d032 --- /dev/null +++ b/src/components/DateRangePicker/DatePickerStyle.css @@ -0,0 +1,1700 @@ +@charset "UTF-8"; +@media (max-width: 767px) { + .react-datepicker__year-read-view--down-arrow, + .react-datepicker__month-read-view--down-arrow, + .react-datepicker__month-year-read-view--down-arrow, + .react-datepicker__navigation-icon::before { + border-color: #64748b; + border-style: solid; + border-width: 2px 2px 0 0; + content: ''; + display: block; + height: 7px; + position: absolute; + top: 6px; + width: 7px; + } + .react-datepicker-popper[data-placement^='bottom'] .react-datepicker__triangle { + fill: #fff; + color: #fff; + stroke: #fff; + } + + .react-datepicker-popper[data-placement^='top'] .react-datepicker__triangle { + fill: #fff; + color: #fff; + stroke: #fff; + } + + .react-datepicker-wrapper { + display: inline-block; + padding: 0; + border: 0; + position: relative; + z-index: 10; + } + + .react-datepicker { + font-size: 0.8rem; + background-color: #fefefe; + border-radius: 0.5rem 0.5rem 0 0; + + display: inline-block; + position: relative; + line-height: initial; + max-width: 100%; + } + + .react-datepicker--time-only .react-datepicker__time-container { + border-left: 0; + } + .react-datepicker--time-only .react-datepicker__time, + .react-datepicker--time-only .react-datepicker__time-box { + border-bottom-left-radius: 0.3rem; + border-bottom-right-radius: 0.3rem; + } + + .react-datepicker-popper { + z-index: 10; + line-height: 0; + } + .react-datepicker__header { + text-align: center; + padding-top: 8px; + position: relative; + } + .react-datepicker__header--time { + padding-bottom: 8px; + padding-left: 5px; + padding-right: 5px; + } + .react-datepicker__header--time:not(.react-datepicker__header--time--only) { + border-top-left-radius: 0; + } + .react-datepicker__header:not(.react-datepicker__header--has-time-select) { + border-top-right-radius: 0.3rem; + } + + .react-datepicker__year-dropdown-container--select, + .react-datepicker__month-dropdown-container--select, + .react-datepicker__month-year-dropdown-container--select, + .react-datepicker__year-dropdown-container--scroll, + .react-datepicker__month-dropdown-container--scroll, + .react-datepicker__month-year-dropdown-container--scroll { + display: inline-block; + margin: 0 15px; + } + + .react-datepicker__current-month, + .react-datepicker-time__header, + .react-datepicker-year-header { + margin-top: 4px; + margin-bottom: 8px; + font-size: 0.944rem; + font-weight: 500; + color: #0f172a; + } + + .react-datepicker-time__header { + text-overflow: ellipsis; + white-space: nowrap; + overflow: hidden; + } + + .react-datepicker__navigation { + align-items: center; + background: none; + display: flex; + justify-content: center; + text-align: center; + cursor: pointer; + position: absolute; + top: 8px; + padding: 0; + border: none; + z-index: 1; + height: 32px; + width: 32px; + text-indent: -999em; + overflow: hidden; + } + .react-datepicker__navigation--previous { + left: 2px; + } + .react-datepicker__navigation--next { + right: 2px; + } + .react-datepicker__navigation--next--with-time:not( + .react-datepicker__navigation--next--with-today-button + ) { + right: 85px; + } + .react-datepicker__navigation--years { + position: relative; + top: 0; + display: block; + margin-left: auto; + margin-right: auto; + } + .react-datepicker__navigation--years-previous { + top: 4px; + } + .react-datepicker__navigation--years-upcoming { + top: -4px; + } + .react-datepicker__navigation:hover *::before { + border-color: #0f172a; + } + + .react-datepicker__navigation-icon { + position: relative; + top: -1px; + font-size: 20px; + width: 0; + } + .react-datepicker__navigation-icon--next { + left: -2px; + } + .react-datepicker__navigation-icon--next::before { + transform: rotate(45deg); + left: -7px; + } + .react-datepicker__navigation-icon--previous { + right: -2px; + } + .react-datepicker__navigation-icon--previous::before { + transform: rotate(225deg); + right: -7px; + } + + .react-datepicker__month-container { + float: left; + } + + .react-datepicker__year { + margin: 0.4rem; + text-align: center; + } + .react-datepicker__year-wrapper { + display: flex; + flex-wrap: wrap; + max-width: 180px; + } + .react-datepicker__year .react-datepicker__year-text { + display: inline-block; + width: 4rem; + margin: 2px; + } + .react-datepicker__month { + margin: 0.4rem; + text-align: center; + } + .react-datepicker__month .react-datepicker__month-text, + .react-datepicker__month .react-datepicker__quarter-text { + display: inline-block; + width: 4rem; + margin: 2px; + } + + .react-datepicker__input-time-container { + clear: both; + width: 100%; + float: left; + text-align: left; + } + .react-datepicker__input-time-container .react-datepicker-time__caption { + padding: 0.4rem; + display: none; + } + .react-datepicker__input-time-container .react-datepicker-time__input-container { + display: inline-block; + position: absolute; + } + + .react-datepicker-time__input-container { + width: 100% !important; + } + + .react-datepicker-time__input { + width: 100% !important; + } + + .react-datepicker__input-time-container + .react-datepicker-time__input-container + .react-datepicker-time__input { + display: inline-block; + } + .react-datepicker__input-time-container + .react-datepicker-time__input-container + .react-datepicker-time__input + input { + width: auto; + } + .react-datepicker__input-time-container + .react-datepicker-time__input-container + .react-datepicker-time__input + input[type='time']::-webkit-inner-spin-button, + .react-datepicker__input-time-container + .react-datepicker-time__input-container + .react-datepicker-time__input + input[type='time']::-webkit-outer-spin-button { + -webkit-appearance: none; + margin: 0; + } + .react-datepicker__input-time-container + .react-datepicker-time__input-container + .react-datepicker-time__input + input[type='time'] { + -moz-appearance: textfield; + } + .react-datepicker__input-time-container + .react-datepicker-time__input-container + .react-datepicker-time__delimiter { + margin-left: 5px; + display: inline-block; + } + + .react-datepicker__time-container { + float: right; + border-left: 1px solid #aeaeae; + width: 85px; + } + .react-datepicker__time-container--with-today-button { + display: inline; + border: 1px solid #aeaeae; + border-radius: 0.3rem; + position: absolute; + right: -87px; + top: 0; + } + .react-datepicker__time-container .react-datepicker__time { + position: relative; + background: white; + border-bottom-right-radius: 0.3rem; + } + .react-datepicker__time-container .react-datepicker__time .react-datepicker__time-box { + width: 85px; + overflow-x: hidden; + margin: 0 auto; + text-align: center; + border-bottom-right-radius: 0.3rem; + } + .react-datepicker__time-container + .react-datepicker__time + .react-datepicker__time-box + ul.react-datepicker__time-list { + list-style: none; + margin: 0; + height: calc(195px + 1.7rem / 2); + overflow-y: scroll; + padding-right: 0; + padding-left: 0; + width: 100%; + box-sizing: content-box; + } + .react-datepicker__time-container + .react-datepicker__time + .react-datepicker__time-box + ul.rea + ct-datepicker__time-list + li.react-datepicker__time-list-item { + height: 30px; + padding: 5px 10px; + white-space: nowrap; + } + .react-datepicker__time-container + .react-datepicker__time + .react-datepicker__time-box + ul.react-datepicker__time-list + li.react-datepicker__time-list-item:hover { + cursor: pointer; + background-color: #f0f0f0; + } + .react-datepicker__time-container + .react-datepicker__time + .react-datepicker__time-box + ul.react-datepicker__time-list + li.react-datepicker__time-list-item--selected { + background-color: #ddf4fa; + color: black; + } + .react-datepicker__time-container + .react-datepicker__time + .react-datepicker__time-box + ul.react-datepicker__time-list + li.react-datepicker__time-list-item--selected:hover { + background-color: #ddf4fa; + color: black; + } + .react-datepicker__time-container + .react-datepicker__time + .react-datepicker__time-box + ul.react-datepicker__time-list + li.react-datepicker__time-list-item--disabled { + color: #ccc; + } + .react-datepicker__time-container + .react-datepicker__time + .react-datepicker__time-box + ul.react-datepicker__time-list + li.react-datepicker__time-list-item--disabled:hover { + cursor: default; + background-color: transparent; + } + + .react-datepicker__week-number { + color: #ccc; + display: inline-block; + width: 1.7rem; + line-height: 1.7rem; + text-align: center; + margin: 0.166rem; + } + .react-datepicker__week-number.react-datepicker__week-number--clickable { + cursor: pointer; + } + .react-datepicker__week-number.react-datepicker__week-number--clickable:not( + .react-datepicker__week-number--selected, + .react-datepicker__week-number--keyboard-selected + ):hover { + border-radius: 0.3rem; + background-color: #f0f0f0; + } + .react-datepicker__week-number--selected { + border-radius: 0; + background-color: #ddf4fa; + color: black; + } + .react-datepicker__week-number--selected:hover { + background-color: #1d5d90; + } + .react-datepicker__week-number--keyboard-selected { + border-radius: 0.3rem; + background-color: #2a87d0; + color: #fdfdfd; + } + .react-datepicker__week-number--keyboard-selected:hover { + background-color: #1d5d90; + } + + .react-datepicker__day-names { + white-space: nowrap; + margin-bottom: -8px; + font-weight: 500; + font-size: 0.8rem; + } + + .react-datepicker__day-name { + display: inline-block; + width: 2.3rem; + line-height: 2.3rem; + text-align: center; + color: #000; + } + + .react-datepicker__week { + white-space: nowrap; + } + + .react-datepicker__day, + .react-datepicker__time-name { + color: #000; + display: inline-block; + width: 2.3rem; + line-height: 2.3rem; + text-align: center; + } + + .react-datepicker__day, + .react-datepicker__month-text, + .react-datepicker__quarter-text, + .react-datepicker__year-text { + cursor: pointer; + } + .react-datepicker__day:hover, + .react-datepicker__month-text:hover, + .react-datepicker__quarter-text:hover, + .react-datepicker__year-text:hover { + border-radius: 0.3rem; + background-color: #f0f0f0; + } + .react-datepicker__day--today, + .react-datepicker__month-text--today, + .react-datepicker__quarter-text--today, + .react-datepicker__year-text--today { + font-weight: bold; + } + .react-datepicker__day--highlighted, + .react-datepicker__month-text--highlighted, + .react-datepicker__quarter-text--highlighted, + .react-datepicker__year-text--highlighted { + border-radius: 0.3rem; + background-color: #3dcc4a; + color: #fdfdfd; + } + .react-datepicker__day--highlighted:hover, + .react-datepicker__month-text--highlighted:hover, + .react-datepicker__quarter-text--highlighted:hover, + .react-datepicker__year-text--highlighted:hover { + background-color: #32be3f; + } + .react-datepicker__day--highlighted-custom-1, + .react-datepicker__month-text--highlighted-custom-1, + .react-datepicker__quarter-text--highlighted-custom-1, + .react-datepicker__year-text--highlighted-custom-1 { + color: magenta; + } + .react-datepicker__day--highlighted-custom-2, + .react-datepicker__month-text--highlighted-custom-2, + .react-datepicker__quarter-text--highlighted-custom-2, + .react-datepicker__year-text--highlighted-custom-2 { + color: green; + } + .react-datepicker__day--holidays, + .react-datepicker__month-text--holidays, + .react-datepicker__quarter-text--holidays, + .react-datepicker__year-text--holidays { + position: relative; + border-radius: 0.3rem; + background-color: #ff6803; + color: #fdfdfd; + } + .react-datepicker__day--holidays .overlay, + .react-datepicker__month-text--holidays .overlay, + .react-datepicker__quarter-text--holidays .overlay, + .react-datepicker__year-text--holidays .overlay { + position: absolute; + bottom: 100%; + left: 50%; + transform: translateX(-50%); + background-color: #333; + color: #fdfdfd; + padding: 4px; + border-radius: 4px; + white-space: nowrap; + visibility: hidden; + opacity: 0; + transition: visibility 0s, opacity 0.3s ease-in-out; + } + .react-datepicker__day--holidays:hover, + .react-datepicker__month-text--holidays:hover, + .react-datepicker__quarter-text--holidays:hover, + .react-datepicker__year-text--holidays:hover { + background-color: #cf5300; + } + .react-datepicker__day--holidays:hover .overlay, + .react-datepicker__month-text--holidays:hover .overlay, + .react-datepicker__quarter-text--holidays:hover .overlay, + .react-datepicker__year-text--holidays:hover .overlay { + visibility: visible; + opacity: 1; + } + .react-datepicker__day--selected, + .react-datepicker__day--in-selecting-range, + .react-datepicker__day--in-range, + .react-datepicker__month-text--selected, + .react-datepicker__month-text--in-selecting-range, + .react-datepicker__month-text--in-range, + .react-datepicker__quarter-text--selected, + .react-datepicker__quarter-text--in-selecting-range, + .react-datepicker__quarter-text--in-range, + .react-datepicker__year-text--selected, + .react-datepicker__year-text--in-selecting-range, + .react-datepicker__year-text--in-range { + border-radius: 0; + background-color: #ddf4fa; + color: black; + } + + .react-datepicker__day--selected.first-selected { + border-radius: 1rem; + background-color: #00a7e4; + color: white; + } + + .react-datepicker__day--selected:hover, + .react-datepicker__day--in-selecting-range:hover, + .react-datepicker__day--in-range:hover, + .react-datepicker__month-text--selected:hover, + .react-datepicker__month-text--in-selecting-range:hover, + .react-datepicker__month-text--in-range:hover, + .react-datepicker__quarter-text--selected:hover, + .react-datepicker__quarter-text--in-selecting-range:hover, + .react-datepicker__quarter-text--in-range:hover, + .react-datepicker__year-text--selected:hover, + .react-datepicker__year-text--in-selecting-range:hover, + .react-datepicker__year-text--in-range:hover { + background-color: #00a7e4; + color: white; + border-radius: 1rem; + } + + .react-datepicker__day--keyboard-selected:hover, + .react-datepicker__month-text--keyboard-selected:hover, + .react-datepicker__quarter-text--keyboard-selected:hover, + .react-datepicker__year-text--keyboard-selected:hover { + border-radius: 0; + background-color: #ddf4fa; + color: black; + } + .react-datepicker__month--selecting-range + .react-datepicker__day--in-range:not( + .react-datepicker__day--in-selecting-range, + .react-datepicker__month-text--in-selecting-range, + .react-datepicker__quarter-text--in-selecting-range, + .react-datepicker__year-text--in-selecting-range + ), + .react-datepicker__year--selecting-range + .react-datepicker__day--in-range:not( + .react-datepicker__day--in-selecting-range, + .react-datepicker__month-text--in-selecting-range, + .react-datepicker__quarter-text--in-selecting-range, + .react-datepicker__year-text--in-selecting-range + ), + .react-datepicker__month--selecting-range + .react-datepicker__month-text--in-range:not( + .react-datepicker__day--in-selecting-range, + .react-datepicker__month-text--in-selecting-range, + .react-datepicker__quarter-text--in-selecting-range, + .react-datepicker__year-text--in-selecting-range + ), + .react-datepicker__year--selecting-range + .react-datepicker__month-text--in-range:not( + .react-datepicker__day--in-selecting-range, + .react-datepicker__month-text--in-selecting-range, + .react-datepicker__quarter-text--in-selecting-range, + .react-datepicker__year-text--in-selecting-range + ), + .react-datepicker__month--selecting-range + .react-datepicker__quarter-text--in-range:not( + .react-datepicker__day--in-selecting-range, + .react-datepicker__month-text--in-selecting-range, + .react-datepicker__quarter-text--in-selecting-range, + .react-datepicker__year-text--in-selecting-range + ), + .react-datepicker__year--selecting-range + .react-datepicker__quarter-text--in-range:not( + .react-datepicker__day--in-selecting-range, + .react-datepicker__month-text--in-selecting-range, + .react-datepicker__quarter-text--in-selecting-range, + .react-datepicker__year-text--in-selecting-range + ), + .react-datepicker__month--selecting-range + .react-datepicker__year-text--in-range:not( + .react-datepicker__day--in-selecting-range, + .react-datepicker__month-text--in-selecting-range, + .react-datepicker__quarter-text--in-selecting-range, + .react-datepicker__year-text--in-selecting-range + ), + .react-datepicker__year--selecting-range + .react-datepicker__year-text--in-range:not( + .react-datepicker__day--in-selecting-range, + .react-datepicker__month-text--in-selecting-range, + .react-datepicker__quarter-text--in-selecting-range, + .react-datepicker__year-text--in-selecting-range + ) { + background-color: #f0f0f0; + color: #000; + } + .react-datepicker__day--disabled, + .react-datepicker__month-text--disabled, + .react-datepicker__quarter-text--disabled, + .react-datepicker__year-text--disabled { + cursor: default; + color: #ccc; + } + .react-datepicker__day--disabled:hover, + .react-datepicker__month-text--disabled:hover, + .react-datepicker__quarter-text--disabled:hover, + .react-datepicker__year-text--disabled:hover { + background-color: transparent; + } + .react-datepicker__day--disabled .overlay, + .react-datepicker__month-text--disabled .overlay, + .react-datepicker__quarter-text--disabled .overlay, + .react-datepicker__year-text--disabled .overlay { + position: absolute; + bottom: 70%; + left: 50%; + transform: translateX(-50%); + background-color: #333; + color: #fdfdfd; + padding: 4px; + border-radius: 4px; + white-space: nowrap; + visibility: hidden; + opacity: 0; + transition: visibility 0s, opacity 0.3s ease-in-out; + } + + .react-datepicker__input-container { + position: relative; + display: none; + width: 100%; + } + .react-datepicker__input-container .react-datepicker__calendar-icon { + position: absolute; + padding: 0.5rem; + box-sizing: content-box; + } + + .react-datepicker__view-calendar-icon input { + padding: 6px 10px 5px 25px; + } + + .react-datepicker__year-read-view, + .react-datepicker__month-read-view, + .react-datepicker__month-year-read-view { + border: 1px solid transparent; + border-radius: 0.3rem; + position: relative; + } + .react-datepicker__year-read-view:hover, + .react-datepicker__month-read-view:hover, + .react-datepicker__month-year-read-view:hover { + cursor: pointer; + } + .react-datepicker__year-read-view:hover .react-datepicker__year-read-view--down-arrow, + .react-datepicker__year-read-view:hover .react-datepicker__month-read-view--down-arrow, + .react-datepicker__month-read-view:hover .react-datepicker__year-read-view--down-arrow, + .react-datepicker__month-read-view:hover .react-datepicker__month-read-view--down-arrow, + .react-datepicker__month-year-read-view:hover .react-datepicker__year-read-view--down-arrow, + .react-datepicker__month-year-read-view:hover .react-datepicker__month-read-view--down-arrow { + border-top-color: #b3b3b3; + } + .react-datepicker__year-read-view--down-arrow, + .react-datepicker__month-read-view--down-arrow, + .react-datepicker__month-year-read-view--down-arrow { + transform: rotate(135deg); + right: -16px; + top: 0; + } + + .react-datepicker__year-dropdown, + .react-datepicker__month-dropdown, + .react-datepicker__month-year-dropdown { + background-color: #f0f0f0; + position: absolute; + width: 50%; + left: 25%; + top: 30px; + z-index: 1; + text-align: center; + border-radius: 0.3rem; + border: 1px solid #aeaeae; + } + .react-datepicker__year-dropdown:hover, + .react-datepicker__month-dropdown:hover, + .react-datepicker__month-year-dropdown:hover { + cursor: pointer; + } + .react-datepicker__year-dropdown--scrollable, + .react-datepicker__month-dropdown--scrollable, + .react-datepicker__month-year-dropdown--scrollable { + height: 150px; + overflow-y: scroll; + } + + .react-datepicker__year-option, + .react-datepicker__month-option, + .react-datepicker__month-year-option { + line-height: 20px; + width: 100%; + display: block; + margin-left: auto; + margin-right: auto; + } + .react-datepicker__year-option:first-of-type, + .react-datepicker__month-option:first-of-type, + .react-datepicker__month-year-option:first-of-type { + border-top-left-radius: 0.3rem; + border-top-right-radius: 0.3rem; + } + .react-datepicker__year-option:last-of-type, + .react-datepicker__month-option:last-of-type, + .react-datepicker__month-year-option:last-of-type { + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; + border-bottom-left-radius: 0.3rem; + border-bottom-right-radius: 0.3rem; + } + .react-datepicker__year-option:hover, + .react-datepicker__month-option:hover, + .react-datepicker__month-year-option:hover { + background-color: #ccc; + } + .react-datepicker__year-option:hover .react-datepicker__navigation--years-upcoming, + .react-datepicker__month-option:hover .react-datepicker__navigation--years-upcoming, + .react-datepicker__month-year-option:hover .react-datepicker__navigation--years-upcoming { + border-bottom-color: #b3b3b3; + } + .react-datepicker__year-option:hover .react-datepicker__navigation--years-previous, + .react-datepicker__month-option:hover .react-datepicker__navigation--years-previous, + .react-datepicker__month-year-option:hover .react-datepicker__navigation--years-previous { + border-top-color: #b3b3b3; + } + .react-datepicker__year-option--selected, + .react-datepicker__month-option--selected, + .react-datepicker__month-year-option--selected { + position: absolute; + left: 15px; + } + + .react-datepicker__close-icon { + cursor: pointer; + background-color: transparent; + border: 0; + outline: 0; + padding: 0 6px 0 0; + position: absolute; + top: 0; + right: 0; + height: 100%; + display: table-cell; + vertical-align: middle; + } + .react-datepicker__close-icon::after { + cursor: pointer; + border-radius: 1; + background-color: #00a7e4; + color: white; + height: 16px; + width: 16px; + padding: 2px; + font-size: 12px; + line-height: 1; + text-align: center; + display: table-cell; + vertical-align: middle; + content: '×'; + } + .react-datepicker__close-icon--disabled { + cursor: default; + } + .react-datepicker__close-icon--disabled::after { + cursor: default; + background-color: #ccc; + } + + .react-datepicker__today-button { + background: #f0f0f0; + border-top: 1px solid #aeaeae; + cursor: pointer; + text-align: center; + font-weight: bold; + padding: 5px 0; + clear: left; + } + + .react-datepicker__portal { + position: fixed; + width: 100vw; + height: 100vh; + background-color: rgba(0, 0, 0, 0.8); + left: 0; + top: 0; + justify-content: center; + align-items: center; + display: flex; + z-index: 2147483647; + } + .react-datepicker__portal .react-datepicker__day-name, + .react-datepicker__portal .react-datepicker__day, + .react-datepicker__portal .react-datepicker__time-name { + width: 3rem; + line-height: 3rem; + } + @media (max-width: 400px), (max-height: 550px) { + .react-datepicker__portal .react-datepicker__day-name, + .react-datepicker__portal .react-datepicker__day, + .react-datepicker__portal .react-datepicker__time-name { + width: 2rem; + line-height: 2rem; + } + } + .react-datepicker__portal .react-datepicker__current-month, + .react-datepicker__portal .react-datepicker-time__header { + font-size: 1.44rem; + } + + .react-datepicker__children-container { + width: 13.8rem; + margin: 0.4rem; + padding-right: 0.2rem; + padding-left: 0.2rem; + height: auto; + } + + .react-datepicker__aria-live { + position: absolute; + clip-path: circle(0); + border: 0; + height: 1px; + margin: -1px; + overflow: hidden; + padding: 0; + width: 1px; + white-space: nowrap; + } + + .react-datepicker__calendar-icon { + width: 1em; + height: 1em; + vertical-align: -0.125em; + } +} + +@media (min-width: 767px) { + .react-datepicker__year-read-view--down-arrow, + .react-datepicker__month-read-view--down-arrow, + .react-datepicker__month-year-read-view--down-arrow, + .react-datepicker__navigation-icon::before { + border-color: #64748b; + border-style: solid; + border-width: 2px 2px 0 0; + content: ''; + display: block; + height: 7px; + position: absolute; + top: 6px; + width: 7px; + } + .react-datepicker-popper[data-placement^='bottom'] .react-datepicker__triangle { + fill: #fff; + color: #fff; + stroke: #fff; + } + + .react-datepicker-popper[data-placement^='top'] .react-datepicker__triangle { + fill: #fff; + color: #fff; + stroke: #fff; + } + + .react-datepicker-wrapper { + display: inline-block; + padding: 0; + border: 0; + position: relative; + z-index: 10; + } + + .react-datepicker { + font-size: 0.8rem; + background-color: #fdfdfd; + border-radius: 0.5rem 0.5rem 0 0; + + display: inline-block; + position: relative; + line-height: initial; + max-width: 100%; + } + + .react-datepicker--time-only .react-datepicker__time-container { + border-left: 0; + } + .react-datepicker--time-only .react-datepicker__time, + .react-datepicker--time-only .react-datepicker__time-box { + border-bottom-left-radius: 0.3rem; + border-bottom-right-radius: 0.3rem; + } + + .react-datepicker-popper { + z-index: 10; + line-height: 0; + } + .react-datepicker__header { + text-align: center; + padding-top: 8px; + position: relative; + } + .react-datepicker__header--time { + padding-bottom: 8px; + padding-left: 5px; + padding-right: 5px; + } + .react-datepicker__header--time:not(.react-datepicker__header--time--only) { + border-top-left-radius: 0; + } + .react-datepicker__header:not(.react-datepicker__header--has-time-select) { + border-top-right-radius: 0.3rem; + } + + .react-datepicker__year-dropdown-container--select, + .react-datepicker__month-dropdown-container--select, + .react-datepicker__month-year-dropdown-container--select, + .react-datepicker__year-dropdown-container--scroll, + .react-datepicker__month-dropdown-container--scroll, + .react-datepicker__month-year-dropdown-container--scroll { + display: inline-block; + margin: 0 15px; + } + + .react-datepicker__current-month, + .react-datepicker-time__header, + .react-datepicker-year-header { + margin-top: 4px; + margin-bottom: 8px; + font-size: 0.944rem; + font-weight: 500; + color: #0f172a; + } + + .react-datepicker-time__header { + text-overflow: ellipsis; + white-space: nowrap; + overflow: hidden; + } + + .react-datepicker__navigation { + align-items: center; + background: none; + display: flex; + justify-content: center; + text-align: center; + cursor: pointer; + position: absolute; + top: 8px; + padding: 0; + border: none; + z-index: 1; + height: 32px; + width: 32px; + text-indent: -999em; + overflow: hidden; + } + .react-datepicker__navigation--previous { + left: 2px; + } + .react-datepicker__navigation--next { + right: 2px; + } + .react-datepicker__navigation--next--with-time:not( + .react-datepicker__navigation--next--with-today-button + ) { + right: 85px; + } + .react-datepicker__navigation--years { + position: relative; + top: 0; + display: block; + margin-left: auto; + margin-right: auto; + } + .react-datepicker__navigation--years-previous { + top: 4px; + } + .react-datepicker__navigation--years-upcoming { + top: -4px; + } + .react-datepicker__navigation:hover *::before { + border-color: #0f172a; + } + + .react-datepicker__navigation-icon { + position: relative; + top: -1px; + font-size: 20px; + width: 0; + } + .react-datepicker__navigation-icon--next { + left: -2px; + } + .react-datepicker__navigation-icon--next::before { + transform: rotate(45deg); + left: -7px; + } + .react-datepicker__navigation-icon--previous { + right: -2px; + } + .react-datepicker__navigation-icon--previous::before { + transform: rotate(225deg); + right: -7px; + } + + .react-datepicker__month-container { + float: left; + width: 50%; + } + + .react-datepicker__month-container:nth-of-type(1) { + border-right: 1px solid #ccc; + } + + .react-datepicker__year { + margin: 0.4rem; + text-align: center; + } + .react-datepicker__year-wrapper { + display: flex; + flex-wrap: wrap; + max-width: 180px; + } + .react-datepicker__year .react-datepicker__year-text { + display: inline-block; + width: 4rem; + margin: 2px; + } + .react-datepicker__month { + margin: 0.4rem; + text-align: center; + } + .react-datepicker__month .react-datepicker__month-text, + .react-datepicker__month .react-datepicker__quarter-text { + display: inline-block; + width: 4rem; + margin: 2px; + } + + .react-datepicker__input-time-container { + border-top: 1px solid #ccc; + clear: both; + width: 100%; + float: left; + text-align: left; + } + .react-datepicker__input-time-container .react-datepicker-time__caption { + padding: 0.4rem; + display: none; + } + .react-datepicker__input-time-container .react-datepicker-time__input-container { + display: inline-block; + position: absolute; + } + + .react-datepicker-time__input-container { + width: 100% !important; + } + + .react-datepicker-time__input { + width: 100% !important; + } + + .react-datepicker__input-time-container + .react-datepicker-time__input-container + .react-datepicker-time__input { + display: inline-block; + } + .react-datepicker__input-time-container + .react-datepicker-time__input-container + .react-datepicker-time__input + input { + width: auto; + } + .react-datepicker__input-time-container + .react-datepicker-time__input-container + .react-datepicker-time__input + input[type='time']::-webkit-inner-spin-button, + .react-datepicker__input-time-container + .react-datepicker-time__input-container + .react-datepicker-time__input + input[type='time']::-webkit-outer-spin-button { + -webkit-appearance: none; + margin: 0; + } + .react-datepicker__input-time-container + .react-datepicker-time__input-container + .react-datepicker-time__input + input[type='time'] { + -moz-appearance: textfield; + } + .react-datepicker__input-time-container + .react-datepicker-time__input-container + .react-datepicker-time__delimiter { + margin-left: 5px; + display: inline-block; + } + + .react-datepicker__time-container { + float: right; + border-left: 1px solid #aeaeae; + width: 85px; + } + .react-datepicker__time-container--with-today-button { + display: inline; + border: 1px solid #aeaeae; + border-radius: 0.3rem; + position: absolute; + right: -87px; + top: 0; + } + .react-datepicker__time-container .react-datepicker__time { + position: relative; + background: white; + border-bottom-right-radius: 0.3rem; + } + .react-datepicker__time-container .react-datepicker__time .react-datepicker__time-box { + width: 85px; + overflow-x: hidden; + margin: 0 auto; + text-align: center; + border-bottom-right-radius: 0.3rem; + } + .react-datepicker__time-container + .react-datepicker__time + .react-datepicker__time-box + ul.react-datepicker__time-list { + list-style: none; + margin: 0; + height: calc(195px + 1.7rem / 2); + overflow-y: scroll; + padding-right: 0; + padding-left: 0; + width: 100%; + box-sizing: content-box; + } + .react-datepicker__time-container + .react-datepicker__time + .react-datepicker__time-box + ul.rea + ct-datepicker__time-list + li.react-datepicker__time-list-item { + height: 30px; + padding: 5px 10px; + white-space: nowrap; + } + .react-datepicker__time-container + .react-datepicker__time + .react-datepicker__time-box + ul.react-datepicker__time-list + li.react-datepicker__time-list-item:hover { + cursor: pointer; + background-color: #f0f0f0; + } + .react-datepicker__time-container + .react-datepicker__time + .react-datepicker__time-box + ul.react-datepicker__time-list + li.react-datepicker__time-list-item--selected { + background-color: #ddf4fa; + color: black; + } + .react-datepicker__time-container + .react-datepicker__time + .react-datepicker__time-box + ul.react-datepicker__time-list + li.react-datepicker__time-list-item--selected:hover { + background-color: #ddf4fa; + color: black; + } + .react-datepicker__time-container + .react-datepicker__time + .react-datepicker__time-box + ul.react-datepicker__time-list + li.react-datepicker__time-list-item--disabled { + color: #ccc; + } + .react-datepicker__time-container + .react-datepicker__time + .react-datepicker__time-box + ul.react-datepicker__time-list + li.react-datepicker__time-list-item--disabled:hover { + cursor: default; + background-color: transparent; + } + + .react-datepicker__week-number { + color: #ccc; + display: inline-block; + width: 1.7rem; + line-height: 1.7rem; + text-align: center; + margin: 0.166rem; + } + .react-datepicker__week-number.react-datepicker__week-number--clickable { + cursor: pointer; + } + .react-datepicker__week-number.react-datepicker__week-number--clickable:not( + .react-datepicker__week-number--selected, + .react-datepicker__week-number--keyboard-selected + ):hover { + border-radius: 0.3rem; + background-color: #f0f0f0; + } + .react-datepicker__week-number--selected { + border-radius: 0; + background-color: #ddf4fa; + color: black; + } + .react-datepicker__week-number--selected:hover { + background-color: #1d5d90; + } + .react-datepicker__week-number--keyboard-selected { + border-radius: 0.3rem; + background-color: #2a87d0; + color: #fdfdfd; + } + .react-datepicker__week-number--keyboard-selected:hover { + background-color: #1d5d90; + } + + .react-datepicker__day-names { + white-space: nowrap; + margin-bottom: -8px; + font-weight: 500; + font-size: 0.8rem; + } + + .react-datepicker__day-name { + display: inline-block; + width: 2.3rem; + line-height: 2.3rem; + text-align: center; + color: #000; + } + + .react-datepicker__week { + white-space: nowrap; + } + + .react-datepicker__day, + .react-datepicker__time-name { + color: #000; + display: inline-block; + width: 2.3rem; + line-height: 2.3rem; + text-align: center; + } + + .react-datepicker__day, + .react-datepicker__month-text, + .react-datepicker__quarter-text, + .react-datepicker__year-text { + cursor: pointer; + } + .react-datepicker__day:hover, + .react-datepicker__month-text:hover, + .react-datepicker__quarter-text:hover, + .react-datepicker__year-text:hover { + border-radius: 0.3rem; + background-color: #f0f0f0; + } + .react-datepicker__day--today, + .react-datepicker__month-text--today, + .react-datepicker__quarter-text--today, + .react-datepicker__year-text--today { + font-weight: bold; + } + .react-datepicker__day--highlighted, + .react-datepicker__month-text--highlighted, + .react-datepicker__quarter-text--highlighted, + .react-datepicker__year-text--highlighted { + border-radius: 0.3rem; + background-color: #3dcc4a; + color: #fdfdfd; + } + .react-datepicker__day--highlighted:hover, + .react-datepicker__month-text--highlighted:hover, + .react-datepicker__quarter-text--highlighted:hover, + .react-datepicker__year-text--highlighted:hover { + background-color: #32be3f; + } + .react-datepicker__day--highlighted-custom-1, + .react-datepicker__month-text--highlighted-custom-1, + .react-datepicker__quarter-text--highlighted-custom-1, + .react-datepicker__year-text--highlighted-custom-1 { + color: magenta; + } + .react-datepicker__day--highlighted-custom-2, + .react-datepicker__month-text--highlighted-custom-2, + .react-datepicker__quarter-text--highlighted-custom-2, + .react-datepicker__year-text--highlighted-custom-2 { + color: green; + } + .react-datepicker__day--holidays, + .react-datepicker__month-text--holidays, + .react-datepicker__quarter-text--holidays, + .react-datepicker__year-text--holidays { + position: relative; + border-radius: 0.3rem; + background-color: #ff6803; + color: #fdfdfd; + } + .react-datepicker__day--holidays .overlay, + .react-datepicker__month-text--holidays .overlay, + .react-datepicker__quarter-text--holidays .overlay, + .react-datepicker__year-text--holidays .overlay { + position: absolute; + bottom: 100%; + left: 50%; + transform: translateX(-50%); + background-color: #333; + color: #fdfdfd; + padding: 4px; + border-radius: 4px; + white-space: nowrap; + visibility: hidden; + opacity: 0; + transition: visibility 0s, opacity 0.3s ease-in-out; + } + .react-datepicker__day--holidays:hover, + .react-datepicker__month-text--holidays:hover, + .react-datepicker__quarter-text--holidays:hover, + .react-datepicker__year-text--holidays:hover { + background-color: #cf5300; + } + .react-datepicker__day--holidays:hover .overlay, + .react-datepicker__month-text--holidays:hover .overlay, + .react-datepicker__quarter-text--holidays:hover .overlay, + .react-datepicker__year-text--holidays:hover .overlay { + visibility: visible; + opacity: 1; + } + .react-datepicker__day--selected, + .react-datepicker__day--in-selecting-range, + .react-datepicker__day--in-range, + .react-datepicker__month-text--selected, + .react-datepicker__month-text--in-selecting-range, + .react-datepicker__month-text--in-range, + .react-datepicker__quarter-text--selected, + .react-datepicker__quarter-text--in-selecting-range, + .react-datepicker__quarter-text--in-range, + .react-datepicker__year-text--selected, + .react-datepicker__year-text--in-selecting-range, + .react-datepicker__year-text--in-range { + border-radius: 0; + background-color: #ddf4fa; + color: black; + } + + .react-datepicker__day--selected.first-selected { + border-radius: 1rem; + background-color: #00a7e4; + color: white; + } + + .react-datepicker__day--selected:hover, + .react-datepicker__day--in-selecting-range:hover, + .react-datepicker__day--in-range:hover, + .react-datepicker__month-text--selected:hover, + .react-datepicker__month-text--in-selecting-range:hover, + .react-datepicker__month-text--in-range:hover, + .react-datepicker__quarter-text--selected:hover, + .react-datepicker__quarter-text--in-selecting-range:hover, + .react-datepicker__quarter-text--in-range:hover, + .react-datepicker__year-text--selected:hover, + .react-datepicker__year-text--in-selecting-range:hover, + .react-datepicker__year-text--in-range:hover { + background-color: #00a7e4; + color: white; + border-radius: 1rem; + } + + .react-datepicker__day--keyboard-selected:hover, + .react-datepicker__month-text--keyboard-selected:hover, + .react-datepicker__quarter-text--keyboard-selected:hover, + .react-datepicker__year-text--keyboard-selected:hover { + border-radius: 0; + background-color: #ddf4fa; + color: black; + } + .react-datepicker__month--selecting-range + .react-datepicker__day--in-range:not( + .react-datepicker__day--in-selecting-range, + .react-datepicker__month-text--in-selecting-range, + .react-datepicker__quarter-text--in-selecting-range, + .react-datepicker__year-text--in-selecting-range + ), + .react-datepicker__year--selecting-range + .react-datepicker__day--in-range:not( + .react-datepicker__day--in-selecting-range, + .react-datepicker__month-text--in-selecting-range, + .react-datepicker__quarter-text--in-selecting-range, + .react-datepicker__year-text--in-selecting-range + ), + .react-datepicker__month--selecting-range + .react-datepicker__month-text--in-range:not( + .react-datepicker__day--in-selecting-range, + .react-datepicker__month-text--in-selecting-range, + .react-datepicker__quarter-text--in-selecting-range, + .react-datepicker__year-text--in-selecting-range + ), + .react-datepicker__year--selecting-range + .react-datepicker__month-text--in-range:not( + .react-datepicker__day--in-selecting-range, + .react-datepicker__month-text--in-selecting-range, + .react-datepicker__quarter-text--in-selecting-range, + .react-datepicker__year-text--in-selecting-range + ), + .react-datepicker__month--selecting-range + .react-datepicker__quarter-text--in-range:not( + .react-datepicker__day--in-selecting-range, + .react-datepicker__month-text--in-selecting-range, + .react-datepicker__quarter-text--in-selecting-range, + .react-datepicker__year-text--in-selecting-range + ), + .react-datepicker__year--selecting-range + .react-datepicker__quarter-text--in-range:not( + .react-datepicker__day--in-selecting-range, + .react-datepicker__month-text--in-selecting-range, + .react-datepicker__quarter-text--in-selecting-range, + .react-datepicker__year-text--in-selecting-range + ), + .react-datepicker__month--selecting-range + .react-datepicker__year-text--in-range:not( + .react-datepicker__day--in-selecting-range, + .react-datepicker__month-text--in-selecting-range, + .react-datepicker__quarter-text--in-selecting-range, + .react-datepicker__year-text--in-selecting-range + ), + .react-datepicker__year--selecting-range + .react-datepicker__year-text--in-range:not( + .react-datepicker__day--in-selecting-range, + .react-datepicker__month-text--in-selecting-range, + .react-datepicker__quarter-text--in-selecting-range, + .react-datepicker__year-text--in-selecting-range + ) { + background-color: #f0f0f0; + color: #000; + } + .react-datepicker__day--disabled, + .react-datepicker__month-text--disabled, + .react-datepicker__quarter-text--disabled, + .react-datepicker__year-text--disabled { + cursor: default; + color: #ccc; + } + .react-datepicker__day--disabled:hover, + .react-datepicker__month-text--disabled:hover, + .react-datepicker__quarter-text--disabled:hover, + .react-datepicker__year-text--disabled:hover { + background-color: transparent; + } + .react-datepicker__day--disabled .overlay, + .react-datepicker__month-text--disabled .overlay, + .react-datepicker__quarter-text--disabled .overlay, + .react-datepicker__year-text--disabled .overlay { + position: absolute; + bottom: 70%; + left: 50%; + transform: translateX(-50%); + background-color: #333; + color: #fdfdfd; + padding: 4px; + border-radius: 4px; + white-space: nowrap; + visibility: hidden; + opacity: 0; + transition: visibility 0s, opacity 0.3s ease-in-out; + } + + .react-datepicker__input-container { + position: relative; + display: none; + width: 100%; + } + .react-datepicker__input-container .react-datepicker__calendar-icon { + position: absolute; + padding: 0.5rem; + box-sizing: content-box; + } + + .react-datepicker__view-calendar-icon input { + padding: 6px 10px 5px 25px; + } + + .react-datepicker__year-read-view, + .react-datepicker__month-read-view, + .react-datepicker__month-year-read-view { + border: 1px solid transparent; + border-radius: 0.3rem; + position: relative; + } + .react-datepicker__year-read-view:hover, + .react-datepicker__month-read-view:hover, + .react-datepicker__month-year-read-view:hover { + cursor: pointer; + } + .react-datepicker__year-read-view:hover .react-datepicker__year-read-view--down-arrow, + .react-datepicker__year-read-view:hover .react-datepicker__month-read-view--down-arrow, + .react-datepicker__month-read-view:hover .react-datepicker__year-read-view--down-arrow, + .react-datepicker__month-read-view:hover .react-datepicker__month-read-view--down-arrow, + .react-datepicker__month-year-read-view:hover .react-datepicker__year-read-view--down-arrow, + .react-datepicker__month-year-read-view:hover .react-datepicker__month-read-view--down-arrow { + border-top-color: #b3b3b3; + } + .react-datepicker__year-read-view--down-arrow, + .react-datepicker__month-read-view--down-arrow, + .react-datepicker__month-year-read-view--down-arrow { + transform: rotate(135deg); + right: -16px; + top: 0; + } + + .react-datepicker__year-dropdown, + .react-datepicker__month-dropdown, + .react-datepicker__month-year-dropdown { + background-color: #f0f0f0; + position: absolute; + width: 50%; + left: 25%; + top: 30px; + z-index: 1; + text-align: center; + border-radius: 0.3rem; + border: 1px solid #aeaeae; + } + .react-datepicker__year-dropdown:hover, + .react-datepicker__month-dropdown:hover, + .react-datepicker__month-year-dropdown:hover { + cursor: pointer; + } + .react-datepicker__year-dropdown--scrollable, + .react-datepicker__month-dropdown--scrollable, + .react-datepicker__month-year-dropdown--scrollable { + height: 150px; + overflow-y: scroll; + } + + .react-datepicker__year-option, + .react-datepicker__month-option, + .react-datepicker__month-year-option { + line-height: 20px; + width: 100%; + display: block; + margin-left: auto; + margin-right: auto; + } + .react-datepicker__year-option:first-of-type, + .react-datepicker__month-option:first-of-type, + .react-datepicker__month-year-option:first-of-type { + border-top-left-radius: 0.3rem; + border-top-right-radius: 0.3rem; + } + .react-datepicker__year-option:last-of-type, + .react-datepicker__month-option:last-of-type, + .react-datepicker__month-year-option:last-of-type { + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; + border-bottom-left-radius: 0.3rem; + border-bottom-right-radius: 0.3rem; + } + .react-datepicker__year-option:hover, + .react-datepicker__month-option:hover, + .react-datepicker__month-year-option:hover { + background-color: #ccc; + } + .react-datepicker__year-option:hover .react-datepicker__navigation--years-upcoming, + .react-datepicker__month-option:hover .react-datepicker__navigation--years-upcoming, + .react-datepicker__month-year-option:hover .react-datepicker__navigation--years-upcoming { + border-bottom-color: #b3b3b3; + } + .react-datepicker__year-option:hover .react-datepicker__navigation--years-previous, + .react-datepicker__month-option:hover .react-datepicker__navigation--years-previous, + .react-datepicker__month-year-option:hover .react-datepicker__navigation--years-previous { + border-top-color: #b3b3b3; + } + .react-datepicker__year-option--selected, + .react-datepicker__month-option--selected, + .react-datepicker__month-year-option--selected { + position: absolute; + left: 15px; + } + + .react-datepicker__close-icon { + cursor: pointer; + background-color: transparent; + border: 0; + outline: 0; + padding: 0 6px 0 0; + position: absolute; + top: 0; + right: 0; + height: 100%; + display: table-cell; + vertical-align: middle; + } + .react-datepicker__close-icon::after { + cursor: pointer; + border-radius: 1; + background-color: #00a7e4; + color: white; + height: 16px; + width: 16px; + padding: 2px; + font-size: 12px; + line-height: 1; + text-align: center; + display: table-cell; + vertical-align: middle; + content: '×'; + } + .react-datepicker__close-icon--disabled { + cursor: default; + } + .react-datepicker__close-icon--disabled::after { + cursor: default; + background-color: #ccc; + } + + .react-datepicker__today-button { + background: #f0f0f0; + border-top: 1px solid #aeaeae; + cursor: pointer; + text-align: center; + font-weight: bold; + padding: 5px 0; + clear: left; + } + + .react-datepicker__portal { + position: fixed; + width: 100vw; + height: 100vh; + background-color: rgba(0, 0, 0, 0.8); + left: 0; + top: 0; + justify-content: center; + align-items: center; + display: flex; + z-index: 2147483647; + } + .react-datepicker__portal .react-datepicker__day-name, + .react-datepicker__portal .react-datepicker__day, + .react-datepicker__portal .react-datepicker__time-name { + width: 3rem; + line-height: 3rem; + } + @media (max-width: 400px), (max-height: 550px) { + .react-datepicker__portal .react-datepicker__day-name, + .react-datepicker__portal .react-datepicker__day, + .react-datepicker__portal .react-datepicker__time-name { + width: 2rem; + line-height: 2rem; + } + } + .react-datepicker__portal .react-datepicker__current-month, + .react-datepicker__portal .react-datepicker-time__header { + font-size: 1.44rem; + } + + .react-datepicker__children-container { + width: 13.8rem; + margin: 0.4rem; + padding-right: 0.2rem; + padding-left: 0.2rem; + height: auto; + } + + .react-datepicker__aria-live { + position: absolute; + clip-path: circle(0); + border: 0; + height: 1px; + margin: -1px; + overflow: hidden; + padding: 0; + width: 1px; + white-space: nowrap; + } + + .react-datepicker__calendar-icon { + width: 1em; + height: 1em; + vertical-align: -0.125em; + } +} diff --git a/src/components/DateRangePicker/DateRangePicker.tsx b/src/components/DateRangePicker/DateRangePicker.tsx new file mode 100644 index 00000000..13344777 --- /dev/null +++ b/src/components/DateRangePicker/DateRangePicker.tsx @@ -0,0 +1,227 @@ +/** at the top of your component file */ +import { Button, HStack, Text, VStack } from '@chakra-ui/react'; +import type React from 'react'; +import { useState } from 'react'; +import DatePicker from 'react-datepicker'; + +import { TimeInput } from '../TimeInput/TimeInput'; + +import { canClose, checkAndSetTime } from './helpers'; + +interface CustomFooterProps { + startTime: string | undefined; + startTimeError: boolean; + endTime: string | undefined; + endTimeError: boolean; + canClose: boolean; + onChangeStartTime: (e: React.ChangeEvent) => void; + onChangeEndTime: (e: React.ChangeEvent) => void; + onBlurStartTime: (e: React.FocusEvent) => void; + onBlurEndTime: (e: React.FocusEvent) => void; + onApplyClick: () => void; + onResetClick: () => void; +} + +const CustomFooter = ({ + startTime, + startTimeError, + endTime, + endTimeError, + canClose, + onChangeStartTime, + onChangeEndTime, + onBlurStartTime, + onBlurEndTime, + onApplyClick, + onResetClick, +}: CustomFooterProps) => ( + + + {/* Start Time Section */} + + + Start Time + + + + + {/* End Time Section */} + + + End Time + + + + + + + + + +); + +interface CustomDateRangePickerProps { + onDateChange: (startDate: number, endDate: number | undefined) => void; + onTimeChange: (startTime: string | undefined, endTime: string | undefined) => void; + isDatePickerOpen: boolean; + setIsDatePickerOpen: (isOpen: boolean) => void; + ctaComponent?: React.ReactNode; + startDate: number; + endDate?: number; + minDate: Date | null; + maxDate: Date | null; + openDirection?: string; + scale?: string; +} + +function CustomDateRangePicker({ + startDate, + endDate, + onDateChange, + onTimeChange, + ctaComponent, + isDatePickerOpen, + setIsDatePickerOpen, + minDate, + maxDate, + scale = '1', + openDirection = 'top-start', +}: CustomDateRangePickerProps) { + const [startTimeText, setStartTimeText] = useState(); + const [startTimeError, setStartTimeError] = useState(false); + const [endTimeText, setEndTimeText] = useState(); + const [endTimeError, setEndTimeError] = useState(false); + + // handle changes inside date picker + const handleDateChange = (dates) => { + const [start, end] = dates; + + if (!start) { + return; + } + onDateChange(start.getTime(), end ? end.getTime() : undefined); + }; + + const handleApplyClick = () => { + if (startTimeError || endTimeError) { + return; + } + setIsDatePickerOpen(false); + onTimeChange(startTimeText, endTimeText); + }; + + const handleResetClick = () => { + setStartTimeError(false); + setEndTimeError(false); + setStartTimeText(undefined); + setEndTimeText(undefined); + onDateChange(0, undefined); + }; + + const onChangeStartTime = (e: React.ChangeEvent) => { + setStartTimeText(e.target.value.toUpperCase()); + }; + + const onChangeEndTime = (e: React.ChangeEvent) => { + setEndTimeText(e.target.value.toUpperCase()); + }; + + const onBlurStartTime = (e) => { + checkAndSetTime(e.target.value, setStartTimeText, setStartTimeError); + }; + + const onBlurEndTime = (e) => { + checkAndSetTime(e.target.value, setEndTimeText, setEndTimeError); + }; + + return ( + <> +
+ + } + dateFormat="MM/dd/yyyy h:mm aa" + endDate={endDate ? new Date(endDate) : null} + maxDate={maxDate} + minDate={minDate || new Date()} + monthsShown={2} + open={isDatePickerOpen} + popperPlacement={openDirection} + startDate={startDate ? new Date(startDate) : null} + onCalendarClose={() => { + setIsDatePickerOpen(false); + }} + onChange={handleDateChange} + /> +
+ {ctaComponent} + + ); +} + +export default CustomDateRangePicker; diff --git a/src/components/DateRangePicker/MobileDateRangePicker.tsx b/src/components/DateRangePicker/MobileDateRangePicker.tsx new file mode 100644 index 00000000..4f3c9b13 --- /dev/null +++ b/src/components/DateRangePicker/MobileDateRangePicker.tsx @@ -0,0 +1,227 @@ +/** at the top of your component file */ +import { Button, HStack, Text, VStack } from '@chakra-ui/react'; +import type React from 'react'; +import { useState } from 'react'; +import DatePicker from 'react-datepicker'; + +import { TimeInput } from '../TimeInput/TimeInput'; + +import { canClose, checkAndSetTime } from './helpers'; + +interface CustomFooterProps { + startTime: string | undefined; + startTimeError: boolean; + endTime: string | undefined; + endTimeError: boolean; + canClose: boolean; + onChangeStartTime: (e: React.ChangeEvent) => void; + onChangeEndTime: (e: React.ChangeEvent) => void; + onBlurStartTime: (e: React.FocusEvent) => void; + onBlurEndTime: (e: React.FocusEvent) => void; + onApplyClick: () => void; + onResetClick: () => void; +} + +const CustomFooter = ({ + startTime, + startTimeError, + endTime, + endTimeError, + canClose, + onChangeStartTime, + onChangeEndTime, + onBlurStartTime, + onBlurEndTime, + onApplyClick, + onResetClick, +}: CustomFooterProps) => ( + + + {/* Start Time Section */} + + + Start Time + + + + {/* End Time Section */} + + + End Time + + + + + + + + + +); + +interface CustomDateRangePickerProps { + onDateChange: (startDate: number, endDate: number | undefined) => void; + onTimeChange: (startTime: string | undefined, endTime: string | undefined) => void; + isDatePickerOpen: boolean; + setIsDatePickerOpen: (isOpen: boolean) => void; + ctaComponent?: React.ReactNode; + startDate: number; + endDate?: number; + minDate: Date | null; + maxDate: Date | null; + openDirection?: string; + scale?: string; +} + +function CustomDateRangePickerMobile({ + startDate, + endDate, + onDateChange, + onTimeChange, + ctaComponent, + isDatePickerOpen, + setIsDatePickerOpen, + minDate, + maxDate, + scale = '1', + openDirection = 'top-start', +}: CustomDateRangePickerProps) { + const [startTimeText, setStartTimeText] = useState(); + const [startTimeError, setStartTimeError] = useState(false); + const [endTimeText, setEndTimeText] = useState(); + const [endTimeError, setEndTimeError] = useState(false); + + // handle changes inside date picker + const handleDateChange = (dates) => { + const [start, end] = dates; + + if (!start) { + return; + } + onDateChange(start.getTime(), end ? end.getTime() : undefined); + }; + + const handleApplyClick = () => { + if (startTimeError || endTimeError) { + return; + } + setIsDatePickerOpen(false); + onTimeChange(startTimeText, endTimeText); + }; + + const handleResetClick = () => { + setStartTimeError(false); + setEndTimeError(false); + setStartTimeText(undefined); + setEndTimeText(undefined); + onDateChange(0, undefined); + }; + + const onChangeStartTime = (e: React.ChangeEvent) => { + setStartTimeText(e.target.value.toUpperCase()); + }; + + const onChangeEndTime = (e: React.ChangeEvent) => { + setEndTimeText(e.target.value.toUpperCase()); + }; + + const onBlurStartTime = (e) => { + checkAndSetTime(e.target.value, setStartTimeText, setStartTimeError); + }; + + const onBlurEndTime = (e) => { + checkAndSetTime(e.target.value, setEndTimeText, setEndTimeError); + }; + + return ( + <> +
+ + } + dateFormat="MM/dd/yyyy h:mm aa" + endDate={endDate ? new Date(endDate) : null} + maxDate={maxDate} + minDate={minDate || new Date()} + monthsShown={1} + open={isDatePickerOpen} + popperPlacement={openDirection} + startDate={startDate ? new Date(startDate) : null} + onCalendarClose={() => { + setIsDatePickerOpen(false); + }} + onChange={handleDateChange} + /> +
+ {ctaComponent} + + ); +} + +export default CustomDateRangePickerMobile; diff --git a/src/components/DateRangePicker/helpers.tsx b/src/components/DateRangePicker/helpers.tsx new file mode 100644 index 00000000..43c674f9 --- /dev/null +++ b/src/components/DateRangePicker/helpers.tsx @@ -0,0 +1,33 @@ +import { DateTime } from 'luxon'; + +export const canClose = ( + startDate, + endDate, + startTimeText, + endTimeText, + startTimeError, + endTimeError, +) => { + // If no date range is selected, allow the date picker to close + if ( + startDate === null && + endDate === null && + startTimeText === undefined && + endTimeText === undefined + ) { + return true; + } + + return startDate != null && !startTimeError && !endTimeError; +}; + +export const checkAndSetTime = (inputValue, setTimeText, setIsErr) => { + const parsedTime = DateTime.fromFormat(inputValue, 'h:mm a'); + if (parsedTime.isValid) { + setTimeText(parsedTime.toFormat('h:mm a')); + setIsErr(false); + } else { + console.error('Error parsing time:', parsedTime.invalidReason); + setIsErr(true); + } +}; diff --git a/src/components/FormControl/FormControl.tsx b/src/components/FormControl/FormControl.tsx index 7c10794f..db00c176 100644 --- a/src/components/FormControl/FormControl.tsx +++ b/src/components/FormControl/FormControl.tsx @@ -13,6 +13,8 @@ export interface FormControlProps extends CFormControlProps { helperText?: string; errorText?: string; labelProps?: FormLabelProps; + helperTextProps?: Record; + marginY?: string; } export const FormControlComponent = ({ @@ -21,14 +23,20 @@ export const FormControlComponent = ({ errorText, children, labelProps, + helperTextProps, + marginY = '5', ...props }: PropsWithChildren) => { return ( - + {label} - {helperText && {helperText}} + {helperText && ( + + {helperText} + + )} {children} {errorText && ( diff --git a/src/components/Icons/CalendarIcon.tsx b/src/components/Icons/CalendarIcon.tsx new file mode 100644 index 00000000..0ab588a2 --- /dev/null +++ b/src/components/Icons/CalendarIcon.tsx @@ -0,0 +1,22 @@ +import { Icon, type IconProps } from '@chakra-ui/react'; + +export const CalendarIcon = ({ ...props }: IconProps) => { + return ( + + + + ); +}; diff --git a/src/components/Icons/CheckedIcon.tsx b/src/components/Icons/CheckedIcon.tsx index 49672b86..51b465ea 100644 --- a/src/components/Icons/CheckedIcon.tsx +++ b/src/components/Icons/CheckedIcon.tsx @@ -5,7 +5,7 @@ interface CheckedIconProps extends IconProps { isChecked?: boolean; } -export const CheckedIcon = ({ isIndeterminate, isChecked, ...props }: CheckedIconProps) => { +export const CheckedIcon = ({ isIndeterminate, isChecked = true, ...props }: CheckedIconProps) => { return ( - + - + ); }; diff --git a/src/components/Icons/EyeIcon.tsx b/src/components/Icons/EyeIcon.tsx new file mode 100644 index 00000000..1ce53c91 --- /dev/null +++ b/src/components/Icons/EyeIcon.tsx @@ -0,0 +1,21 @@ +import { Icon, type IconProps } from '@chakra-ui/react'; + +export const EyeIcon = (props: IconProps) => { + return ( + + + + ); +}; diff --git a/src/components/Icons/FilterIcon.tsx b/src/components/Icons/FilterIcon.tsx new file mode 100644 index 00000000..dda78e3e --- /dev/null +++ b/src/components/Icons/FilterIcon.tsx @@ -0,0 +1,34 @@ +import { Icon, type IconProps } from '@chakra-ui/react'; + +interface FilterIconProps extends IconProps { + highToLow: boolean; +} + +export const FilterIcon = ({ highToLow, ...props }: FilterIconProps) => { + return ( + + {highToLow ? ( + + ) : ( + + )} + + ); +}; diff --git a/src/components/Icons/MinusButtonIcon.tsx b/src/components/Icons/MinusButtonIcon.tsx new file mode 100644 index 00000000..bd7934a8 --- /dev/null +++ b/src/components/Icons/MinusButtonIcon.tsx @@ -0,0 +1,22 @@ +import { Icon, type IconProps } from '@chakra-ui/react'; + +export const MinusButtonIcon = (props: IconProps) => { + return ( + + + + ); +}; diff --git a/src/components/Icons/PlusButtonIcon.tsx b/src/components/Icons/PlusButtonIcon.tsx new file mode 100644 index 00000000..72534a95 --- /dev/null +++ b/src/components/Icons/PlusButtonIcon.tsx @@ -0,0 +1,21 @@ +import { Icon, type IconProps } from '@chakra-ui/react'; + +export const PlusButtonIcon = (props: IconProps) => { + return ( + + + + ); +}; diff --git a/src/components/Icons/ShareIcon.tsx b/src/components/Icons/ShareIcon.tsx new file mode 100644 index 00000000..6d23cf12 --- /dev/null +++ b/src/components/Icons/ShareIcon.tsx @@ -0,0 +1,23 @@ +import { Icon, type IconProps } from '@chakra-ui/react'; + +export const ShareIcon = ({ ...props }: IconProps) => { + return ( + + + + + ); +}; diff --git a/src/components/ImageFileInput/ImageFileInput.tsx b/src/components/ImageFileInput/ImageFileInput.tsx index 1f4d8c23..3abc443d 100644 --- a/src/components/ImageFileInput/ImageFileInput.tsx +++ b/src/components/ImageFileInput/ImageFileInput.tsx @@ -15,7 +15,9 @@ import { import { ImageIcon } from '../Icons'; interface ImageFileInputProps extends InputProps { - label: string; + label?: string; + ctaText?: string; + buttonText?: string; selectedFile?: File; preview?: string; errorMessage?: string; @@ -24,6 +26,8 @@ interface ImageFileInputProps extends InputProps { export const ImageFileInput = ({ label, + ctaText = 'Browse or drag and drop your image here', + buttonText = 'Browse images', selectedFile, preview, errorMessage, @@ -32,11 +36,13 @@ export const ImageFileInput = ({ }: ImageFileInputProps) => { return ( - - - {label} - - + {label && ( + + + {label} + + + )} - Browse or drag and drop your image here + {ctaText}
- Browse images + {buttonText}
)} diff --git a/src/components/ImageFileInput/ImageFileInputSmall.tsx b/src/components/ImageFileInput/ImageFileInputSmall.tsx new file mode 100644 index 00000000..bbc5633e --- /dev/null +++ b/src/components/ImageFileInput/ImageFileInputSmall.tsx @@ -0,0 +1,117 @@ +import { + Center, + Flex, + type FlexProps, + FormControl, + FormLabel, + Input, + InputGroup, + type InputProps, + Text, + Image, + Show, +} from '@chakra-ui/react'; + +import { ImageIcon } from '../Icons'; + +interface ImageFileInputSmallProps extends InputProps { + label?: string; + ctaText?: string; + buttonText?: string; + selectedFile?: File; + preview?: string; + errorMessage?: string; + flexProps?: FlexProps; +} + +export const ImageFileInputSmall = ({ + label, + ctaText = 'Browse or drag and drop your image here', + buttonText = 'Browse images', + selectedFile, + preview, + errorMessage, + flexProps, + ...props +}: ImageFileInputSmallProps) => { + return ( + + {label && ( + + + {label} + + + )} + + + { + // console.log('File input clicked'); + }} + {...props} + /> + + {selectedFile !== undefined && preview ? ( + Image upload preview + ) : ( + + + + {ctaText} + +
+ {buttonText} +
+
+ )} +
+
+ {errorMessage && ( + + {errorMessage} + + )} +
+ ); +}; diff --git a/src/components/Navbar/MobileMenu.tsx b/src/components/Navbar/MobileMenu.tsx index afa1531c..49b4ce92 100644 --- a/src/components/Navbar/MobileMenu.tsx +++ b/src/components/Navbar/MobileMenu.tsx @@ -31,6 +31,8 @@ interface MobileMenuProps { export const MobileMenu = ({ menuItems }: MobileMenuProps) => { const { isLoggedIn } = useAuthWalletContext(); const { isOpen, onOpen, onClose } = useDisclosure(); + const isTicketSubdirectory = + location.pathname.startsWith('/tickets/') || location.pathname.startsWith('/claim/'); const btnRef = useRef(null); return ( @@ -59,9 +61,11 @@ export const MobileMenu = ({ menuItems }: MobileMenuProps) => { })} - - {isLoggedIn ? : } - + {!isTicketSubdirectory && ( + + {isLoggedIn ? : } + + )} diff --git a/src/components/Navbar/Navbar.tsx b/src/components/Navbar/Navbar.tsx index 4f56477e..66c22505 100644 --- a/src/components/Navbar/Navbar.tsx +++ b/src/components/Navbar/Navbar.tsx @@ -25,16 +25,17 @@ type NavbarProps = BoxProps; export const Navbar = (props: NavbarProps) => { const { isLoggedIn } = useAuthWalletContext(); + const isTicketSubdirectory = + location.pathname.startsWith('/tickets/') || location.pathname.startsWith('/claim/'); const MENU_ITEMS = [ { - name: 'Docs', - href: 'https://docs.keypom.xyz', - isExternal: true, + name: 'Gallery', + href: '/gallery', }, { - name: 'Get in touch', - href: 'https://twitter.com/keypomxyz', + name: 'Docs', + href: 'https://docs.keypom.xyz', isExternal: true, }, { @@ -42,6 +43,11 @@ export const Navbar = (props: NavbarProps) => { href: '/drops', isProtected: !isLoggedIn, }, + { + name: 'My Events', + href: '/events', + isProtected: !isLoggedIn, + }, ]; const menuItems = MENU_ITEMS.map((item) => ( @@ -72,7 +78,7 @@ export const Navbar = (props: NavbarProps) => { {/* Menu Items */} {menuItems} - {isLoggedIn ? : } + {!isTicketSubdirectory && (isLoggedIn ? : )} diff --git a/src/components/NotFound404/NotFound404.tsx b/src/components/NotFound404/NotFound404.tsx index cf549cd5..4e481e93 100644 --- a/src/components/NotFound404/NotFound404.tsx +++ b/src/components/NotFound404/NotFound404.tsx @@ -1,38 +1,46 @@ import { Button, Center, Divider, Hide, HStack, Show, Text, VStack } from '@chakra-ui/react'; import { useNavigate } from 'react-router-dom'; -export const NotFound404 = () => { +export const NotFound404 = ({ + header = '404', + subheader = 'This page could not be found.', + cta = 'back to homepage', +}: { + header?: string; + subheader?: string; + cta?: string; +}) => { const navigate = useNavigate(); return (
- 404 + {header} - This page could not be found. + {subheader} - 404 + {header} - This page could not be found. + {subheader} diff --git a/src/components/ProtectedRoutes/ProtectedRoute.tsx b/src/components/ProtectedRoutes/ProtectedRoute.tsx index c1e0ba41..a0787848 100644 --- a/src/components/ProtectedRoutes/ProtectedRoute.tsx +++ b/src/components/ProtectedRoutes/ProtectedRoute.tsx @@ -16,6 +16,7 @@ export const ProtectedRoute = ({ useEffect(() => { if (!isLoggedIn) { + // eslint-disable-next-line no-console console.error('Unauthenticated page access.'); navigate(redirectPath); } diff --git a/src/components/SignedInButton/SignedInButton.tsx b/src/components/SignedInButton/SignedInButton.tsx index bf150930..5aed7ef8 100644 --- a/src/components/SignedInButton/SignedInButton.tsx +++ b/src/components/SignedInButton/SignedInButton.tsx @@ -27,6 +27,10 @@ export const SignedInButton = () => { const { account, selector } = useAuthWalletContext(); const handleSignOut = async () => { + if (!selector.isSignedIn()) { + console.error('Not signed in'); + return; + } const wallet = await selector.wallet(); wallet @@ -37,7 +41,7 @@ export const SignedInButton = () => { }) .catch((err) => { // eslint-disable-next-line no-console - console.log('Failed to sign out'); + console.error('Failed to sign out'); // eslint-disable-next-line no-console console.error(err); }); @@ -121,7 +125,7 @@ export const SignedInButton = () => { } onClick={handleMasterKey}> - Master Key + Site Password } onClick={handleSignOut}> Sign out diff --git a/src/components/Step/Step.tsx b/src/components/Step/Step.tsx index 899eee2e..8b771498 100644 --- a/src/components/Step/Step.tsx +++ b/src/components/Step/Step.tsx @@ -1,10 +1,8 @@ import { Box, HStack, Text } from '@chakra-ui/react'; -import type React from 'react'; export interface StepItem { title: string; name: string; - component: React.ReactNode; } export interface StepProps { diff --git a/src/components/Table/DataTable.tsx b/src/components/Table/DataTable.tsx index 586d564e..6b5d3bd0 100644 --- a/src/components/Table/DataTable.tsx +++ b/src/components/Table/DataTable.tsx @@ -1,7 +1,6 @@ import { TableContainer, Show, - Hide, Tbody, Table, type TableProps, @@ -33,11 +32,24 @@ import { type ColumnItem, type DataItem } from './types'; */ interface DataTableProps extends TableProps { - type?: 'all-drops' | 'drop-manager'; + type?: + | 'all-drops' + | 'drop-manager' + | 'no-filtered-keys' + | 'no-filtered-drops' + | 'event-manager' + | 'all-tickets' + | 'no-filtered-events' + | 'all-events' + | 'no-filtered-tickets' + | 'create-tickets' + | 'collect-info'; showColumns?: boolean; columns: ColumnItem[]; data: DataItem[]; loading?: boolean; + showMobileTitles: string[]; + excludeMobileColumns: string[]; } export const DataTable = ({ @@ -46,6 +58,8 @@ export const DataTable = ({ columns = [], data = [], loading = false, + showMobileTitles = [], + excludeMobileColumns = [], ...props }: DataTableProps) => { const navigate = useNavigate(); @@ -98,12 +112,21 @@ export const DataTable = ({ {/* Desktop Table */} - +
{showColumns && ( - {columns.map((col) => ( - ))} @@ -116,9 +139,16 @@ export const DataTable = ({ {/* Mobile table */} - - - + + + ) : ( diff --git a/src/components/Table/MobileDataTable.tsx b/src/components/Table/MobileDataTable.tsx index c5272bf0..12e36f39 100644 --- a/src/components/Table/MobileDataTable.tsx +++ b/src/components/Table/MobileDataTable.tsx @@ -17,12 +17,16 @@ interface MobileDataTableProps extends TableProps { columns: ColumnItem[]; data: DataItem[]; loading: boolean; + showMobileTitles: string[]; + excludeMobileTitles: string[]; } export const MobileDataTable = ({ columns, data, loading = false, + showMobileTitles = [], + excludeMobileTitles = [], ...props }: MobileDataTableProps) => { const navigate = useNavigate(); @@ -41,7 +45,7 @@ export const MobileDataTable = ({ )); } - return data.map((drop) => ( + return data.map((drop, idx) => ( {columns - .filter((column) => actionColumn.title !== column.title) // exclude action column + .filter( + (column) => + actionColumn.id !== column.id && !excludeMobileTitles.includes(column.id), + ) // exclude action column .map((column) => ( - {column.selector(drop)} + + {showMobileTitles.includes(column.id) ? `${column.title}: ` : ''} + {column.selector(drop)} + ))} - + )); }; return ( -
+ {columns.map((col, index) => ( + {col.title}
{actionColumn.selector(drop)} + {actionColumn.selector(drop)} +
+
{getMobileTableBody()}
diff --git a/src/components/Table/constants.ts b/src/components/Table/constants.ts index 5449a7eb..fc2f8383 100644 --- a/src/components/Table/constants.ts +++ b/src/components/Table/constants.ts @@ -3,8 +3,44 @@ export const EMPTY_TABLE_TEXT_MAP = { heading: `You haven't added any drops`, text: `Let's create a drop!`, }, + 'all-events': { + heading: `You haven't created any events`, + text: `Create a new event!`, + }, + 'no-filtered-events': { + heading: `No events found with the current filters`, + text: `Please try different filters`, + }, + 'event-manager': { + heading: `You don't have any tickets for this event!`, + text: `Please create a new event`, + }, + 'no-filtered-drops': { + heading: `No drops found with the current filters`, + text: `Please try different filters`, + }, + 'no-filtered-keys': { + heading: `No keys found with the current filters`, + text: `Please try different filters`, + }, 'drop-manager': { heading: `You haven't added any keys`, text: '', }, + 'all-tickets': { + heading: `No tickets have been purchased yet`, + text: `Please try again later`, + }, + 'no-filtered-tickets': { + heading: `No tickets found with the current filters`, + text: `Please try different filters`, + }, + 'collect-info': { + heading: `You don't have any attendee questions`, + text: `Add some if you want!`, + }, + 'create-tickets': { + heading: `You don't have any tickets`, + text: `Create a new ticket!`, + }, }; diff --git a/src/components/TimeInput/TimeInput.tsx b/src/components/TimeInput/TimeInput.tsx new file mode 100644 index 00000000..42bbd3c6 --- /dev/null +++ b/src/components/TimeInput/TimeInput.tsx @@ -0,0 +1,62 @@ +import type React from 'react'; +import { useState, useEffect } from 'react'; +import InputMask from 'react-input-mask'; +import { Input } from '@chakra-ui/react'; + +interface TimeInputProps { + value: string; + onChange: (e: React.ChangeEvent) => void; + onBlur: (e: React.FocusEvent) => void; + isInvalid: boolean; + placeholder: string; +} + +export const TimeInput = ({ value, onChange, onBlur, isInvalid, placeholder }: TimeInputProps) => { + const [mask, setMask] = useState('9|'); + + useEffect(() => { + // Check if the second character is a colon + if (value.length === 2) { + if (value[1] === ':') { + setMask('9:99 ??'); // Change the mask to "9:99 ??" + } else { + setMask('99:99 ??'); // Default mask + } + } + if (value.length < 2) { + setMask('9|'); // Default mask + } + }, [value]); // Depend on value to re-evaluate when it changes + + const handleBlur = (e: React.FocusEvent) => { + onBlur(e); // Call the parent component's onBlur for validation + }; + + return ( + + {(inputProps) => ( + + )} + + ); +}; diff --git a/src/components/ToggleColorModeButton/ToggleColorModeButton.tsx b/src/components/ToggleColorModeButton/ToggleColorModeButton.tsx new file mode 100644 index 00000000..84f09963 --- /dev/null +++ b/src/components/ToggleColorModeButton/ToggleColorModeButton.tsx @@ -0,0 +1,10 @@ +import { useColorMode, Button } from '@chakra-ui/react'; + +export const ToggleColorModeButton = () => { + const { colorMode, toggleColorMode } = useColorMode(); + return ( + + ); +}; diff --git a/src/components/ToggleSwitch/ToggleSwitch.tsx b/src/components/ToggleSwitch/ToggleSwitch.tsx new file mode 100644 index 00000000..a705d339 --- /dev/null +++ b/src/components/ToggleSwitch/ToggleSwitch.tsx @@ -0,0 +1,27 @@ +import { Switch, Box } from '@chakra-ui/react'; + +function ToggleSwitch({ + toggle, + handleToggle, + disabled = false, + size = 'md', +}: { + toggle: boolean; + handleToggle: (a: any) => void; + disabled?: boolean; + size?: 'sm' | 'md' | 'lg'; +}) { + return ( + + + + ); +} + +export default ToggleSwitch; diff --git a/src/config/config.ts b/src/config/config.ts index 063e939c..367b04eb 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -3,7 +3,11 @@ import { type IToken, type IWalletOption } from '@/types/common'; const contractName = process.env.REACT_APP_CONTRACT_ID ?? 'v2.keypom.testnet'; const cloudflareIfps = process.env.REACT_APP_CLOUDFLARE_IFPS ?? 'https://cloudflare-ipfs.com/ipfs'; // eslint-disable-next-line no-console -console.log(process.env.REACT_APP_NETWORK_ID, process.env.REACT_APP_CONTRACT_ID); +console.log( + 'Network and Contract IDs: ', + process.env.REACT_APP_NETWORK_ID, + process.env.REACT_APP_CONTRACT_ID, +); const SUPPORTED_WALLET_OPTIONS: IWalletOption[] = [ { diff --git a/src/constants/common.ts b/src/constants/common.ts index 6b234115..3f699e0b 100644 --- a/src/constants/common.ts +++ b/src/constants/common.ts @@ -6,6 +6,7 @@ export const CLOUDFLARE_IPFS = 'https://cloudflare-ipfs.com/ipfs'; export const DROP_TYPE = { TOKEN: 'TOKEN', TICKET: 'TICKET', + EVENT: 'EVENT', TRIAL: 'TRIAL', NFT: 'NFT', SIMPLE: 'SIMPLE', @@ -15,10 +16,18 @@ export const DROP_TYPE = { type DROP_TYPE_KEYS = keyof typeof DROP_TYPE; export type DROP_TYPES = (typeof DROP_TYPE)[DROP_TYPE_KEYS]; +export const PURCHASED_LOCAL_STORAGE_PREFIX = 'TICKETS_PURCHASED'; + export const MASTER_KEY = 'MASTER_KEY'; export const MAX_FILE_SIZE = 10000000; -export const PAGE_SIZE_LIMIT = 10; +export const WORKER_BASE_URL = 'https://keypom-nft-storage.keypom.workers.dev/'; +export const EVENTS_WORKER_IPFS_PINNING = 'https://stripe-worker.kp-capstone.workers.dev/ipfs-pin'; +export const EVENTS_WORKER_BASE = 'https://stripe-worker.kp-capstone.workers.dev'; + +export const PAGE_SIZE_LIMIT = 5; export const NFT_ATTEMPT_KEY = 'NFT_ATTEMPT'; export const PAGE_QUERY_PARAM = 'page'; +export const KEYPOM_EVENTS_CONTRACT = '1711230915949-kp-ticketing.testnet'; +export const KEYPOM_MARKETPLACE_CONTRACT = '1711230915949-marketplace.testnet'; diff --git a/src/contexts/AppContext.tsx b/src/contexts/AppContext.tsx index 91d4d98e..7ef1268b 100644 --- a/src/contexts/AppContext.tsx +++ b/src/contexts/AppContext.tsx @@ -1,4 +1,4 @@ -import { createContext, type PropsWithChildren, useContext, useState } from 'react'; +import { createContext, type PropsWithChildren, useContext, useState, useEffect } from 'react'; import { type ButtonProps } from '@chakra-ui/react'; import { set } from '@/utils/localStorage'; @@ -15,8 +15,10 @@ export interface AppModalOptions { buttonProps?: ButtonProps; } -interface AppModalValues { +export interface AppModalValues { isOpen: boolean; + modalContent?: React.ReactNode; + size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl' | 'full'; closeOnOverlayClick?: boolean; closeButtonVisible?: boolean; message?: string; @@ -27,23 +29,76 @@ interface AppModalValues { isLoading?: boolean; isSuccess?: boolean; isError?: boolean; + canClose?: boolean; } interface AppContextValues { appModal: AppModalValues; + fetchAttempts: number; setAppModal: (args: AppModalValues) => void; + setTriggerPriceFetch: (trigger: boolean) => void; + nearPrice?: number; + setNearPrice: (price: number) => void; } const AppContext = createContext(null); +const fetchPrice = async (url, parseData) => { + try { + const response = await fetch(url); + const data = await response.json(); + return parseData(data); + } catch (error) { + // eslint-disable-next-line no-console + console.error('Fetch error:', error); + return null; + } +}; + export const AppContextProvider = ({ children }: PropsWithChildren) => { const [appModal, setAppModal] = useState({ isOpen: false, }); + const [nearPrice, setNearPrice] = useState(); + const [fetchAttempts, setFetchAttempts] = useState(0); + const [triggerPriceFetch, setTriggerPriceFetch] = useState(true); + + useEffect(() => { + const setPriceWithFallback = async () => { + const coingeckoPrice = await fetchPrice( + 'https://api.coingecko.com/api/v3/simple/price?ids=near&vs_currencies=usd', + (data) => data.near.usd, + ); + + if (coingeckoPrice !== null) { + setNearPrice(coingeckoPrice); + return; + } + + const binancePrice = await fetchPrice( + 'https://api.binance.com/api/v3/ticker/price?symbol=NEARUSDT', + (data) => data.price, + ); + + if (binancePrice !== null) { + setNearPrice(parseFloat(binancePrice)); + } + }; + + if (triggerPriceFetch) { + setFetchAttempts(fetchAttempts + 1); + setTriggerPriceFetch(false); + setPriceWithFallback(); + } + }, [triggerPriceFetch]); const value = { appModal, setAppModal, + fetchAttempts, + nearPrice, + setTriggerPriceFetch, + setNearPrice, }; return {children}; @@ -64,12 +119,11 @@ export const useAppContext = () => { export const openMasterKeyModal = (setAppModal, confirm, cancel) => { setAppModal({ isOpen: true, - header: 'Set your master key!', - message: - 'This key is used to generate the links for all of your drops. Do NOT lose it or forget it!', + header: 'Enter your Keypom password', + message: 'This is used for security purpose. Do not share or lose your password.', inputs: [ { - placeholder: 'Master Key', + placeholder: 'Password', valueKey: 'masterKey', }, ], @@ -84,9 +138,8 @@ export const openMasterKeyModal = (setAppModal, confirm, cancel) => { }, }, { - label: 'Set Master Key', + label: 'Set Password', func: ({ masterKey }) => { - console.log(masterKey); if (!masterKey || masterKey.length === 0) { alert('Master Key must be specified. Please try again.'); if (cancel) cancel(); diff --git a/src/contexts/AuthWalletContext.tsx b/src/contexts/AuthWalletContext.tsx index 7b11c7de..98271214 100644 --- a/src/contexts/AuthWalletContext.tsx +++ b/src/contexts/AuthWalletContext.tsx @@ -105,7 +105,7 @@ export const AuthWalletContextProvider = ({ children }: PropsWithChildren) => { selector: selector as WalletSelector, accounts, accountId, - isLoggedIn: Boolean(sessionStorage.getItem('account')), // selector?.isSignedIn(), with null, cant login. with undefined, cant signout properly + isLoggedIn: Boolean(selector ? selector.isSignedIn() : true), // selector?.isSignedIn(), with null, cant login. with undefined, cant signout properly account: account as Account, }; diff --git a/src/custom.d.ts b/src/custom.d.ts new file mode 100644 index 00000000..a0993ed6 --- /dev/null +++ b/src/custom.d.ts @@ -0,0 +1,4 @@ +declare module '*.png' { + const value: any; + export = value; +} diff --git a/src/data/db.json b/src/data/db.json new file mode 100644 index 00000000..f980c4ef --- /dev/null +++ b/src/data/db.json @@ -0,0 +1,104 @@ +{ + "events": [ + { + "id": 1, + "title": "Alcoholics Anonymous Meeting", + "description": "A funny event desciption yayay", + "location": "Waterloo On, CA", + "date": "2018-11-11", + "tickets": "10", + "price": "12.56", + "img": "img/alcohol.png" + }, + { + "id": 2, + "title": "Paint Night", + "description": "A different event desciption yayay", + "location": "Waterloo On, CA", + "date": "2018-11-11", + "tickets": "10", + "price": "12.34", + "img": "img/paintticket.png" + }, + { + "id": 3, + "title": "Art Show", + "description": "got this description from the db", + "location": "Toronto On, CA", + "date": "2022-11-11", + "tickets": "13", + "price": "43.21", + "img": "img/monalisa.png" + }, + { + "id": 4, + "title": "Basketball Intermurals", + "description": "Another funny event desciption yayay", + "location": "Kitchener On, CA", + "date": "2021-12-12", + "tickets": "15", + "price": "11.13", + "img": "img/basketball.png" + }, + { + "id": 5, + "title": "Ice Hockey Rink Signups", + "description": "A funny event desciption hurray ok", + "location": "Waterloo On, CA", + "date": "2021-10-12", + "tickets": "152", + "price": "42.00", + "img": "img/hockeyticket.png" + }, + { + "id": 6, + "title": "Ballet", + "description": "A Ballet event desciption yayay", + "location": "Alaska, CA", + "date": "2021-10-12", + "tickets": "12", + "price": "3.99", + "img": "img/ballet.png" + }, + { + "id": 7, + "title": "Car Show", + "location": "123 street, CA", + "description": "A Car event desciption yayay", + "date": "2021-10-1", + "tickets": "14", + "price": "19.99", + "img": "img/car.png" + }, + { + "id": 8, + "title": "Friends Meetup", + "description": "A Friends event desciption yayay", + "location": "Waterloo On, CA", + "date": "2021-11-1", + "tickets": "112", + "price": "7.20", + "img": "img/friends.png" + }, + { + "id": 9, + "title": "Hockey Game", + "description": "Hockey game for hockey enjoyers", + "location": "Waterloo On, CA", + "date": "2011-11-1", + "tickets": "111", + "price": "3.60", + "img": "img/hockeygame.png" + }, + { + "id": 10, + "title": "Music Concert", + "description": "Music! Its fun", + "location": "Waterloo On, CA", + "date": "2024-1-1", + "tickets": "1000", + "price": "95.05", + "img": "img/music.png" + } + ] +} diff --git a/src/features/all-drops/components/AllDrops.tsx b/src/features/all-drops/components/AllDrops.tsx index f56a74ed..533cc673 100644 --- a/src/features/all-drops/components/AllDrops.tsx +++ b/src/features/all-drops/components/AllDrops.tsx @@ -1,11 +1,15 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; import { + Input, + Hide, + InputGroup, + InputLeftElement, + Icon, + Button, Badge, Box, - Button, HStack, Menu, - MenuButton, - MenuItem, MenuList, Show, Text, @@ -15,32 +19,40 @@ import { Skeleton, VStack, } from '@chakra-ui/react'; -import { useCallback, useEffect, useRef, useState } from 'react'; import { type ProtocolReturnedDrop } from 'keypom-js'; -import { ChevronDownIcon } from '@chakra-ui/icons'; -import { useSearchParams } from 'react-router-dom'; +import { SearchIcon } from '@chakra-ui/icons'; +import { useNavigate } from 'react-router-dom'; +import { PAGE_SIZE_LIMIT, DROP_TYPE } from '@/constants/common'; import { useAppContext } from '@/contexts/AppContext'; import { useAuthWalletContext } from '@/contexts/AuthWalletContext'; import { type ColumnItem, type DataItem } from '@/components/Table/types'; import { DataTable } from '@/components/Table'; import { DeleteIcon } from '@/components/Icons'; -import { truncateAddress } from '@/utils/truncateAddress'; -import { NextButton, PrevButton } from '@/components/Pagination'; -import { usePagination } from '@/hooks/usePagination'; -import { CLOUDFLARE_IPFS, DROP_TYPE, PAGE_QUERY_PARAM } from '@/constants/common'; import keypomInstance from '@/lib/keypom'; -import { PopoverTemplate } from '@/components/PopoverTemplate'; - -import { MENU_ITEMS } from '../config/menuItems'; +import { + DROP_TYPE_OPTIONS, + DROP_CLAIM_STATUS_OPTIONS, + DATE_FILTER_OPTIONS, + DROP_CLAIM_STATUS_ITEMS, + PAGE_SIZE_ITEMS, + DROP_TYPE_ITEMS, + DATE_FILTER_ITEMS, + CREATE_DROP_ITEMS, + createMenuItems, +} from '../config/menuItems'; + +import { DropDownButton } from './DropDownButton'; import { MobileDrawerMenu } from './MobileDrawerMenu'; import { setConfirmationModalHelper } from './ConfirmationModal'; +import { DropManagerPagination } from './DropManagerPagination'; +import { FilterOptionsMobileButton } from './FilterOptionsMobileButton'; const COLUMNS: ColumnItem[] = [ { id: 'dropName', - title: 'Drop name', + title: 'Name', selector: (drop) => drop.name, thProps: { minW: '240px', @@ -55,7 +67,7 @@ const COLUMNS: ColumnItem[] = [ }, { id: 'dropType', - title: 'Drop type', + title: 'Type', selector: (drop) => drop.type, loadingElement: , }, @@ -77,159 +89,276 @@ const COLUMNS: ColumnItem[] = [ }, ]; -export default function AllDrops() { - const [searchParams, setSearchParams] = useSearchParams(); +interface AllDropsProps { + pageTitle: string; + hasDateFilter: boolean; + ctaButtonLabel: string; +} + +export default function AllDrops({ pageTitle, hasDateFilter, ctaButtonLabel }: AllDropsProps) { const { setAppModal } = useAppContext(); + const navigate = useNavigate(); + + const [numPages, setNumPages] = useState(0); + const [curPage, setCurPage] = useState(0); const { isOpen, onOpen, onClose } = useDisclosure(); const [isLoading, setIsLoading] = useState(true); + const [isAllDropsLoading, setIsAllDropsLoading] = useState(true); const popoverClicked = useRef(0); - const [dataSize, setDataSize] = useState(0); - const [data, setData] = useState>([]); + const [selectedFilters, setSelectedFilters] = useState<{ + type: string; + search: string; + status: string; + date: string; + pageSize: number; + }>({ + type: DROP_TYPE_OPTIONS.ANY, + search: '', + date: DATE_FILTER_OPTIONS.ANY, + status: DROP_CLAIM_STATUS_OPTIONS.ANY, + pageSize: PAGE_SIZE_LIMIT, + }); + const [searchTerm, setSearchTerm] = useState(''); + const [numOwnedDrops, setNumOwnedDrops] = useState(0); + const [filteredDataItems, setFilteredDataItems] = useState([]); const [wallet, setWallet] = useState({}); const { selector, accountId } = useAuthWalletContext(); - const { - setPagination, - hasPagination, - pagination, - isFirstPage, - isLastPage, - loading, - handleNextPage, - handlePrevPage, - } = usePagination({ - dataSize, - handlePrevApiCall: async () => { - const prevPageIndex = pagination.pageIndex - 1; - await handleGetDrops({ - start: prevPageIndex * pagination.pageSize, - limit: pagination.pageSize, - }); - const newQueryParams = new URLSearchParams({ - [PAGE_QUERY_PARAM]: (prevPageIndex + 1).toString(), - }); - setSearchParams(newQueryParams); - }, - handleNextApiCall: async () => { - const nextPageIndex = pagination.pageIndex + 1; - await handleGetDrops({ - start: nextPageIndex * pagination.pageSize, - limit: pagination.pageSize, - }); - const newQueryParams = new URLSearchParams({ - [PAGE_QUERY_PARAM]: (nextPageIndex + 1).toString(), - }); - setSearchParams(newQueryParams); - }, - }); + const handlePageSizeSelect = (item) => { + setSelectedFilters((prevFilters) => ({ + ...prevFilters, + pageSize: parseInt(item.label), + })); + }; - const handleGetDropsSize = async () => { - const numDrops = await keypomInstance.getDropSupplyForOwner({ - accountId, - }); + const handleDropTypeSelect = (item) => { + setSelectedFilters((prevFilters) => ({ + ...prevFilters, + type: item.label, + })); + }; + + const handleDateSelect = (item) => { + setSelectedFilters((prevFilters) => ({ + ...prevFilters, + date: item.label, + })); + }; - setDataSize(numDrops); + const handleSearchChange = (event) => { + const { value } = event.target; + setSearchTerm(value); }; - const setAllDropsData = async (drop: ProtocolReturnedDrop) => { - const { drop_id: id, metadata, next_key_id: totalKeys } = drop; - const claimedKeys = await keypomInstance.getAvailableKeys(id); - const claimedText = `${totalKeys - claimedKeys} / ${totalKeys}`; + const handleDropStatusSelect = (item) => { + setSelectedFilters((prevFilters) => ({ + ...prevFilters, + status: item.label, + })); + }; - const { dropName } = keypomInstance.getDropMetadata(metadata); + const handleNextPage = () => { + setCurPage((prev) => prev + 1); + }; - let type: string | null = ''; - try { - type = keypomInstance.getDropType(drop); - } catch (_) { - type = DROP_TYPE.OTHER; + const handlePrevPage = () => { + setCurPage((prev) => prev - 1); + }; + + const handleKeyDown = (event) => { + if (event.key === 'Enter') { + setSelectedFilters((prevFilters) => ({ + ...prevFilters, + search: searchTerm, + })); } + }; + + const handleFiltering = async (drops) => { + // Apply the selected filters + if (selectedFilters.type !== DROP_TYPE_OPTIONS.ANY) { + drops = drops.filter( + (drop) => + keypomInstance.getDropType(drop).toLowerCase() === selectedFilters.type.toLowerCase(), + ); + } + + if (selectedFilters.status !== DROP_CLAIM_STATUS_OPTIONS.ANY) { + // Convert each drop to a promise that resolves to either the drop or null + const dropsPromises = drops.map(async (drop) => { + const keysLeft = await keypomInstance.getAvailableKeys(drop.drop_id); + const isFullyClaimed = keysLeft === 0; + const isPartiallyClaimed = keysLeft > 0 && keysLeft < drop.next_key_id; + const isUnclaimed = keysLeft === drop.next_key_id; - let nftHref: string | undefined; - if (type === DROP_TYPE.NFT) { - let nftMetadata = { - media: '', - title: '', - description: '', - }; - try { - const fcMethods = drop.fc?.methods; if ( - fcMethods === undefined || - fcMethods.length === 0 || - fcMethods[0] === undefined || - fcMethods[0][0] === undefined + (isFullyClaimed && selectedFilters.status === DROP_CLAIM_STATUS_OPTIONS.FULLY) || + (isPartiallyClaimed && selectedFilters.status === DROP_CLAIM_STATUS_OPTIONS.PARTIALLY) || + (isUnclaimed && selectedFilters.status === DROP_CLAIM_STATUS_OPTIONS.UNCLAIMED) ) { - throw new Error('Unable to retrieve function calls.'); + return drop; } + return null; + }); - const { nftData } = await keypomInstance.getNFTorTokensMetadata( - fcMethods[0][0], - drop.drop_id, - ); + // Wait for all promises to resolve, then filter out the nulls + const resolvedDrops = await Promise.all(dropsPromises); + drops = resolvedDrops.filter((drop): drop is ProtocolReturnedDrop => drop !== null); + } - nftMetadata = { - media: `${CLOUDFLARE_IPFS}/${nftData?.metadata?.media}`, // eslint-disable-line - title: nftData?.metadata?.title, - description: nftData?.metadata?.description, - }; + if (selectedFilters.search.trim() !== '') { + drops = drops.filter((drop) => { + const { dropName } = keypomInstance.getDropMetadata(drop.metadata); + return dropName.toLowerCase().includes(selectedFilters.search.toLowerCase()); + }); + } - console.log(nftData); - } catch (e) { - console.error('failed to get nft metadata', e); // eslint-disable-line no-console - } - nftHref = nftMetadata?.media || 'assets/image-not-found.png'; + if (selectedFilters.date !== DATE_FILTER_OPTIONS.ANY) { + drops = drops + .filter((drop: ProtocolReturnedDrop) => { + try { + const dropMeta = JSON.parse(drop.metadata || '{}'); + const date = new Date(dropMeta.dateCreated); + return dropMeta.dateCreated && !isNaN(date.getTime()); // Ensures dateCreated is valid + } catch (e) { + return false; // Exclude drops with malformed metadata + } + }) + .sort((a, b) => { + // Assuming metadata has been validated, no need for try-catch here + const dateA = new Date(JSON.parse(a.metadata).dateCreated).getTime(); + const dateB = new Date(JSON.parse(b.metadata).dateCreated).getTime(); + return selectedFilters.date === DATE_FILTER_OPTIONS.NEWEST + ? dateB - dateA + : dateA - dateB; + }); } - return { - id, - name: truncateAddress(dropName, 'end', 48), - type: type?.toLowerCase(), - media: nftHref, - claimed: claimedText, - }; + return drops; }; - const handleGetDrops = useCallback( - async ({ start = 0, limit = pagination.pageSize }) => { - const drops = await keypomInstance.getDrops({ accountId, start, limit }); + const handleGetAllDrops = useCallback(async () => { + setIsAllDropsLoading(true); - setWallet(await selector.wallet()); + const drops = await keypomInstance.getAllDrops({ + accountId: accountId!, + }); - setData( - await Promise.all( - drops.map(async (drop) => { - return await setAllDropsData(drop); - }), - ), - ); + const filteredDrops = await handleFiltering(drops); + const dropData = await Promise.all( + filteredDrops.map(async (drop) => await keypomInstance.getDropData({ drop })), + ); + setFilteredDataItems(dropData); + + const totalPages = Math.ceil(filteredDrops.length / selectedFilters.pageSize); + setNumPages(totalPages); + + setCurPage(0); + setIsAllDropsLoading(false); + }, [accountId, selectedFilters, keypomInstance]); + + const handleGetInitialDrops = useCallback(async () => { + setIsLoading(true); + + // First get the total supply of drops so we know when to stop fetching + const totalSupply = await keypomInstance.getDropSupplyForOwner({ accountId: accountId! }); + setNumOwnedDrops(totalSupply); + + // Loop until we have enough filtered drops to fill the page size + let dropsFetched = 0; + let filteredDrops: ProtocolReturnedDrop[] = []; + while (dropsFetched < totalSupply && filteredDrops.length < selectedFilters.pageSize) { + const drops = await keypomInstance.getPaginatedDrops({ + accountId: accountId!, + start: dropsFetched, + limit: selectedFilters.pageSize, + }); + dropsFetched += drops.length; - setIsLoading(false); - }, - [pagination], - ); + const curFiltered = await handleFiltering(drops); + filteredDrops = filteredDrops.concat(curFiltered); + } + + // Now, map over the filtered drops and set the data + const dropData = await Promise.all( + filteredDrops.map(async (drop) => await keypomInstance.getDropData({ drop })), + ); + + if (filteredDataItems.length === 0) { + setFilteredDataItems(dropData); + } + setCurPage(0); + setIsLoading(false); + }, [accountId, selectedFilters, keypomInstance]); + + useEffect(() => { + async function fetchWallet() { + if (!selector) return; + try { + const wallet = await selector.wallet(); + setWallet(wallet); + } catch (error) { + console.error('Error fetching wallet:', error); + // Handle the error appropriately + } + } + + fetchWallet(); + }, [selector]); + + useEffect(() => { + if (!accountId) return; + + // First get enough data with the current filters to fill the page size + handleGetInitialDrops(); + }, [accountId]); useEffect(() => { if (!accountId) return; - // page query param should be indexed from 1 - const pageQuery = searchParams.get('page'); - const currentPageIndex = pageQuery !== null ? parseInt(pageQuery) - 1 : 0; - setPagination((pagination) => ({ ...pagination, pageIndex: currentPageIndex })); - handleGetDropsSize(); - handleGetDrops({ start: currentPageIndex * pagination.pageSize }); - }, [accountId, searchParams]); - - const dropMenuItems = MENU_ITEMS.map((item) => ( - - {item.label} - - )); - - const handleDeleteClick = (dropId) => { + // In parallel, fetch all the drops + handleGetAllDrops(); + }, [accountId, selectedFilters]); + + const createDropMenuItems = createMenuItems({ + menuItems: CREATE_DROP_ITEMS, + onClick: (item) => { + navigate(item.label.includes('NFT') ? '/drop/nft/new' : '/drop/token/new'); + }, + }); + + const pageSizeMenuItems = createMenuItems({ + menuItems: PAGE_SIZE_ITEMS, + onClick: (item) => { + handlePageSizeSelect(item); + }, + }); + + const filterDropMenuItems = createMenuItems({ + menuItems: DROP_TYPE_ITEMS, + onClick: (item) => { + handleDropTypeSelect(item); + }, + }); + + const filterDataMenuItems = createMenuItems({ + menuItems: DATE_FILTER_ITEMS, + onClick: (item) => { + handleDateSelect(item); + }, + }); + + const dropStatusMenuItems = createMenuItems({ + menuItems: DROP_CLAIM_STATUS_ITEMS, + onClick: (item) => { + handleDropStatusSelect(item); + }, + }); + + const handleDeleteClick = (dropId: string | number) => { setConfirmationModalHelper(setAppModal, async () => { await keypomInstance.deleteDrops({ wallet, @@ -240,163 +369,230 @@ export default function AllDrops() { }; const getTableRows = (): DataItem[] => { - if (data === undefined || data.length === 0) return []; - - return data.reduce((result: DataItem[], drop) => { - if (drop !== null) { - // show token drop manager for other drops type - const dropType = - (drop.type as string).toUpperCase() === DROP_TYPE.OTHER ? DROP_TYPE.TOKEN : drop.type; - const dataItem = { - ...drop, - name: {drop.name}, - type: ( - - {drop.type} - - ), - media: drop.media !== undefined && , - claimed: {drop.claimed} Claimed, - action: ( - - ), - href: `/drop/${(dropType as string).toLowerCase()}/${drop.id}`, - }; - return [...result, dataItem]; - } - return result; - }, []); + if (filteredDataItems === undefined || filteredDataItems.length === 0) return []; + + return filteredDataItems + .slice(curPage * selectedFilters.pageSize, (curPage + 1) * selectedFilters.pageSize) + .reduce((result: DataItem[], drop) => { + if (drop !== null) { + // show token drop manager for other drops type + const dropType = + (drop.type as string).toUpperCase() === DROP_TYPE.OTHER ? DROP_TYPE.TOKEN : drop.type; + const dataItem = { + ...drop, + name: ( + + {drop.name} + + ), + type: ( + + {drop.type} + + ), + media: drop.media !== undefined && , + claimed: {drop.claimed} Claimed, + action: ( + + ), + href: `/drop/${(dropType as string).toLowerCase()}/${drop.id}`, + }; + return [...result, dataItem]; + } + return result; + }, []); }; - const createADropPopover = (menuIsOpen: boolean) => ({ - header: 'Click here to create a drop!', - shouldOpen: - !isLoading && - popoverClicked.current === 0 && - !hasPagination && - data.length === 0 && - !menuIsOpen, - }); - - const CreateADropButton = ({ isOpen }: { isOpen: boolean }) => ( - - } - variant="secondary-content-box" - onClick={() => (popoverClicked.current += 1)} - > - Create a drop - - - ); - const CreateADropMobileButton = () => ( - - - - ); + const getTableType = () => { + if (filteredDataItems.length === 0 && numOwnedDrops === 0) { + return 'all-drops'; + } + return 'no-filtered-drops'; + }; return ( - {/* Header Bar */} - {/* Desktop Dropdown Menu */} - + {/* Desktop Menu */} + + {pageTitle} - All drops - - {hasPagination && ( - + + + + + - )} - - {({ isOpen }) => ( - - - {dropMenuItems} - + + + + {({ isOpen }) => ( + + (popoverClicked.current += 1)} + /> + {filterDropMenuItems} + + )} + + {hasDateFilter && ( + + {({ isOpen }) => ( + + (popoverClicked.current += 1)} + /> + {filterDataMenuItems} + + )} + )} - - - {hasPagination && ( - - )} + + {({ isOpen }) => ( + + (popoverClicked.current += 1)} + /> + {dropStatusMenuItems} + + )} + + + {({ isOpen }) => ( + + (popoverClicked.current += 1)} + /> + {createDropMenuItems} + + )} + + - {/* Mobile Menu Button */} - + {/* Mobile Menu */} + All drops - - - {hasPagination && ( - - - - - )} + + + + {({ isOpen }) => ( + + (popoverClicked.current += 1)} + /> + {createDropMenuItems} + + )} + - + - {/* Mobile Menu For Creating Drop */} - - - + (popoverClicked.current += 1)} + /> + + {/* Mobile Popup Menu For Filtering */} + ); } diff --git a/src/features/all-drops/components/AllEvents.tsx b/src/features/all-drops/components/AllEvents.tsx new file mode 100644 index 00000000..80dd7bd7 --- /dev/null +++ b/src/features/all-drops/components/AllEvents.tsx @@ -0,0 +1,514 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { + Input, + Hide, + InputGroup, + InputLeftElement, + Icon, + Button, + Badge, + Box, + HStack, + Menu, + MenuList, + Show, + Text, + useDisclosure, + Heading, + Skeleton, + VStack, + Image, +} from '@chakra-ui/react'; +import { SearchIcon } from '@chakra-ui/icons'; +import { useNavigate } from 'react-router-dom'; +import { type Wallet } from '@near-wallet-selector/core'; + +import keypomInstance from '@/lib/keypom'; +import { PAGE_SIZE_LIMIT } from '@/constants/common'; +import { useAppContext } from '@/contexts/AppContext'; +import { useAuthWalletContext } from '@/contexts/AuthWalletContext'; +import { type ColumnItem, type DataItem } from '@/components/Table/types'; +import { DataTable } from '@/components/Table'; +import { type FunderEventMetadata } from '@/lib/eventsHelpers'; +import { truncateAddress } from '@/utils/truncateAddress'; +import { ShareIcon } from '@/components/Icons/ShareIcon'; +import { DeleteIcon } from '@/components/Icons'; +import useDeletion from '@/components/AppModal/useDeletion'; +import { performDeletionLogic } from '@/components/AppModal/PerformDeletion'; + +import { + DROP_TYPE_OPTIONS, + DROP_CLAIM_STATUS_OPTIONS, + DATE_FILTER_OPTIONS, + PAGE_SIZE_ITEMS, + DATE_FILTER_ITEMS, + createMenuItems, +} from '../config/menuItems'; + +import { DropDownButton } from './DropDownButton'; +import { MobileDrawerMenu } from './MobileDrawerMenu'; +import { DropManagerPagination } from './DropManagerPagination'; +import { FilterOptionsMobileButton } from './FilterOptionsMobileButton'; + +const COLUMNS: ColumnItem[] = [ + { + id: 'dropName', + title: 'Name', + selector: (drop) => drop.name, + thProps: { + minW: '240px', + }, + loadingElement: , + }, + { + id: 'dateCreated', + title: 'Date created', + selector: (drop) => drop.dateCreated, + loadingElement: , + }, + { + id: 'description', + title: 'Description', + selector: (drop) => drop.description, + loadingElement: , + }, + { + id: 'action', + title: '', + selector: (drop) => drop.action, + loadingElement: , + }, +]; + +interface AllDropsProps { + pageTitle: string; + hasDateFilter: boolean; + ctaButtonLabel: string; +} + +export default function AllEvents({ pageTitle, hasDateFilter, ctaButtonLabel }: AllDropsProps) { + const { setAppModal } = useAppContext(); + const navigate = useNavigate(); + + const [numPages, setNumPages] = useState(0); + const [curPage, setCurPage] = useState(0); + + const { isOpen, onOpen, onClose } = useDisclosure(); + const [isLoading, setIsLoading] = useState(true); + const popoverClicked = useRef(0); + + const [selectedFilters, setSelectedFilters] = useState<{ + type: string; + search: string; + status: string; + date: string; + pageSize: number; + }>({ + type: DROP_TYPE_OPTIONS.ANY, + search: '', + date: DATE_FILTER_OPTIONS.ANY, + status: DROP_CLAIM_STATUS_OPTIONS.ANY, + pageSize: PAGE_SIZE_LIMIT, + }); + const [searchTerm, setSearchTerm] = useState(''); + const [numOwnedEvents, setNumOwnedEvents] = useState(0); + const [filteredDataItems, setFilteredDataItems] = useState([]); + const [wallet, setWallet] = useState(); + + const { selector, accountId } = useAuthWalletContext(); + + const formatDate = (date) => { + // Create an instance of Intl.DateTimeFormat for formatting + const formatter = new Intl.DateTimeFormat('en-US', { + month: 'short', // Full month name. + day: 'numeric', // Numeric day of the month. + year: 'numeric', // Numeric full year. + hour: 'numeric', // Numeric hour. + minute: 'numeric', // Numeric minute. + hour12: true, // Use 12-hour time. + }); + return formatter.format(date); + }; + + const handlePageSizeSelect = (item) => { + setSelectedFilters((prevFilters) => ({ + ...prevFilters, + pageSize: parseInt(item.label), + })); + }; + + const handleDateSelect = (item) => { + setSelectedFilters((prevFilters) => ({ + ...prevFilters, + date: item.label, + })); + }; + + const handleSearchChange = (event) => { + const { value } = event.target; + setSearchTerm(value); + }; + + const handleNextPage = () => { + setCurPage((prev) => prev + 1); + }; + + const handlePrevPage = () => { + setCurPage((prev) => prev - 1); + }; + + const handleKeyDown = (event) => { + if (event.key === 'Enter') { + setSelectedFilters((prevFilters) => ({ + ...prevFilters, + search: searchTerm, + })); + } + }; + + const handleFiltering = async (events: FunderEventMetadata[]) => { + if (selectedFilters.search.trim() !== '') { + events = events.filter((event) => { + return ( + event.name.toLowerCase().includes(selectedFilters.search.toLowerCase()) || + event.description.toLowerCase().includes(selectedFilters.search.toLowerCase()) + ); + }); + } + + if (selectedFilters.date !== DATE_FILTER_OPTIONS.ANY) { + events = events + .filter((event) => { + try { + const date = new Date(parseInt(event.dateCreated)); + return date && !isNaN(date.getTime()); // Ensures dateCreated is valid + } catch (e) { + return false; // Exclude drops with malformed metadata + } + }) + .sort((a, b) => { + // Assuming metadata has been validated, no need for try-catch here + const dateA = new Date(parseInt(a.dateCreated)).getTime(); + const dateB = new Date(parseInt(b.dateCreated)).getTime(); + return selectedFilters.date === DATE_FILTER_OPTIONS.NEWEST + ? dateB - dateA + : dateA - dateB; + }); + } + + return events; + }; + + const handleGetAllEvents = useCallback(async () => { + setIsLoading(true); + let eventDrops: FunderEventMetadata[] = []; + try { + eventDrops = await keypomInstance.getEventsForAccount({ + accountId: accountId!, + }); + } catch (e) { + console.error('Error fetching events:', e); + } + + const numEvents = eventDrops.length; + setNumOwnedEvents(numEvents); + + const filteredEvents = await handleFiltering(eventDrops); + const dropData = filteredEvents.map((event: FunderEventMetadata) => { + return { + id: event.id, + name: truncateAddress(event.name || 'Untitled', 'end', 48), + media: event.artwork, + dateCreated: formatDate(new Date(parseInt(event.dateCreated))), // Ensure drop has dateCreated or adjust accordingly + description: truncateAddress(event.description, 'end', 12), + eventId: event.id, + }; + }); + + setFilteredDataItems(dropData); + + const totalPages = Math.ceil(filteredEvents.length / selectedFilters.pageSize); + setNumPages(totalPages); + + setCurPage(0); + setIsLoading(false); + }, [accountId, selectedFilters, keypomInstance]); + + useEffect(() => { + async function fetchWallet() { + if (!selector) return; + try { + const wallet = await selector.wallet(); + setWallet(wallet); + } catch (error) { + console.error('Error fetching wallet:', error); + // Handle the error appropriately + } + } + + fetchWallet(); + }, [selector]); + + useEffect(() => { + if (!accountId) return; + + // In parallel, fetch all the drops + handleGetAllEvents(); + }, [accountId, selectedFilters]); + + const pageSizeMenuItems = createMenuItems({ + menuItems: PAGE_SIZE_ITEMS, + onClick: (item) => { + handlePageSizeSelect(item); + }, + }); + + const filterDataMenuItems = createMenuItems({ + menuItems: DATE_FILTER_ITEMS, + onClick: (item) => { + handleDateSelect(item); + }, + }); + + const { openConfirmationModal } = useDeletion({ + setAppModal, + }); + + const handleDeleteClick = async (eventId: string) => { + if (!wallet || !eventId) return; + + const ticketData = await keypomInstance.getTicketsForEventId({ + accountId: accountId!, + eventId, + }); + + const deletionArgs = { + wallet, + accountId, + navigate, + eventId, + ticketData, + deleteAll: true, + setAppModal, + }; + + // Open the confirmation modal with customization if needed + openConfirmationModal( + deletionArgs, + 'Are you sure you want to delete this event and all its tickets? This action cannot be undone.', + performDeletionLogic, + ); + }; + + const getTableRows = (): DataItem[] => { + if (filteredDataItems === undefined || filteredDataItems.length === 0) return []; + + const data = filteredDataItems + .slice(curPage * selectedFilters.pageSize, (curPage + 1) * selectedFilters.pageSize) + .reduce((result: DataItem[], drop) => { + if (drop !== null) { + // show token drop manager for other drops type + const dataItem = { + ...drop, + name: ( + + {`Event + + {drop.name} + + + ), + type: ( + + {drop.type} + + ), + dateCreated: drop.dateCreated, + numTickets: drop.numTickets, + claimed: {drop.claimed} Claimed, + action: ( + + + + + ), + href: `/events/event/${((drop.eventId as string) || '').toString()}`, + }; + return [...result, dataItem]; + } + return result; + }, []); + return data; + }; + + const getTableType = () => { + if (filteredDataItems.length === 0 && numOwnedEvents === 0) { + return 'all-events'; + } + return 'no-filtered-events'; + }; + + return ( + + {/* Desktop Menu */} + + {pageTitle} + + + + + + + + + + + {({ isOpen }) => ( + + (popoverClicked.current += 1)} + /> + {filterDataMenuItems} + + )} + + + + + + + + {/* Mobile Menu */} + + + + My Events + + + + + + + + + + + + (popoverClicked.current += 1)} + /> + + {/* Mobile Popup Menu For Filtering */} + + + ); +} diff --git a/src/features/all-drops/components/DropDownButton.tsx b/src/features/all-drops/components/DropDownButton.tsx new file mode 100644 index 00000000..8ae5c978 --- /dev/null +++ b/src/features/all-drops/components/DropDownButton.tsx @@ -0,0 +1,29 @@ +import { ChevronDownIcon } from '@chakra-ui/icons'; +import { Button, MenuButton } from '@chakra-ui/react'; + +export const DropDownButton = ({ + isOpen, + placeholder, + variant, + onClick, +}: { + isOpen: boolean; + placeholder: string; + variant: 'primary' | 'secondary'; + onClick: () => void; +}) => ( + } + variant={variant} + onClick={onClick} + > + {placeholder} + +); diff --git a/src/features/all-drops/components/DropManagerPagination.tsx b/src/features/all-drops/components/DropManagerPagination.tsx new file mode 100644 index 00000000..42f4381d --- /dev/null +++ b/src/features/all-drops/components/DropManagerPagination.tsx @@ -0,0 +1,88 @@ +import { Box, HStack, Menu, MenuList, Show, Heading, Skeleton } from '@chakra-ui/react'; +import { type ReactElement } from 'react'; + +import { NextButton, PrevButton } from '@/components/Pagination'; + +import { DropDownButton } from './DropDownButton'; + +interface DropManagerPaginationProps { + isLoading: boolean; + pageSizeMenuItems: ReactElement[]; + onClickRowsSelect: () => void; + rowsSelectPlaceholder: string; + curPage: number; + numPages: number; + handleNextPage: () => void; + handlePrevPage: () => void; + type?: string; +} + +export const DropManagerPagination = ({ + isLoading, + pageSizeMenuItems, + onClickRowsSelect, + rowsSelectPlaceholder, + curPage, + numPages, + handleNextPage, + handlePrevPage, + type = 'Rows', +}: DropManagerPaginationProps) => { + if (isLoading) { + // Render Skeleton loaders while content is loading + return ( + + + + + ); + } + return ( + + + + + {type} per page + + + + + {type} + + + + {({ isOpen }) => ( + + + {pageSizeMenuItems} + + )} + + + + + {curPage + 1} of {numPages === 0 ? 1 : numPages} + + + + + + ); +}; diff --git a/src/features/all-drops/components/FilterOptionsMobileButton.tsx b/src/features/all-drops/components/FilterOptionsMobileButton.tsx new file mode 100644 index 00000000..6d356771 --- /dev/null +++ b/src/features/all-drops/components/FilterOptionsMobileButton.tsx @@ -0,0 +1,26 @@ +import { ChevronDownIcon } from '@chakra-ui/icons'; +import { Button } from '@chakra-ui/react'; + +export const FilterOptionsMobileButton = ({ + buttonTitle, + onOpen, + popoverClicked, +}: { + buttonTitle: string; + onOpen: () => void; + popoverClicked: React.MutableRefObject; +}) => ( + +); diff --git a/src/features/all-drops/components/MobileDrawerMenu.tsx b/src/features/all-drops/components/MobileDrawerMenu.tsx index 8e3f1363..e0226143 100644 --- a/src/features/all-drops/components/MobileDrawerMenu.tsx +++ b/src/features/all-drops/components/MobileDrawerMenu.tsx @@ -1,3 +1,4 @@ +import type React from 'react'; import { Button, Drawer, @@ -6,53 +7,84 @@ import { DrawerContent, DrawerHeader, DrawerOverlay, + Input, + InputGroup, + InputLeftElement, + Icon, VStack, + Menu, + MenuButton, + MenuList, } from '@chakra-ui/react'; +import { SearchIcon } from '@chakra-ui/icons'; -import { MENU_ITEMS } from '../config/menuItems'; +interface FilterConfig { + label: string; + value: string; + menuItems: JSX.Element[]; +} -export interface MobileDrawerMenuProps { +interface MobileDrawerMenuProps { isOpen: boolean; onClose: () => void; + searchTerm?: string; + handleSearchChange?: (e: React.ChangeEvent) => void; + handleKeyDown?: (event: React.KeyboardEvent) => void; + filters: FilterConfig[]; + title: string; + customButton?: React.ReactNode; // Optional custom button } -export const MobileDrawerMenu = ({ isOpen, onClose }: MobileDrawerMenuProps) => { - const getMobileDrawerMenuItems = () => - MENU_ITEMS.map((item) => ( - - )); +export const MobileDrawerMenu = ({ + isOpen, + onClose, + searchTerm, + handleSearchChange, + handleKeyDown, + filters, + title, + customButton, // Destructure the customButton prop +}: MobileDrawerMenuProps) => { return ( - Create a drop + {title} - {getMobileDrawerMenuItems()} + + {searchTerm !== undefined && handleSearchChange && ( + + + + + + + )} + {filters.map((filter) => ( + + + {filter.label}: {filter.value} + + {filter.menuItems} + + ))} + {/* Optionally render the custom button if provided */} + {customButton} + diff --git a/src/features/all-drops/config/menuItems.tsx b/src/features/all-drops/config/menuItems.tsx index 4ee2ce16..1d370c07 100644 --- a/src/features/all-drops/config/menuItems.tsx +++ b/src/features/all-drops/config/menuItems.tsx @@ -1,17 +1,57 @@ -import { LinkIcon, NFTIcon } from '@/components/Icons'; +import { MenuItem } from '@chakra-ui/react'; + +import { LinkIcon, NFTIcon, TicketIcon, CheckedIcon } from '@/components/Icons'; import { type MenuItemProps } from '@/components/Menu'; +import { FilterIcon } from '@/components/Icons/FilterIcon'; + +export const DROP_TYPE_OPTIONS = { + ANY: 'Any', + TOKEN: 'Token', + NFT: 'NFT', + EVENT: 'Event', +}; + +export const DROP_CLAIM_STATUS_OPTIONS = { + ANY: 'Any', + FULLY: 'Fully', + PARTIALLY: 'Partially', + UNCLAIMED: 'Unclaimed', +}; + +export const KEY_CLAIM_STATUS_OPTIONS = { + ANY: 'Any', + CLAIMED: 'Claimed', + UNCLAIMED: 'Unclaimed', +}; + +export const TICKET_CLAIM_STATUS_OPTIONS = { + ANY: 'Any', + PURCHASED: 'Purchased', + SCANNED: 'Scanned', +}; + +export const DATE_FILTER_OPTIONS = { + ANY: 'Any', + NEWEST: 'Newest', + OLDEST: 'Oldest', +}; -export const MENU_ITEMS: MenuItemProps[] = [ +export const createMenuItems = ({ menuItems, onClick }) => { + return menuItems.map((item) => ( + onClick(item)} {...item}> + {item.label} + + )); +}; +export const CREATE_DROP_ITEMS: MenuItemProps[] = [ { label: 'Token Drop', as: 'a', - href: '/drop/token/new', icon: , }, { label: 'NFT Drop', as: 'a', - href: '/drop/nft/new', icon: , }, // { @@ -21,3 +61,165 @@ export const MENU_ITEMS: MenuItemProps[] = [ // icon: , // }, ]; + +export const DATE_FILTER_ITEMS: MenuItemProps[] = [ + { + label: 'Any', + icon: , + }, + { + label: 'Newest', + icon: , + }, + { + label: 'Oldest', + icon: , + }, +]; + +export const DROP_TYPE_ITEMS: MenuItemProps[] = [ + { + label: 'Any', + icon: , + }, + { + label: 'Token', + icon: , + }, + { + label: 'NFT', + icon: , + }, + { + label: 'Event', + icon: , + }, +]; + +export const GALLERY_PRICE_ITEMS: MenuItemProps[] = [ + { + label: 'Any', + }, + { + label: '<20', + }, + { + label: '20-50', + }, + { + label: '50-100', + }, + { + label: '100+', + }, +]; + +export const DROP_CLAIM_STATUS_ITEMS: MenuItemProps[] = [ + { + label: 'Any', + icon: , + }, + { + label: 'Fully', + color: 'green.600', + }, + { + label: 'Partially', + color: 'yellow.600', + }, + { + label: 'Unclaimed', + color: 'gray.600', + }, +]; + +export const KEY_CLAIM_STATUS_ITEMS: MenuItemProps[] = [ + { + label: 'Any', + icon: , + }, + { + label: 'Claimed', + color: 'green.600', + }, + { + label: 'Unclaimed', + color: 'gray.600', + }, +]; + +export const TICKET_CLAIM_STATUS_ITEMS: MenuItemProps[] = [ + { + label: 'Any', + icon: , + }, + { + label: 'Purchased', + color: 'gray.600', + }, + { + label: 'Scanned', + color: 'green.600', + }, +]; + +export const PAGE_SIZE_ITEMS: MenuItemProps[] = [ + { + label: '5', + color: 'gray.600', + }, + { + label: '10', + color: 'gray.600', + }, + { + label: '15', + color: 'gray.600', + }, + { + label: '20', + color: 'gray.600', + }, + { + label: '50', + color: 'gray.600', + }, +]; + +export const GALLERY_PAGE_SIZE_ITEMS: MenuItemProps[] = [ + { + label: '6', + color: 'gray.600', + }, + { + label: '12', + color: 'gray.600', + }, + { + label: '18', + color: 'gray.600', + }, + { + label: '24', + color: 'gray.600', + }, + { + label: '60', + color: 'gray.600', + }, +]; + +export const SORT_MENU_ITEMS: MenuItemProps[] = [ + { + label: 'no sort', + // color: 'gray.600', + }, + { + label: 'ascending', + // color: 'gray.600', + }, + { + label: 'descending', + // color: 'gray.600', + }, +]; diff --git a/src/features/all-drops/routes/AllDropsPage.tsx b/src/features/all-drops/routes/AllDropsPage.tsx index 06bd3b54..3af90dcf 100644 --- a/src/features/all-drops/routes/AllDropsPage.tsx +++ b/src/features/all-drops/routes/AllDropsPage.tsx @@ -5,7 +5,7 @@ import AllDrops from '../components/AllDrops'; export default function AllDropsPage() { return ( - + ); } diff --git a/src/features/all-drops/routes/AllEventsPage.tsx b/src/features/all-drops/routes/AllEventsPage.tsx new file mode 100644 index 00000000..2df73e55 --- /dev/null +++ b/src/features/all-drops/routes/AllEventsPage.tsx @@ -0,0 +1,11 @@ +import { Box } from '@chakra-ui/react'; + +import AllEvents from '../components/AllEvents'; + +export default function AllEventsPage() { + return ( + + + + ); +} diff --git a/src/features/claim/components/TokenNFTClaim.tsx b/src/features/claim/components/TokenNFTClaim.tsx index 115ff9fe..0cb035a7 100644 --- a/src/features/claim/components/TokenNFTClaim.tsx +++ b/src/features/claim/components/TokenNFTClaim.tsx @@ -108,7 +108,6 @@ export const TokenNFTClaim = ({ label: 'Ok', func: () => { setAppModal({ isOpen: false }); - console.log('tx acknowledged'); }, }, ], diff --git a/src/features/claim/components/ticket/QrDetails.tsx b/src/features/claim/components/ticket/QrDetails.tsx index 97dcf435..9c5e3715 100644 --- a/src/features/claim/components/ticket/QrDetails.tsx +++ b/src/features/claim/components/ticket/QrDetails.tsx @@ -1,12 +1,28 @@ -import { Box, Button, Flex, Text } from '@chakra-ui/react'; +import { Box, Button, Flex, Heading, Text, VStack } from '@chakra-ui/react'; import QRCode from 'react-qr-code'; +import { useNavigate } from 'react-router-dom'; + +import { type TicketMetadataExtra } from '@/lib/eventsHelpers'; +import { dateAndTimeToText } from '@/features/drop-manager/utils/parseDates'; interface QrDetailsProps { qrValue: string; ticketName: string; + eventName: string; + eventId: string; + funderId: string; + ticketInfoExtra?: TicketMetadataExtra; } -export const QrDetails = ({ qrValue, ticketName }: QrDetailsProps) => { +export const QrDetails = ({ + qrValue, + ticketName, + eventName, + eventId, + funderId, + ticketInfoExtra, +}: QrDetailsProps) => { + const navigate = useNavigate(); const handleDownloadQrCode = () => { const svg = document.getElementById('QRCode'); @@ -50,21 +66,40 @@ export const QrDetails = ({ qrValue, ticketName }: QrDetailsProps) => { > - - {ticketName} + + {eventName} - + + Save this QR code and show it at the event to gain entry. - + + + + + + Can be sold through: + + + {ticketInfoExtra && dateAndTimeToText(ticketInfoExtra?.salesValidThrough)} + + + ); }; diff --git a/src/features/claim/components/ticket/TicketQRPage.tsx b/src/features/claim/components/ticket/TicketQRPage.tsx deleted file mode 100644 index c49e0601..00000000 --- a/src/features/claim/components/ticket/TicketQRPage.tsx +++ /dev/null @@ -1,125 +0,0 @@ -import { Box, Button, Center, Flex, Heading, Hide, Text, VStack } from '@chakra-ui/react'; -import { useEffect, useState } from 'react'; -import { getKeyInformation } from 'keypom-js'; - -import { IconBox } from '@/components/IconBox'; -import { TicketIcon } from '@/components/Icons'; -import { ErrorBox } from '@/components/ErrorBox'; -import { useTicketClaim } from '@/features/claim/contexts/TicketClaimContext'; -import { useClaimParams } from '@/hooks/useClaimParams'; -import { BoxWithShape } from '@/components/BoxWithShape'; -import { QrDetails } from '@/features/claim/components/ticket/QrDetails'; -import { DROP_TYPE } from '@/constants/common'; -import { DropClaimMetadata } from '@/features/claim/components/DropClaimMetadata'; -import { AvatarImage } from '@/components/AvatarImage'; -import { DropBox } from '@/components/DropBox'; - -export const TicketQRPage = () => { - const { secretKey } = useClaimParams(); - const [claimAttempted, setClaimAttempted] = useState(false); - const { handleClaim, qrValue, claimError, getDropMetadata } = useTicketClaim(); - const [isShowSummary, setShowSummary] = useState(false); - - const { giftType, title, tokens, nftImage } = getDropMetadata(); - - const checkClaim = async () => { - const keyInfo = await getKeyInformation({ secretKey }); - console.log('claiming', claimAttempted, keyInfo.cur_key_use); - if (!claimAttempted && keyInfo.cur_key_use === 1) { - // do not await since it will only prevent user from seeing QR code, we can always show error after - await handleClaim(); - } - setClaimAttempted(true); - }; - - useEffect(() => { - checkClaim(); - }, []); - - if (claimError) { - return ; - } - - return ( -
- - - {isShowSummary ? 'Your ticket' : 'Claim your ticket'} - - - } - maxW={{ base: '345px', md: '30rem' }} - minW={{ base: 'inherit', md: '345px' }} - p="0" - pb="0" - > - {isShowSummary ? ( - - - - - - - Attendance gifts - - - {giftType === DROP_TYPE.NFT ? ( - <> - - - - {title} - - - - ) : ( - - {tokens.map(({ icon, value, symbol }, index) => ( - - ))} - - )} - - - Return here after checking into the event to claim. - - - - ) : ( - - - - - )} - - -
- ); -}; diff --git a/src/features/claim/contexts/TicketClaimContext.tsx b/src/features/claim/contexts/TicketClaimContext.tsx deleted file mode 100644 index 8f3ac366..00000000 --- a/src/features/claim/contexts/TicketClaimContext.tsx +++ /dev/null @@ -1,204 +0,0 @@ -import { - createContext, - type PropsWithChildren, - useContext, - useState, - useEffect, - lazy, -} from 'react'; -import { useNavigate } from 'react-router-dom'; - -import keypomInstance from '@/lib/keypom'; -import { useClaimParams } from '@/hooks/useClaimParams'; -import { DROP_TYPE, type DROP_TYPES } from '@/constants/common'; -import getConfig from '@/config/config'; -import { type TokenAsset } from '@/types/common'; - -const TicketQRPage = lazy( - async () => - await import('@/features/claim/components/ticket/TicketQRPage').then((mod) => ({ - default: mod.TicketQRPage, - })), -); - -const TicketGiftPage = lazy( - async () => await import('@/features/claim/components/ticket/TicketGiftPage'), -); - -const TICKET_FLOW_KEY_USE = { - 1: TicketQRPage, - 2: TicketQRPage, - 3: TicketGiftPage, -}; - -export interface TicketClaimContextTypes { - getDropMetadata: () => { - title: string; - description: string; - nftImage: string; - tokens: TokenAsset[]; - giftType: DROP_TYPES; - wallets: string[]; - }; - currentPage: (() => JSX.Element | null) | undefined; - qrValue: string; - handleClaim: () => Promise; - isClaimInfoLoading: boolean; - claimInfoError: string | null; - claimError: string | null; -} - -const TicketClaimContext = createContext(null); -// TODO: check if drop has been claimed -/** - * - * Context to manage whole ticket claim flow for each key uses - */ -export const TicketClaimContextProvider = ({ children }: PropsWithChildren) => { - const navigate = useNavigate(); - const { secretKey, contractId } = useClaimParams(); - - // Drop metadata - const [title, setTitle] = useState(''); - const [description, setDescription] = useState(''); - const [nftImage, setNftImage] = useState(''); - const [wallets, setWallets] = useState([getConfig().defaultWallet.name]); - - // QR code - const [qrValue, setQrValue] = useState(''); - - // Claim info states - const [currentPage, setCurrentPage] = useState<() => JSX.Element | null>(); - const [isClaimInfoLoading, setIsLoading] = useState(true); - const [claimInfoError, setClaimInfoError] = useState(null); - const [claimError, setClaimError] = useState(null); - - const [currentKeyUse, setCurrentKeyUse] = useState(null); - const [tokens, setTokens] = useState([]); - const [giftType, setGiftType] = useState(DROP_TYPE.NFT); - - const loadTokenClaimInfo = async () => { - try { - const { ftMetadata, amountNEAR, amountTokens } = - await keypomInstance.getTokenClaimInformation(contractId, secretKey); - const tokens: TokenAsset[] = [ - { - icon: 'https://cryptologos.cc/logos/near-protocol-near-logo.svg?v=024', - value: amountNEAR || '0', - symbol: 'NEAR', - }, - ]; - setTokens({ - ...tokens, - ...(ftMetadata - ? { - icon: ftMetadata.icon as string, - value: amountTokens ?? '0', - symbol: ftMetadata.symbol, - } - : {}), - }); - setGiftType(DROP_TYPE.TOKEN); - } catch (err) { - setClaimInfoError(err.message); - } - }; - - const loadNFTClaimInfo = async () => { - const claimInfo = await keypomInstance.getTicketNftInformation(contractId, secretKey); - - setTitle(claimInfo.title); - setDescription(claimInfo.description); - setNftImage(claimInfo.media); - setQrValue(JSON.stringify({ contractId, secretKey })); - setWallets(claimInfo.wallets); - }; - - const loadTicketClaimInfo = async () => { - // determine if ticket has NFT series else defer to show token as reward - setIsLoading(true); - - try { - await loadNFTClaimInfo(); - } catch (err) { - if (err.message === 'NFT series not found') { - // show tokens instead - await loadTokenClaimInfo(); - } else { - console.error(err.message); - setClaimInfoError(err.message); - } - } - - setIsLoading(false); - }; - - const showCurrentPage = async () => { - if (claimInfoError) { - // if key has been deleted or has errors, we show the error in main page - return; - } - - const _currentKeyUse = await keypomInstance.getCurrentKeyUse(contractId, secretKey); - setCurrentPage(TICKET_FLOW_KEY_USE[_currentKeyUse]); - setCurrentKeyUse(_currentKeyUse); - }; - - useEffect(() => { - if (secretKey === '' || contractId === '') { - navigate('/'); - } - // eslint-disable-next-line - loadTicketClaimInfo().then(async () => { - await showCurrentPage(); - }); - }, []); - - const getDropMetadata = () => { - return { - title, - description, - nftImage, - tokens, - giftType, - wallets, - }; - }; - - const handleClaim = async () => { - // Only allow claiming when there are 3 remaining uses - if (currentKeyUse === 1) { - try { - await keypomInstance.claim(secretKey, 'foo', true); - } catch (err) { - setClaimError(err); - } - } - }; - - return ( - - {children} - - ); -}; - -export const useTicketClaim = (): TicketClaimContextTypes => { - const context = useContext(TicketClaimContext); - - if (context === null) { - throw new Error('useTicketClaim must be used within a TicketClaimContextProvider'); - } - - return context; -}; diff --git a/src/features/claim/routes/ClaimRouter.tsx b/src/features/claim/routes/ClaimRouter.tsx index 1843b731..2b0b5b1f 100644 --- a/src/features/claim/routes/ClaimRouter.tsx +++ b/src/features/claim/routes/ClaimRouter.tsx @@ -33,9 +33,6 @@ const ClaimPage = () => { case DROP_TYPE.NFT: navigate(`/claim/nft/${contractId}?${searchParamsStr}#${secretKey}`); break; - case DROP_TYPE.TRIAL: - navigate(`/claim/trial/${contractId}?${searchParamsStr}#${secretKey}`); - break; default: throw new Error('This linkdrop is unsupported.'); } diff --git a/src/features/claim/routes/TicketClaimPage.tsx b/src/features/claim/routes/TicketClaimPage.tsx deleted file mode 100644 index b4e0e988..00000000 --- a/src/features/claim/routes/TicketClaimPage.tsx +++ /dev/null @@ -1,47 +0,0 @@ -import { Box, Center, Spinner } from '@chakra-ui/react'; - -import { ErrorBox } from '@/components/ErrorBox'; -import { - TicketClaimContextProvider, - useTicketClaim, -} from '@/features/claim/contexts/TicketClaimContext'; - -/** - * - * High level Ticket Claim page to handle all key uses - * - Handles claim info loading and error - * - Renders the page for the key use - */ - -const TicketClaimPage = () => { - const { claimInfoError, isClaimInfoLoading, currentPage: PageComponent } = useTicketClaim(); - - if (isClaimInfoLoading) { - return ( -
- -
- ); - } - - if (claimInfoError) { - return ; - } - - return ( - - {/* render ticket page according to key use */} - {PageComponent && } - - ); -}; - -const WrappedTicketClaimPage = () => { - return ( - - - - ); -}; - -export default WrappedTicketClaimPage; diff --git a/src/features/claim/routes/TrialClaimPage.tsx b/src/features/claim/routes/TrialClaimPage.tsx index 0cd267a9..31d8ac50 100644 --- a/src/features/claim/routes/TrialClaimPage.tsx +++ b/src/features/claim/routes/TrialClaimPage.tsx @@ -1,4 +1,4 @@ -import { Box, Button, Center, Heading, useBoolean, VStack } from '@chakra-ui/react'; +import { Box, Button, Center, Heading, VStack } from '@chakra-ui/react'; import { useNavigate, useSearchParams } from 'react-router-dom'; import { useEffect, useState } from 'react'; import { claimTrialAccountDrop, accountExists } from 'keypom-js'; @@ -24,7 +24,6 @@ const TrialClaimPage = () => { const navigate = useNavigate(); const { contractId, secretKey } = useClaimParams(); const { setAppModal } = useAppContext(); - const [showInputWallet] = useBoolean(false); const [tokens] = useState([]); const [isClaimSuccessful, setIsClaimSuccessful] = useState(false); const [isClaimLoading, setIsClaimLoading] = useState(false); @@ -44,7 +43,8 @@ const TrialClaimPage = () => { try { await keypomInstance.getTokenClaimInformation(contractId, secretKey); } catch (e) { - console.log(e); + // eslint-disable-next-line no-console + console.error(e); // `no drop ID for PK` is error we should pass through to the redirect URL setClaimError('No drop for this link!'); } @@ -217,7 +217,9 @@ const TrialClaimPage = () => { label={`Your Account Name`} message={`Create Your Account`} noBackIcon={true} - onBack={showInputWallet.off} + onBack={() => { + console.log(false); + }} /> diff --git a/src/features/create-drop/components/CreateTicketDropLayout.tsx b/src/features/create-drop/components/CreateTicketDropLayout.tsx new file mode 100644 index 00000000..53d57da6 --- /dev/null +++ b/src/features/create-drop/components/CreateTicketDropLayout.tsx @@ -0,0 +1,29 @@ +// Generic layout for all drop + +import { Box, Heading } from '@chakra-ui/react'; + +import { Breadcrumbs, type IBreadcrumbItem } from '@/components/Breadcrumbs'; + +export const CreateTicketDropLayout = ({ + breadcrumbs, + description, + children, +}: { + breadcrumbs: IBreadcrumbItem[]; + description: string; + children: React.ReactNode; +}) => { + return ( + + + + + {description} + + + + {children} + + + ); +}; diff --git a/src/features/create-drop/components/nft/CreateNftDropSummary.tsx b/src/features/create-drop/components/nft/CreateNftDropSummary.tsx index 7f9cd56b..52c4b48e 100644 --- a/src/features/create-drop/components/nft/CreateNftDropSummary.tsx +++ b/src/features/create-drop/components/nft/CreateNftDropSummary.tsx @@ -21,10 +21,10 @@ export const CreateNftDropSummary = () => { { - handleDropConfirmation(paymentData); + handleDropConfirmation(paymentData!); }} /> ); diff --git a/src/features/create-drop/components/ticket/AcceptPaymentForm.tsx b/src/features/create-drop/components/ticket/AcceptPaymentForm.tsx new file mode 100644 index 00000000..f61327e1 --- /dev/null +++ b/src/features/create-drop/components/ticket/AcceptPaymentForm.tsx @@ -0,0 +1,231 @@ +import { + HStack, + Image, + Heading, + VStack, + Divider, + Button, + useToast, + Box, + Text, + Flex, + Hide, +} from '@chakra-ui/react'; +import { useEffect, useState } from 'react'; + +import { EVENTS_WORKER_BASE } from '@/constants/common'; +import keypomInstance from '@/lib/keypom'; +import { useAppContext } from '@/contexts/AppContext'; +import ToggleSwitch from '@/components/ToggleSwitch/ToggleSwitch'; + +import { type EventStepFormProps } from '../../routes/CreateTicketDropPage'; + +const purchaseWithStripe = new URL( + '../../../../../public/assets/purchase_with_stripe.webp', + import.meta.url, +); + +const STRIPE_PURCHASE_IMAGE = purchaseWithStripe.href.replace(purchaseWithStripe.search, ''); + +function uuidv4() { + return '10000000-1000-4000-8000-100000000000'.replace(/[018]/g, (c) => + (+c ^ (crypto.getRandomValues(new Uint8Array(1))[0] & (15 >> (+c / 4)))).toString(16), + ); +} + +const AcceptPaymentForm = (props: EventStepFormProps) => { + const { accountId, formData, setFormData } = props; + const toast = useToast(); + const { fetchAttempts, nearPrice, setTriggerPriceFetch } = useAppContext(); + + const handleToggle = (isStripe) => { + let curVal = isStripe ? formData.acceptStripePayments : formData.acceptNearPayments; + curVal = !curVal; + + if (isStripe) { + setFormData({ ...formData, acceptStripePayments: curVal }); + } else { + setFormData({ ...formData, acceptNearPayments: curVal }); + } + }; + + const [isLoading, setIsLoading] = useState(false); + + const checkForPriorStripeConnected = () => { + const stripeAccountId = localStorage.getItem('STRIPE_ACCOUNT_ID'); + if (stripeAccountId) { + setFormData({ ...formData, stripeAccountId, acceptStripePayments: false }); + } + + return stripeAccountId; + }; + + useEffect(() => { + const body = localStorage.getItem('STRIPE_ACCOUNT_INFO'); + if (body) { + const { stripeAccountId, uuid } = JSON.parse(body); + if (window.location.href.includes(`successMessage=${uuid as string}`)) { + localStorage.removeItem('STRIPE_ACCOUNT_INFO'); + + localStorage.setItem('STRIPE_ACCOUNT_ID', stripeAccountId); + setFormData({ ...formData, stripeAccountId, acceptStripePayments: true }); + } + } + }, []); + + useEffect(() => { + checkForPriorStripeConnected(); + }, []); + + useEffect(() => { + if (!nearPrice) { + setTriggerPriceFetch(true); + } else { + setFormData({ ...formData, nearPrice }); + } + }, [nearPrice, fetchAttempts]); + + const handleConnectStripe = async () => { + if (!formData.nearPrice) { + toast({ + title: 'Unable to fetch NEAR price', + description: `Please try again later or contact support.`, + status: 'error', + duration: 5000, + isClosable: true, + }); + } + if (checkForPriorStripeConnected()) { + return; + } + + setIsLoading(true); + const stripeAccountId = await keypomInstance.getStripeAccountId(accountId || ''); + + if (stripeAccountId) { + setFormData({ ...formData, stripeAccountId, acceptStripePayments: true }); + setIsLoading(false); + return; + } + + let response: Response | undefined; + const uuid = uuidv4(); + try { + const body = { + accountId, + redirectUrl: `${window.location.origin}/drop/ticket/new?successMessage=${uuid}`, + }; + + const url = `${EVENTS_WORKER_BASE}/stripe/create-account`; + response = await fetch(url, { + method: 'POST', + body: JSON.stringify(body), + }); + } catch (error) { + // eslint-disable-next-line no-console + console.error('Error connecting to stripe: ', error); + } + + if (response?.ok) { + const resBody = await response.json(); + const { accountLinkUrl, stripeAccountId } = resBody; + + localStorage.setItem('STRIPE_ACCOUNT_INFO', JSON.stringify({ stripeAccountId, uuid })); + window.location.href = accountLinkUrl; + } else { + toast({ + title: 'Unable to Create Stripe Account', + description: `Please try again later or contact support.`, + status: 'error', + duration: 5000, + isClosable: true, + }); + } + }; + + return ( + + + + Payment Options + + + Select your preferred methods for payment processing. + + + + + $NEAR Checkout + + + Allow ticket purchases with $NEAR. + + + + + NEAR Payments + + { + handleToggle(false); + }} + size="lg" + toggle={formData.acceptNearPayments} + /> + + + + + Stripe Checkout + + + Allow ticket purchases with credit cards. + + + + + Stripe Payments + + { + handleToggle(true); + }} + size="lg" + toggle={formData.acceptStripePayments} + /> + + + + + + + + + Stripe Purchase Illustration + + + + ); +}; + +export { AcceptPaymentForm }; diff --git a/src/features/create-drop/components/ticket/AdditionalGiftsForm/AdditionalGiftsForm.tsx b/src/features/create-drop/components/ticket/AdditionalGiftsForm/AdditionalGiftsForm.tsx deleted file mode 100644 index c4a4356f..00000000 --- a/src/features/create-drop/components/ticket/AdditionalGiftsForm/AdditionalGiftsForm.tsx +++ /dev/null @@ -1,61 +0,0 @@ -import { Box, TabPanel, TabPanels } from '@chakra-ui/react'; -import { useFormContext } from 'react-hook-form'; -import { useEffect } from 'react'; - -import { RoundedTabs, type TabListItem } from '@/components/RoundedTabs'; -import { POAPNftIcon } from '@/components/Icons'; -import { type CreateTicketFieldsSchema } from '@/features/create-drop/contexts/CreateTicketDropContext/CreateTicketDropContext'; - -import { POAPNftForm } from './POAPNftForm'; - -interface AdditionalGiftTabItem extends TabListItem { - name: 'token' | 'poapNft'; -} - -const TAB_LIST: AdditionalGiftTabItem[] = [ - /** token is commented in case there's a need for it in the future */ - // { - // name: 'token', - // label: 'Token', - // icon: , - // }, - { - name: 'poapNft', - label: 'POAP NFT', - icon: , - }, -]; - -export const AdditionalGiftsForm = () => { - const { setValue, formState, resetField } = useFormContext(); - const { dirtyFields } = formState; - - useEffect(() => { - if (dirtyFields.additionalGift?.poapNft !== null) { - setValue('additionalGift.type', 'poapNft', { shouldValidate: true }); - } else if (dirtyFields.additionalGift?.token !== null) { - setValue('additionalGift.type', 'token', { shouldValidate: true }); - } else { - setValue('additionalGift.type', 'none', { shouldValidate: true }); - } - }, [dirtyFields?.additionalGift, setValue]); - - const handleChange = () => { - resetField('additionalGift'); - }; - - return ( - - - - {/* - - */} - - - - - - - ); -}; diff --git a/src/features/create-drop/components/ticket/AdditionalGiftsForm/POAPNftForm.tsx b/src/features/create-drop/components/ticket/AdditionalGiftsForm/POAPNftForm.tsx deleted file mode 100644 index fec0a0e6..00000000 --- a/src/features/create-drop/components/ticket/AdditionalGiftsForm/POAPNftForm.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import { VStack } from '@chakra-ui/react'; -import { Controller, useFormContext } from 'react-hook-form'; - -import { ArtworkInput } from '@/features/create-drop/components/Fields/ArtworkInput'; -import { TextInput } from '@/components/TextInput'; -import { TextAreaInput } from '@/components/TextAreaInput'; -import { type CreateTicketFieldsSchema } from '@/features/create-drop/contexts/CreateTicketDropContext/CreateTicketDropContext'; - -export const POAPNftForm = () => { - const { control } = useFormContext(); - - return ( - - ( - - )} - /> - ( - - )} - /> - - - ); -}; diff --git a/src/features/create-drop/components/ticket/AdditionalGiftsForm/TokenForm.tsx b/src/features/create-drop/components/ticket/AdditionalGiftsForm/TokenForm.tsx deleted file mode 100644 index baad73e3..00000000 --- a/src/features/create-drop/components/ticket/AdditionalGiftsForm/TokenForm.tsx +++ /dev/null @@ -1,97 +0,0 @@ -import { useMemo } from 'react'; -import { Controller, useFormContext } from 'react-hook-form'; -import { evaluate, format } from 'mathjs'; -import { formatNearAmount } from 'keypom-js'; - -import { FormControl } from '@/components/FormControl'; -import { TokenInput } from '@/components/TokenInputMenu'; -import { type CreateTicketFieldsSchema } from '@/features/create-drop/contexts/CreateTicketDropContext/CreateTicketDropContext'; -import { useAuthWalletContext } from '@/contexts/AuthWalletContext'; -import { type IToken } from '@/types/common'; - -export const TokenForm = () => { - const { - setValue, - trigger, - control, - watch, - formState: { errors }, - } = useFormContext(); - const { account } = useAuthWalletContext(); - - const selectedTokenError = errors?.additionalGift?.token?.selectedToken; - - const WALLET_TOKENS: IToken[] = account - ? [ - { - amount: formatNearAmount(account.amount, 4), - symbol: 'NEAR', - }, - ] - : []; - - const [selectedToken, amountPerLink, totalTickets] = watch([ - 'additionalGift.token.selectedToken', - 'additionalGift.token.amountPerLink', - 'totalTickets', - ]); - const totalCost = useMemo(() => { - if (totalTickets && amountPerLink !== undefined) { - return format(evaluate(`${totalTickets} * ${amountPerLink as number}`), { - precision: 14, - }); - } - return 0; - }, [amountPerLink, totalTickets]); - - const handleTokenChange = (tokenSymbol: string) => { - const foundWallet = WALLET_TOKENS.find((token) => token.symbol === tokenSymbol); - - if (!foundWallet) { - return; - } - - setValue( - 'additionalGift.token.selectedToken', - { symbol: foundWallet.symbol, amount: foundWallet.amount }, - { shouldDirty: true, shouldValidate: true }, - ); - }; - - return ( - ( - - { - if (e.target.value.length > e.target.maxLength) - e.target.value = e.target.value.slice(0, e.target.maxLength); - field.onChange(parseFloat(e.target.value)); - void trigger(); // errors not getting updated if its not manually validated - }} - > - - - - - )} - /> - ); -}; diff --git a/src/features/create-drop/components/ticket/CollectInfoForm.tsx b/src/features/create-drop/components/ticket/CollectInfoForm.tsx new file mode 100644 index 00000000..26fc4043 --- /dev/null +++ b/src/features/create-drop/components/ticket/CollectInfoForm.tsx @@ -0,0 +1,186 @@ +import { Button, HStack, Skeleton, Text, VStack } from '@chakra-ui/react'; +import { useMemo, useState } from 'react'; +import { EditIcon } from '@chakra-ui/icons'; + +import { FormControlComponent } from '@/components/FormControl'; +import { type ColumnItem } from '@/components/Table/types'; +import { DeleteIcon } from '@/components/Icons'; +import { DataTable } from '@/components/Table'; +import ToggleSwitch from '@/components/ToggleSwitch/ToggleSwitch'; + +import { + type TicketDropFormData, + type EventStepFormProps, +} from '../../routes/CreateTicketDropPage'; + +import { ModifyQuestionModal } from './ModifyQuestionModal'; +import { EMAIL_QUESTION } from './helpers'; + +const columns: ColumnItem[] = [ + { + id: 'question', + title: 'Question', + selector: (row) => row.id, + loadingElement: , + }, + { + id: 'isRequired', + title: 'Is required?', + selector: (row) => row.isRequired, + loadingElement: , + }, + { + id: 'action', + title: '', + selector: (row) => row.action, + loadingElement: , + }, +]; + +export const CollectInfoFormValidation = (formData: TicketDropFormData) => { + const newFormData = { ...formData }; + const isErr = false; + + return { isErr, newFormData }; +}; + +const CollectInfoForm = (props: EventStepFormProps) => { + const { formData, setFormData } = props; + const [isModalOpen, setIsModalOpen] = useState(false); + const [userInput, setUserInput] = useState(''); + const [originalQuestion, setOriginalQuestion] = useState(); + + const handleDeleteClick = (id) => { + const newQuestions = formData.questions.filter((item) => item.question !== id); + setFormData({ ...formData, questions: newQuestions }); + }; + + const handleToggle = (id) => { + const newQuestions = formData.questions.map((item) => { + if (item.question === id) { + return { ...item, isRequired: !item.isRequired }; + } + return item; + }); + setFormData({ ...formData, questions: newQuestions }); + }; + + const handleModalClose = (shouldAdd, originalQuestion) => { + if (shouldAdd) { + let newQuestions = formData.questions; + if (originalQuestion) { + newQuestions = newQuestions.map((item) => { + if (item.question === originalQuestion) { + return { ...item, question: userInput, isRequired: item.isRequired }; + } + return item; + }); + } else { + newQuestions.push({ question: userInput, isRequired: false }); + } + + setFormData({ ...formData, questions: newQuestions }); + } + + setIsModalOpen(false); + }; + + const getTableRows = (data, handleToggle, handleDeleteClick) => { + if (data === undefined) return []; + + return data.map((item: { question: string; isRequired: boolean }) => ({ + id: item.question === EMAIL_QUESTION ? `${EMAIL_QUESTION} (required)` : item.question, // Assuming `item` has a `question` property that can serve as `id` + isRequired: ( + handleToggle(item.question)} + toggle={item.isRequired} + /> + ), + action: ( + + + + + ), + })); + }; + + const data = useMemo( + () => getTableRows(formData.questions, handleToggle, handleDeleteClick), + [getTableRows, formData.questions], + ); + + return ( + <> + + + + + + + {data.length >= 4 && ( + + You can only add up to 4 custom fields + + )} + + + ); +}; + +export { CollectInfoForm }; diff --git a/src/features/create-drop/components/ticket/CreateTicketDropForm.tsx b/src/features/create-drop/components/ticket/CreateTicketDropForm.tsx deleted file mode 100644 index d48b16b9..00000000 --- a/src/features/create-drop/components/ticket/CreateTicketDropForm.tsx +++ /dev/null @@ -1,97 +0,0 @@ -import { useFormContext } from 'react-hook-form'; -import { Button, Divider, HStack } from '@chakra-ui/react'; - -import { IconBox } from '@/components/IconBox'; -import { LinkIcon } from '@/components/Icons'; -import { Step } from '@/components/Step/Step'; -import { useDropFlowContext } from '@/features/create-drop/contexts/DropFlowContext'; -import { useCreateTicketDropContext } from '@/features/create-drop/contexts/CreateTicketDropContext/CreateTicketDropContext'; - -export const CreateTicketDropForm = () => { - const { onNext } = useDropFlowContext(); - const { - reset, - formState: { isValid, dirtyFields, defaultValues }, - } = useFormContext(); - - const { currentIndex, onNextStep, onPreviousStep, formSteps } = useCreateTicketDropContext(); - - const currentStep = formSteps[currentIndex]; - - const stepsDisplay = formSteps.map((step, index) => ( - - )); - - // TODO: add next step to summary - const handleSubmitClick = () => { - onNext?.(); - }; - - const handleNextStepClick = () => { - if (currentIndex === formSteps.length - 1) { - handleSubmitClick(); - return; - } - - reset(defaultValues, { keepValues: true }); - onNextStep(); - }; - - // isDirty from react form hook does not match with dirtyFields correctly - // https://github.com/react-hook-form/react-hook-form/issues/4740 - const isDirty = Object.keys(dirtyFields).length > 0; - - return ( - } - maxW={{ base: '21.5rem', md: '36rem' }} - mx="auto" - > - - {stepsDisplay} - - {currentStep.component} - - - {currentIndex > 0 && ( - - )} - {currentStep.isSkipable && !isDirty ? ( - - ) : ( - - )} - - - ); -}; diff --git a/src/features/create-drop/components/ticket/CreateTicketDropSummary.tsx b/src/features/create-drop/components/ticket/CreateTicketDropSummary.tsx deleted file mode 100644 index 2d55507c..00000000 --- a/src/features/create-drop/components/ticket/CreateTicketDropSummary.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import { DropSummary } from '@/features/create-drop/components/DropSummary'; -import { useCreateTicketDropContext } from '@/features/create-drop/contexts/CreateTicketDropContext/CreateTicketDropContext'; - -export const CreateTicketDropSummary = () => { - const { getSummaryData, getPaymentData, createLinksSWR } = useCreateTicketDropContext(); - const { data, handleDropConfirmation } = createLinksSWR; - - return ( - - ); -}; diff --git a/src/features/create-drop/components/ticket/CreateTicketsForm.tsx b/src/features/create-drop/components/ticket/CreateTicketsForm.tsx new file mode 100644 index 00000000..4e6e37b9 --- /dev/null +++ b/src/features/create-drop/components/ticket/CreateTicketsForm.tsx @@ -0,0 +1,322 @@ +import { + Button, + Show, + Heading, + Hide, + HStack, + Image, + Skeleton, + VStack, + Text, +} from '@chakra-ui/react'; +import { useMemo, useState } from 'react'; +import { EditIcon } from '@chakra-ui/icons'; + +import { FormControlComponent } from '@/components/FormControl'; +import { type ColumnItem } from '@/components/Table/types'; +import { CopyIcon, DeleteIcon } from '@/components/Icons'; +import { DataTable } from '@/components/Table'; +import { truncateAddress } from '@/utils/truncateAddress'; +import { type DateAndTimeInfo } from '@/lib/eventsHelpers'; + +import { + type TicketDropFormData, + type EventStepFormProps, +} from '../../routes/CreateTicketDropPage'; + +import { ModifyTicketModal } from './ModifyTicketModal'; +import { PreviewTicketModal } from './PreviewTicketModal'; + +const columns: ColumnItem[] = [ + { + id: 'ticket', + title: 'Ticket', + selector: (row) => row.name, + loadingElement: , + }, + { + id: 'previewTicket', + title: '', + selector: (row) => row.previewTicket, + loadingElement: , + }, + { + id: 'numTickets', + title: '# tickets', + selector: (row) => row.numTickets, + loadingElement: , + }, + { + id: 'priceNEAR', + title: 'Price (NEAR)', + selector: (row) => row.price, + loadingElement: , + }, + { + id: 'action', + title: '', + selector: (row) => row.action, + loadingElement: , + }, +]; + +export const CollectInfoFormValidation = (formData: TicketDropFormData) => { + const newFormData = { ...formData }; + const isErr = false; + + return { isErr, newFormData }; +}; + +export const defaultTicket = { + name: '', + description: '', + artwork: undefined, + priceNear: '0', + salesValidThrough: { + startDate: 0, + }, + passValidThrough: { + startDate: 0, + }, + maxSupply: 0, + maxPurchases: 0, +}; + +export interface TicketInfoFormMetadata { + name: string; + description: string; + artwork: File | undefined; + priceNear: string; + salesValidThrough: DateAndTimeInfo; + passValidThrough: DateAndTimeInfo; + maxSupply: number; + maxPurchases: number; +} + +const CreateTicketsForm = (props: EventStepFormProps) => { + const { formData, setFormData } = props; + const [isModifyTicketModalOpen, setIsModifyTicketModalOpen] = useState(false); + const [isPreviewTicketModalOpen, setIsPreviewTicketModalOpen] = useState(false); + const [currentTicket, setCurrentTicket] = useState(defaultTicket); + const [editedTicket, setEditedTicket] = useState(); + + const handleDeleteClick = (id) => { + const newTickets = formData.tickets.filter((item) => item.name !== id); + setFormData({ ...formData, tickets: newTickets }); + }; + + const handleCopyItem = (id) => { + const ticket = formData.tickets.find((item) => item.name === id); + if (!ticket) return; + + // Function to generate a new unique ticket name + const generateNewTicketName = (baseName: string) => { + let copyNumber = 1; + let newTicketName = `${baseName} (${copyNumber})`; + // As long as the new ticket name already exists, increase the number + while (formData.tickets.some((item) => item.name === newTicketName)) { + copyNumber += 1; + newTicketName = `${baseName} (${copyNumber})`; + } + return newTicketName; + }; + + // Generate a unique name for the new copied ticket + const newTicketName = generateNewTicketName(ticket.name); + const newTicket = { ...ticket, name: newTicketName }; + // Add the new unique ticket to the list of tickets + setFormData({ ...formData, tickets: [...formData.tickets, newTicket] }); + }; + + const handleModalClose = (shouldAdd: boolean, editedQuestion?: TicketInfoFormMetadata) => { + if (shouldAdd) { + let newTickets = formData.tickets; + if (editedQuestion) { + newTickets = newTickets.map((item) => { + if (item.name === editedQuestion.name) { + return currentTicket; + } + return item; + }); + } else { + newTickets.push(currentTicket); + } + + setFormData({ ...formData, tickets: newTickets }); + } + + setIsModifyTicketModalOpen(false); + }; + + const openPreviewModal = (item: TicketInfoFormMetadata) => { + setCurrentTicket(item); + setIsPreviewTicketModalOpen(true); + }; + + const getTableRows = (data) => { + if (data === undefined) return []; + + return data.map((item: TicketInfoFormMetadata) => ({ + id: item.name, + price: item.priceNear === '0' ? 'Free' : item.priceNear, + numTickets: item.maxSupply, + name: ( + <> + + + {`Event + + + {truncateAddress(item.name, 'end', 35)} + + + {truncateAddress(item.description, 'end', 35)} + + + + + + + {`Event + + + {truncateAddress(item.name, 'end', 35)} + + + {truncateAddress(item.description, 'end', 35)} + + + + + + ), + previewTicket: ( + { + openPreviewModal(item); + }} + > + Preview ticket + + ), + action: ( + + + + + + ), + })); + }; + + const data = useMemo(() => getTableRows(formData.tickets), [getTableRows, formData.tickets]); + + return ( + <> + + + + + + + + + + + ); +}; + +export { CreateTicketsForm }; diff --git a/src/features/create-drop/components/ticket/DynamicTicketPreview.tsx b/src/features/create-drop/components/ticket/DynamicTicketPreview.tsx new file mode 100644 index 00000000..907f7b17 --- /dev/null +++ b/src/features/create-drop/components/ticket/DynamicTicketPreview.tsx @@ -0,0 +1,102 @@ +import { Button, Heading, HStack, IconButton, Image, VStack, Box } from '@chakra-ui/react'; + +import { PlusButtonIcon } from '@/components/Icons/PlusButtonIcon'; +import { MinusButtonIcon } from '@/components/Icons/MinusButtonIcon'; +import { dateAndTimeToText } from '@/features/drop-manager/utils/parseDates'; + +import { type TicketInfoFormMetadata } from './CreateTicketsForm'; + +export const DynamicTicketPreview = ({ + currentTicket, +}: { + currentTicket: TicketInfoFormMetadata; +}) => { + const placeholderColor = 'gray.200'; // Define your placeholder color here + + return ( + + + {currentTicket.artwork ? ( + Event Artwork + ) : ( + + )} + + {currentTicket.name ? ( + + {currentTicket.name} + + ) : ( + + )} + {currentTicket.passValidThrough.startDate ? ( + + {dateAndTimeToText(currentTicket.passValidThrough)} + + ) : ( + + )} + {currentTicket.description ? ( + + {currentTicket.description} + + ) : ( + + + + + + + )} + + + } // replace with your actual icon + variant="outline" + /> + + } // replace with your actual icon + variant="outline" + /> + + + + + + ); +}; diff --git a/src/features/create-drop/components/ticket/EventCreationStatusModal.tsx b/src/features/create-drop/components/ticket/EventCreationStatusModal.tsx new file mode 100644 index 00000000..03ddd988 --- /dev/null +++ b/src/features/create-drop/components/ticket/EventCreationStatusModal.tsx @@ -0,0 +1,166 @@ +import { + Modal, + ModalOverlay, + ModalContent, + ModalHeader, + ModalBody, + ModalFooter, + Button, + Text, + VStack, + IconButton, + useToast, +} from '@chakra-ui/react'; +import { useState } from 'react'; +import { useNavigate } from 'react-router-dom'; + +import { TicketIcon } from '@/components/Icons'; +import { EVENTS_WORKER_BASE } from '@/constants/common'; + +interface EventCreationStatusModalProps { + isSuccess: boolean; + setIsOpen: () => void; + isOpen: boolean; + prevEventData?: { + priceByDropId?: Record; + eventId: string; + stripeAccountId?: string; + }; +} + +export const EventCreationStatusModal = ({ + isOpen, + setIsOpen, + isSuccess, + prevEventData, +}: EventCreationStatusModalProps) => { + const [isSetting, setIsSetting] = useState(false); + const navigate = useNavigate(); + const toast = useToast(); + + const handlePostEventCreation = async () => { + if (isSuccess) { + setIsSetting(true); + + if (prevEventData?.stripeAccountId !== undefined) { + let response: Response | undefined; + try { + const url = `${EVENTS_WORKER_BASE}/stripe/create-event`; + const body = { + priceByDropId: prevEventData.priceByDropId, + stripeAccountId: prevEventData.stripeAccountId, + eventId: prevEventData.eventId, + }; + response = await fetch(url, { + method: 'POST', + body: JSON.stringify(body), + }); + } catch (error) { + return; + } + + if (!response?.ok) { + toast({ + title: 'Unable to upload event to stripe', + description: `Please delete the event and try again later. If the error persists, contact support.`, + status: 'error', + duration: 10000, + isClosable: true, + }); + } else { + localStorage.removeItem('EVENT_INFO_SUCCESS_DATA'); + } + } else { + localStorage.removeItem('EVENT_INFO_SUCCESS_DATA'); + } + setIsSetting(false); + navigate('/events'); + } else { + localStorage.removeItem('EVENT_INFO_SUCCESS_DATA'); + } + setIsOpen(); + }; + + const bodyMessage = () => { + if (!isSuccess) { + return 'Please try again later. If the error persists, contact support.'; + } + + if (prevEventData?.stripeAccountId !== undefined) { + return 'The last step is to upload the event details to stripe so that you can receive funds.'; + } + + return `You're all set! Your event has been created and you can view it in the events page.`; + }; + + const buttonMessage = () => { + if (!isSuccess) { + return 'Close'; + } + + if (prevEventData?.stripeAccountId !== undefined) { + return 'Upload to Stripe'; + } + + return 'View Event'; + }; + + return ( + { + // console.log('close'); + }} + > + + + } // replace with your actual icon + left="50%" + overflow="visible !important" + position="absolute" + top={-6} // Half of the icon size to make it center on the edge + transform="translateX(-50%) translateY(-10%)" + variant="outline" + width="60px" + zIndex={1500} // Higher than ModalContent to overlap + /> + + {isSuccess ? 'Event Created Successfully' : 'Event Creation Failed'} + + + + + {bodyMessage()} + + + + + + + + + ); +}; diff --git a/src/features/create-drop/components/ticket/EventInfoForm.tsx b/src/features/create-drop/components/ticket/EventInfoForm.tsx index 91cd5a65..ff7d7677 100644 --- a/src/features/create-drop/components/ticket/EventInfoForm.tsx +++ b/src/features/create-drop/components/ticket/EventInfoForm.tsx @@ -1,57 +1,278 @@ -import { Box, Input } from '@chakra-ui/react'; -import { Controller, useFormContext } from 'react-hook-form'; +import { Input, HStack, VStack, Show, Hide, Box } from '@chakra-ui/react'; +import { useEffect, useState } from 'react'; -import { FormControl } from '@/components/FormControl'; -import { type CreateTicketFieldsSchema } from '@/features/create-drop/contexts/CreateTicketDropContext/CreateTicketDropContext'; +import CustomDateRangePicker from '@/components/DateRangePicker/DateRangePicker'; +import { ImageFileInput } from '@/components/ImageFileInput'; +import CustomDateRangePickerMobile from '@/components/DateRangePicker/MobileDateRangePicker'; +import { FormControlComponent } from '@/components/FormControl'; +import { dateAndTimeToText } from '@/features/drop-manager/utils/parseDates'; -export const EventInfoForm = () => { - const { control } = useFormContext(); +import { + type TicketDropFormData, + type EventStepFormProps, +} from '../../routes/CreateTicketDropPage'; - return ( - - { - return ( - - - - ); +import EventPagePreview from './EventPagePreview'; + +export const ClearEventInfoForm = () => { + return { + eventName: { value: '' }, + eventArtwork: { value: undefined }, + eventDescription: { value: '' }, + eventLocation: { value: '' }, + date: { + value: { + startDate: 0, + }, + }, + }; +}; + +export const EventInfoFormValidation = (formData: TicketDropFormData) => { + const newFormData = { ...formData }; + let isErr = false; + return { isErr, newFormData: formData }; + + if (formData.eventName.value === '') { + newFormData.eventName = { ...formData.eventName, error: 'Event name is required' }; + isErr = true; + } + if (formData.eventArtwork.value === undefined) { + newFormData.eventArtwork = { ...formData.eventArtwork, error: 'Event artwork is required' }; + isErr = true; + } + if (formData.eventDescription.value === '') { + newFormData.eventDescription = { + ...formData.eventDescription, + error: 'Event description is required', + }; + isErr = true; + } + if (formData.eventLocation.value === '') { + newFormData.eventLocation = { + ...formData.eventLocation, + error: 'Event location is required', + }; + isErr = true; + } + if (formData.date.value.startDate === null) { + newFormData.date = { ...formData.date, error: 'Event date is required' }; + isErr = true; + } + + return { isErr, newFormData }; +}; + +const EventInfoForm = (props: EventStepFormProps) => { + const { formData, setFormData } = props; + + const [isDatePickerOpen, setIsDatePickerOpen] = useState(false); + const [datePlaceholer, setDatePlaceholder] = useState('Select date and time'); + const [datePreviewText, setDatePreviewText] = useState(''); + + const [preview, setPreview] = useState(); + + useEffect(() => { + if (formData.eventArtwork.value === undefined) { + setPreview(undefined); + return; + } + const objectUrl = URL.createObjectURL(formData.eventArtwork.value); + setPreview(objectUrl); + + return () => { + URL.revokeObjectURL(objectUrl); + }; + }, [formData.eventArtwork.value]); + + const onSelectFile = (e) => { + if (!e.target.files || e.target.files.length === 0) { + setPreview(undefined); + setFormData({ ...formData, eventArtwork: { value: undefined } }); + return; + } + + const file = e.target.files[0]; + setFormData({ ...formData, eventArtwork: { value: file } }); + }; + + useEffect(() => { + const datePlaceholder = dateAndTimeToText(formData.date.value, 'Select date and time'); + const datePreviewText = dateAndTimeToText(formData.date.value); + + setDatePlaceholder(datePlaceholder); + setDatePreviewText(datePreviewText); + }, [formData.date]); + + const margins = '2 !important'; + + const datePickerCTA = ( + + - { - return ( - - { - field.onChange(parseInt(e.target.value), 10); - }} - /> - - ); + type="text" + width="100%" + onClick={() => { + setIsDatePickerOpen(true); }} /> - + + ); + + return ( + + + + { + setFormData({ ...formData, eventName: { value: e.target.value } }); + }} + /> + + + { + setFormData({ ...formData, eventDescription: { value: e.target.value } }); + }} + /> + + + { + setFormData({ ...formData, eventLocation: { value: e.target.value } }); + }} + /> + + + { + setFormData({ + ...formData, + date: { value: { ...formData.date, startDate, endDate } }, + }); + }} + onTimeChange={(startTime, endTime) => { + setFormData({ + ...formData, + date: { value: { ...formData.date.value, startTime, endTime } }, + }); + }} + /> + + + { + setFormData({ + ...formData, + date: { value: { ...formData.date.value, startDate, endDate } }, + }); + }} + onTimeChange={(startTime, endTime) => { + setFormData({ + ...formData, + date: { value: { ...formData.date.value, startTime, endTime } }, + }); + }} + /> + + + { + onSelectFile(e); + }} + /> + + + + + + + + + + ); }; + +export { EventInfoForm }; diff --git a/src/features/create-drop/components/ticket/EventPagePreview.tsx b/src/features/create-drop/components/ticket/EventPagePreview.tsx new file mode 100644 index 00000000..6322dcbe --- /dev/null +++ b/src/features/create-drop/components/ticket/EventPagePreview.tsx @@ -0,0 +1,137 @@ +import { Box, VStack, Image, Heading, HStack } from '@chakra-ui/react'; + +import TicketPreview from './TicketPreview'; + +interface EventPagePreviewProps { + eventName: string; + eventDescription?: string; + eventLocation?: string; + eventDate: string; + eventArtwork?: string; +} + +function EventPagePreview({ + eventName, + eventDescription, + eventLocation, + eventDate, + eventArtwork, +}: EventPagePreviewProps) { + return ( + <> + {/* Adjust height as needed */} + {!eventArtwork ? ( + + ) : ( + Event Artwork + )} + + + {eventName || 'Event name'} + + + + + Event details + + {eventDescription ? ( + // Assuming you want to display the event description when it exists + + {eventDescription} + + ) : ( + // Placeholder pills when there is no event description + + {Array.from({ length: 5 }, (_, index) => ( + + ))} + + + )} + + + + + Location + + {eventLocation ? ( + // Assuming you want to display the event description when it exists + + {eventLocation} + + ) : ( + + )} + + + + Date + + {eventDate ? ( + // Assuming you want to display the event description when it exists + + {eventDate} + + ) : ( + + )} + + + + + Tickets + + + + + ); +} + +export default EventPagePreview; diff --git a/src/features/create-drop/components/ticket/KeypomPasswordPromptModal.tsx b/src/features/create-drop/components/ticket/KeypomPasswordPromptModal.tsx new file mode 100644 index 00000000..f869f707 --- /dev/null +++ b/src/features/create-drop/components/ticket/KeypomPasswordPromptModal.tsx @@ -0,0 +1,147 @@ +import { + Modal, + ModalOverlay, + ModalContent, + ModalHeader, + ModalCloseButton, + ModalBody, + ModalFooter, + Button, + Input, + useToast, + Text, + VStack, + InputGroup, + InputRightElement, + IconButton, +} from '@chakra-ui/react'; +import { useState } from 'react'; +import { ViewIcon, ViewOffIcon } from '@chakra-ui/icons'; + +import { set } from '@/utils/localStorage'; +import { EyeIcon } from '@/components/Icons/EyeIcon'; + +interface KeypomPasswordPromptModalProps { + isOpen: boolean; + isSetting: boolean; + onModalClose: () => void; +} + +export const KeypomPasswordPromptModal = ({ + isOpen, + isSetting, + onModalClose, +}: KeypomPasswordPromptModalProps) => { + const [password, setPassword] = useState(''); + const [showPassword, setShowPassword] = useState(false); + const toast = useToast(); + + const handlePasswordSubmit = () => { + // Perform any validation you need here + if (password.length < 8) { + toast({ + title: 'Password too short.', + description: "Please enter a password that's at least 8 characters long.", + status: 'error', + duration: 5000, + isClosable: true, + }); + return; + } + + // If validation passes, save the password + set('MASTER_KEY', password); + onModalClose(); + toast({ + title: 'Password set successfully.', + status: 'success', + duration: 5000, + isClosable: true, + }); + }; + + const handlePasswordVisibilityToggle = () => { + setShowPassword(!showPassword); + }; + + return ( + + + + } // replace with your actual icon + left="50%" + overflow="visible !important" + position="absolute" + top={-6} // Half of the icon size to make it center on the edge + transform="translateX(-50%) translateY(-10%)" + variant="outline" + width="60px" + zIndex={1500} // Higher than ModalContent to overlap + /> + + Set your Keypom password + + + + + + + To create an event, you need to set a Keypom password. This allows you to manage + your event securely. + + + { + setPassword(e.target.value); + }} + /> + + : } + size="sm" + variant="ghost" + onClick={handlePasswordVisibilityToggle} + /> + + + + Do not lose your password. It cannot be recovered if lost. + + + + + + + + + + ); +}; diff --git a/src/features/create-drop/components/ticket/ModifyQuestionModal.tsx b/src/features/create-drop/components/ticket/ModifyQuestionModal.tsx new file mode 100644 index 00000000..27269fcf --- /dev/null +++ b/src/features/create-drop/components/ticket/ModifyQuestionModal.tsx @@ -0,0 +1,81 @@ +import { Button, Input, Modal, ModalContent, ModalOverlay, Text, VStack } from '@chakra-ui/react'; + +import { EMAIL_QUESTION } from './helpers'; + +interface ModifyQuestionModalProps { + allQuestions: Array<{ id: string; isRequired: boolean }>; + isOpen: boolean; + onClose: (shouldAdd: boolean, originalQuestion?: string) => void; + userInput: string; + setUserInput: (value: string) => void; + originalQuestion?: string; +} + +export const ModifyQuestionModal = ({ + allQuestions, + isOpen, + onClose, + userInput, + setUserInput, + originalQuestion, +}: ModifyQuestionModalProps) => { + let canAddQuestion = !allQuestions.some((question) => question.id === userInput); + if (userInput === EMAIL_QUESTION) { + canAddQuestion = false; + } + return ( + { + onClose(false, originalQuestion); + }} + > + + + + + + {originalQuestion ? 'Edit question' : 'Custom question'} + + + Add a question you'd like attendees to fill out for this event + + { + e.preventDefault(); + setUserInput(e.target.value); + }} + /> + + + + + + + + + ); +}; diff --git a/src/features/create-drop/components/ticket/ModifyTicketModal.tsx b/src/features/create-drop/components/ticket/ModifyTicketModal.tsx new file mode 100644 index 00000000..4afcce7f --- /dev/null +++ b/src/features/create-drop/components/ticket/ModifyTicketModal.tsx @@ -0,0 +1,930 @@ +import { + Button, + Hide, + Grid, + GridItem, + Input, + Modal, + ModalContent, + ModalOverlay, + Show, + Textarea, + VStack, + Center, + Box, + HStack, +} from '@chakra-ui/react'; +import { useEffect, useRef, useState } from 'react'; +import { DateTime } from 'luxon'; + +import { FormControlComponent } from '@/components/FormControl'; +import CustomDateRangePicker from '@/components/DateRangePicker/DateRangePicker'; +import CustomDateRangePickerMobile from '@/components/DateRangePicker/MobileDateRangePicker'; +import { ImageFileInput } from '@/components/ImageFileInput'; +import { type DateAndTimeInfo } from '@/lib/eventsHelpers'; +import { dateAndTimeToText } from '@/features/drop-manager/utils/parseDates'; +import { ImageFileInputSmall } from '@/components/ImageFileInput/ImageFileInputSmall'; + +import { type TicketDropFormData } from '../../routes/CreateTicketDropPage'; + +import { type TicketInfoFormMetadata } from './CreateTicketsForm'; +import TicketPriceSelector from './TicketPriceSelector'; +import { DynamicTicketPreview } from './DynamicTicketPreview'; + +const defaultErrors = { + name: '', + description: '', + salesValidThrough: '', + passValidThrough: '', + maxSupply: '', + price: '', + artwork: '', + maxPurchases: '', +}; + +export const isValidNonNegativeNumber = (value) => { + return /^\d*\.?\d+$/.test(value); +}; + +interface ModifyTicketModalProps { + isOpen: boolean; + onClose: (shouldAdd: boolean, editedTicket?: TicketInfoFormMetadata) => void; + eventDate: DateAndTimeInfo; + formData: TicketDropFormData; + allTickets: TicketInfoFormMetadata[]; + currentTicket: TicketInfoFormMetadata; + setCurrentTicket: (ticket: TicketInfoFormMetadata) => void; + editedTicket?: TicketInfoFormMetadata; +} + +// Function to parse a time string and return a Luxon DateTime object +const parseTime = (timeString) => { + // Assuming your time string format is "HH:mm" (e.g., "14:00" for 2:00 PM) + // Adjust the format as necessary to match your input format + return DateTime.fromFormat(timeString, 'H:mm'); +}; + +export const ModifyTicketModal = ({ + isOpen, + onClose, + formData, + eventDate, + allTickets, + currentTicket, + setCurrentTicket, + editedTicket, +}: ModifyTicketModalProps) => { + const [errors, setErrors] = useState(defaultErrors); + + const [isSalesValidModalOpen, setIsSalesValidModalOpen] = useState(false); + const [isPassValidModalOpen, setIsPassValidModalOpen] = useState(false); + const [preview, setPreview] = useState(); + const inputRef = useRef(null); + + const validateForm = () => { + let isErr = false; + // Create a new object with the properties of defaultErrors to ensure a new reference + const newErrors = { ...defaultErrors }; + if (!currentTicket.name) { + newErrors.name = 'Name is required'; + isErr = true; + } + + if (!editedTicket && allTickets.some((ticket) => ticket.name === currentTicket.name)) { + newErrors.name = 'Name must be unique'; + isErr = true; + } + + if (!currentTicket.description) { + newErrors.description = 'Description is required'; + isErr = true; + } + + if (!currentTicket.salesValidThrough.startDate) { + newErrors.salesValidThrough = 'Sales valid through is required'; + isErr = true; + } + + if (!currentTicket.passValidThrough.startDate) { + newErrors.passValidThrough = 'Pass valid through is required'; + isErr = true; + } + + if (currentTicket.salesValidThrough.endTime && currentTicket.salesValidThrough.startTime) { + const startTime = parseTime(currentTicket.salesValidThrough.startTime); + const endTime = parseTime(currentTicket.salesValidThrough.endTime); + + if (endTime <= startTime) { + newErrors.salesValidThrough = 'End time must be greater than start time'; + isErr = true; + } + } + + if (currentTicket.passValidThrough.endTime && currentTicket.passValidThrough.startTime) { + const startTime = parseTime(currentTicket.passValidThrough.startTime); + const endTime = parseTime(currentTicket.passValidThrough.endTime); + + // Use Luxon's isAfter method to compare times + if (endTime <= startTime) { + newErrors.passValidThrough = 'End time must be greater than start time'; + isErr = true; + } + } + + if (currentTicket.maxSupply < 1) { + newErrors.maxSupply = 'Max supply is required'; + isErr = true; + } + + if (currentTicket.maxPurchases < 1) { + newErrors.maxPurchases = 'Max purchases is required'; + isErr = true; + } + + if (!isValidNonNegativeNumber(currentTicket.priceNear)) { + newErrors.price = 'Price is required'; + isErr = true; + } + + if (!currentTicket.artwork) { + newErrors.artwork = 'Artwork is required'; + isErr = true; + } + + // Now newErrors is a new object, so setting it should trigger a re-render + setErrors(newErrors); + // eslint-disable-next-line no-console + + if (!isErr) { + onClose(true, editedTicket); + } + }; + + const margins = '3'; + + const datePickerCTA = ( + label: string, + errorField: string, + dateObject: DateAndTimeInfo, + onClick, + ) => ( + + + + ); + + useEffect(() => { + if (isSalesValidModalOpen) { + setIsPassValidModalOpen(false); + } + if (isPassValidModalOpen) { + setIsSalesValidModalOpen(false); + } + }, [isSalesValidModalOpen, isPassValidModalOpen]); + + useEffect(() => { + if (currentTicket.salesValidThrough.startDate) { + setErrors({ ...errors, salesValidThrough: '' }); + } + }, [currentTicket.salesValidThrough]); + + useEffect(() => { + if (currentTicket.passValidThrough.startDate) { + setErrors({ ...errors, passValidThrough: '' }); + } + }, [currentTicket.passValidThrough]); + + useEffect(() => { + const selectedFile = currentTicket.artwork; + if (selectedFile === undefined) { + setPreview(undefined); + return; + } + const objectUrl = URL.createObjectURL(selectedFile); + setPreview(objectUrl); + + return () => { + URL.revokeObjectURL(objectUrl); + }; + }, [currentTicket.artwork]); + + useEffect(() => { + // Reset errors when the modal is closed (i.e., isOpen changes from true to false) + if (!isOpen) { + setErrors(defaultErrors); + } + }, [isOpen]); + + useEffect(() => { + const inputEl = inputRef.current; + const preventScroll = (e: WheelEvent) => { + e.preventDefault(); + }; + + if (inputEl) { + // Attach the event listener without specifying the passive option + inputEl.addEventListener('wheel', preventScroll); + } + + return () => { + if (inputEl) { + // Clean up the event listener + inputEl.removeEventListener('wheel', preventScroll); + } + }; + }, []); + + const onSelectFile = (e) => { + if (!e.target.files || e.target.files.length === 0) { + setCurrentTicket({ ...currentTicket, artwork: undefined }); + return; + } + + setCurrentTicket({ ...currentTicket, artwork: e.target.files[0] }); + }; + + return ( + { + onClose(false, editedTicket); + }} + > + + + + + {/* Top-left: Form Section */} + + + + { + setErrors({ ...errors, name: '' }); + setCurrentTicket({ ...currentTicket, name: e.target.value }); + }} + /> + + +