diff --git a/template/customization/bottombar-native.tsx b/template/customization/bottombar-native.tsx new file mode 100644 index 000000000..e318c95a3 --- /dev/null +++ b/template/customization/bottombar-native.tsx @@ -0,0 +1,30 @@ +import React from 'react'; +import {ToolbarPreset, useSidePanel} from 'customization-api'; +import { + PollButtonSidePanelTrigger, + POLL_SIDEBAR_NAME, +} from './polling/components/PollButtonSidePanelTrigger'; + +const NativeBottomToolbar = () => { + const {setSidePanel} = useSidePanel(); + + return ( + { + setSidePanel(POLL_SIDEBAR_NAME); + }, + }, + }, + }, + }} + /> + ); +}; + +export default NativeBottomToolbar; diff --git a/template/customization/index.tsx b/template/customization/index.tsx new file mode 100644 index 000000000..3dd93ace5 --- /dev/null +++ b/template/customization/index.tsx @@ -0,0 +1,28 @@ +import {$config, customize} from 'customization-api'; +import NativeBottomToolbar from './bottombar-native'; +import PollSidebar from './polling/components/PollSidebar'; +import Poll from './polling/components/Poll'; +import {POLL_SIDEBAR_NAME} from './polling/components/PollButtonSidePanelTrigger'; + +const config = customize({ + components: { + videoCall: { + wrapper: Poll, + bottomToolBar: NativeBottomToolbar, + customSidePanel: () => { + return [ + { + name: POLL_SIDEBAR_NAME, + component: PollSidebar, + title: 'Polls', + onClose: () => {}, + }, + ]; + }, + }, + }, +}); + +const isLiveMode = $config.AUDIO_ROOM || $config.EVENT_MODE; + +export default isLiveMode ? {} : config; diff --git a/template/customization/package.json b/template/customization/package.json new file mode 100644 index 000000000..b53a61759 --- /dev/null +++ b/template/customization/package.json @@ -0,0 +1,3 @@ +{ + "name": "customization" +} diff --git a/template/customization/polling/components/Poll.tsx b/template/customization/polling/components/Poll.tsx new file mode 100644 index 000000000..742eb76e3 --- /dev/null +++ b/template/customization/polling/components/Poll.tsx @@ -0,0 +1,87 @@ +import React, {useMemo, useCallback} from 'react'; +import {PollModalType, PollProvider, usePoll} from '../context/poll-context'; +import PollFormWizardModal from './modals/PollFormWizardModal'; +import {PollEventsProvider, PollEventsSubscriber} from '../context/poll-events'; +import PollResponseFormModal from './modals/PollResponseFormModal'; +import PollResultModal from './modals/PollResultModal'; +import PollConfirmModal from './modals/PollEndConfirmModal'; +import PollItemNotFound from './modals/PollItemNotFound'; +import {log} from '../helpers'; + +function Poll({children}: {children?: React.ReactNode}) { + return ( + + + + {children} + + + + + ); +} + +function PollModals() { + const {modalState, polls} = usePoll(); + // Log only in development mode to prevent performance hits + if (process.env.NODE_ENV === 'development') { + log('polls data changed: ', polls); + } + + const renderModal = useCallback(() => { + switch (modalState.modalType) { + case PollModalType.DRAFT_POLL: + if (modalState.id && polls[modalState.id]) { + const editFormObject = {...polls[modalState.id]}; + return ( + + ); + } + return ; + case PollModalType.PREVIEW_POLL: + if (modalState.id && polls[modalState.id]) { + const previewFormObject = {...polls[modalState.id]}; + return ( + + ); + } + break; + case PollModalType.RESPOND_TO_POLL: + if (modalState.id && polls[modalState.id]) { + return ; + } + return ; + case PollModalType.VIEW_POLL_RESULTS: + if (modalState.id && polls[modalState.id]) { + return ; + } + return ; + case PollModalType.END_POLL_CONFIRMATION: + if (modalState.id && polls[modalState.id]) { + return ; + } + return ; + case PollModalType.DELETE_POLL_CONFIRMATION: + if (modalState.id && polls[modalState.id]) { + return ( + + ); + } + return ; + case PollModalType.NONE: + break; + default: + log('Unknown modal type: ', modalState); + return <>; + } + }, [modalState, polls]); + + const memoizedModal = useMemo(() => renderModal(), [renderModal]); + + return <>{memoizedModal}; +} + +export default Poll; diff --git a/template/customization/polling/components/PollAvatarHeader.tsx b/template/customization/polling/components/PollAvatarHeader.tsx new file mode 100644 index 000000000..d584f05fe --- /dev/null +++ b/template/customization/polling/components/PollAvatarHeader.tsx @@ -0,0 +1,86 @@ +import {Text, View, StyleSheet} from 'react-native'; +import React from 'react'; +import {PollItem} from '../context/poll-context'; +import { + useContent, + UserAvatar, + ThemeConfig, + useString, + videoRoomUserFallbackText, + UidType, + $config, +} from 'customization-api'; + +interface Props { + pollItem: PollItem; +} + +function PollAvatarHeader({pollItem}: Props) { + const remoteUserDefaultLabel = useString(videoRoomUserFallbackText)(); + const {defaultContent} = useContent(); + + const getPollCreaterName = ({uid, name}: {uid: UidType; name: string}) => { + return defaultContent[uid]?.name || name || remoteUserDefaultLabel; + }; + + return ( + + + + + + + {getPollCreaterName(pollItem.createdBy)} + + {pollItem.type} + + + ); +} +export const style = StyleSheet.create({ + titleCard: { + display: 'flex', + flexDirection: 'row', + gap: 12, + }, + title: { + display: 'flex', + flexDirection: 'column', + gap: 2, + }, + titleAvatar: { + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + }, + titleAvatarContainer: { + width: 36, + height: 36, + borderRadius: 18, + backgroundColor: $config.VIDEO_AUDIO_TILE_AVATAR_COLOR, + }, + titleAvatarContainerText: { + fontSize: ThemeConfig.FontSize.small, + lineHeight: 16, + fontWeight: '600', + color: $config.VIDEO_AUDIO_TILE_COLOR, + }, + titleText: { + color: $config.FONT_COLOR, + fontSize: ThemeConfig.FontSize.normal, + fontWeight: '700', + lineHeight: 20, + }, + titleSubtext: { + color: $config.FONT_COLOR, + fontSize: ThemeConfig.FontSize.tiny, + fontWeight: '400', + lineHeight: 16, + }, +}); + +export default PollAvatarHeader; diff --git a/template/customization/polling/components/PollButtonSidePanelTrigger.tsx b/template/customization/polling/components/PollButtonSidePanelTrigger.tsx new file mode 100644 index 000000000..c568de4a7 --- /dev/null +++ b/template/customization/polling/components/PollButtonSidePanelTrigger.tsx @@ -0,0 +1,92 @@ +import React from 'react'; +import { + useSidePanel, + ToolbarItem, + ImageIcon, + ThemeConfig, + $config, + useActionSheet, + IconButton, + IconButtonProps, +} from 'customization-api'; +import {View, Text, StyleSheet} from 'react-native'; +import pollIcons from '../poll-icons'; + +const POLL_SIDEBAR_NAME = 'side-panel-poll'; + +const PollButtonSidePanelTrigger = () => { + const {isOnActionSheet} = useActionSheet(); + const {sidePanel, setSidePanel} = useSidePanel(); + + const isPollPanelActive = sidePanel === POLL_SIDEBAR_NAME; + // On smaller screens + if (isOnActionSheet) { + const iconButtonProps: IconButtonProps = { + onPress: () => { + setSidePanel(POLL_SIDEBAR_NAME); + }, + iconProps: { + icon: pollIcons['bar-chart'], + tintColor: isPollPanelActive + ? $config.PRIMARY_ACTION_TEXT_COLOR + : $config.SECONDARY_ACTION_COLOR, + iconBackgroundColor: isPollPanelActive + ? $config.PRIMARY_ACTION_BRAND_COLOR + : '', + }, + btnTextProps: { + text: 'Polls', + textColor: $config.FONT_COLOR, + numberOfLines: 1, + textStyle: { + marginTop: 8, + }, + }, + isOnActionSheet: isOnActionSheet, + }; + + return ( + + + + ); + } + // On bigger screens + return ( + + + + + Polls + + ); +}; + +export {PollButtonSidePanelTrigger, POLL_SIDEBAR_NAME}; + +const style = StyleSheet.create({ + toolbarItem: { + display: 'flex', + flexDirection: 'row', + }, + toolbarImg: { + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + marginRight: 8, + }, + toolbarText: { + color: $config.SECONDARY_ACTION_COLOR, + fontSize: ThemeConfig.FontSize.normal, + fontWeight: '400', + fontFamily: ThemeConfig.FontFamily.sansPro, + }, + spacing: { + margin: 12, + }, +}); diff --git a/template/customization/polling/components/PollCard.tsx b/template/customization/polling/components/PollCard.tsx new file mode 100644 index 000000000..6da7099b6 --- /dev/null +++ b/template/customization/polling/components/PollCard.tsx @@ -0,0 +1,317 @@ +import React from 'react'; +import {Text, View, StyleSheet, TouchableOpacity} from 'react-native'; +import { + PollItem, + PollStatus, + PollTaskRequestTypes, + usePoll, +} from '../context/poll-context'; +import { + ThemeConfig, + TertiaryButton, + useLocalUid, + $config, + LinkButton, + ImageIcon, +} from 'customization-api'; +import {BaseMoreButton} from '../ui/BaseMoreButton'; +import {PollCardMoreActions} from './PollCardMoreActions'; +import {capitalizeFirstLetter, getPollTypeDesc, hasUserVoted} from '../helpers'; +import { + PollFormSubmitButton, + PollRenderResponseFormBody, +} from './form/poll-response-forms'; +import {usePollPermissions} from '../hook/usePollPermissions'; +import {usePollForm} from '../hook/usePollForm'; + +const PollCardHeader = ({pollItem}: {pollItem: PollItem}) => { + const moreBtnRef = React.useRef(null); + const [actionMenuVisible, setActionMenuVisible] = + React.useState(false); + const {editPollForm, handlePollTaskRequest} = usePoll(); + const {canEdit} = usePollPermissions({pollItem}); + + return ( + + + + {getPollTypeDesc(pollItem.type, pollItem.multiple_response)} + + {pollItem.status === PollStatus.LATER && ( + <> + + Draft + + )} + + {canEdit && ( + + {pollItem.status === PollStatus.LATER && ( + { + editPollForm(pollItem.id); + }}> + + + Edit + + + )} + + { + handlePollTaskRequest(action, pollItem.id); + setActionMenuVisible(false); + }} + /> + + )} + + ); +}; + +const PollCardContent = ({pollItem}: {pollItem: PollItem}) => { + const {sendResponseToPoll} = usePoll(); + const {canViewPollDetails} = usePollPermissions({pollItem}); + const localUid = useLocalUid(); + const hasSubmittedResponse = hasUserVoted(pollItem.options, localUid); + + const onFormSubmit = (responses: string | string[]) => { + sendResponseToPoll(pollItem, responses); + }; + + const onFormSubmitComplete = () => { + // console.log('supriya'); + // Declaring this method just to have buttonVisible working + }; + + const { + onSubmit, + selectedOption, + handleRadioSelect, + selectedOptions, + handleCheckboxToggle, + answer, + setAnswer, + buttonText, + submitDisabled, + buttonStatus, + buttonVisible, + } = usePollForm({ + pollItem, + initialSubmitted: hasSubmittedResponse, + onFormSubmit, + onFormSubmitComplete, + }); + + return ( + + + {capitalizeFirstLetter(pollItem.question)} + + {pollItem.status === PollStatus.LATER ? ( + <> + ) : ( + <> + + {(hasSubmittedResponse && !buttonVisible) || + pollItem.status === PollStatus.FINISHED ? ( + <> + ) : ( + + + + )} + + )} + + ); +}; + +const PollCardFooter = ({pollItem}: {pollItem: PollItem}) => { + const {handlePollTaskRequest} = usePoll(); + const {canEnd, canViewPollDetails} = usePollPermissions({pollItem}); + + return ( + + {canEnd && pollItem.status === PollStatus.ACTIVE && ( + + { + handlePollTaskRequest( + PollTaskRequestTypes.FINISH_CONFIRMATION, + pollItem.id, + ); + }} + /> + + )} + {canViewPollDetails && ( + + + + handlePollTaskRequest( + PollTaskRequestTypes.VIEW_DETAILS, + pollItem.id, + ) + } + /> + + + )} + + ); +}; + +function PollCard({pollItem}: {pollItem: PollItem}) { + return ( + + + + + {pollItem.status !== PollStatus.LATER && ( + <> + + + )} + + + ); +} +export {PollCard}; + +const style = StyleSheet.create({ + fullWidth: { + alignSelf: 'stretch', + }, + pollItem: { + marginVertical: 12, + }, + btnContainer: { + minHeight: 36, + paddingVertical: 9, + paddingHorizontal: 8, + }, + pollCard: { + display: 'flex', + flexDirection: 'column', + gap: 8, + padding: 12, + borderRadius: 12, + borderWidth: 2, + borderColor: $config.CARD_LAYER_3_COLOR, + backgroundColor: $config.CARD_LAYER_1_COLOR, + }, + pollCardHeader: { + height: 24, + display: 'flex', + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + }, + pollCardHeaderText: { + color: $config.FONT_COLOR + ThemeConfig.EmphasisPlus.low, + fontSize: ThemeConfig.FontSize.tiny, + fontFamily: ThemeConfig.FontFamily.sansPro, + fontWeight: '600', + lineHeight: 12, + }, + pollCardContent: { + display: 'flex', + flexDirection: 'column', + gap: 12, + alignSelf: 'stretch', + alignItems: 'flex-start', + }, + pollCardContentQuestionText: { + color: $config.FONT_COLOR + ThemeConfig.EmphasisPlus.high, + fontSize: ThemeConfig.FontSize.normal, + fontFamily: ThemeConfig.FontFamily.sansPro, + fontWeight: '700', + lineHeight: 19, + }, + pollCardFooter: { + display: 'flex', + flexDirection: 'column', + gap: 8, + }, + linkBtnContainer: { + minHeight: 36, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + paddingVertical: 9, + paddingHorizontal: 8, + }, + linkText: { + textAlign: 'center', + color: $config.PRIMARY_ACTION_BRAND_COLOR, + fontSize: ThemeConfig.FontSize.small, + fontFamily: ThemeConfig.FontFamily.sansPro, + fontWeight: '600', + lineHeight: 16, + }, + space: { + marginHorizontal: 8, + }, + row: { + display: 'flex', + flexDirection: 'row', + alignItems: 'center', + }, + gap8: { + gap: 8, + }, + gap5: { + gap: 5, + }, + mr8: { + marginRight: 8, + }, + dot: { + width: 5, + height: 5, + borderRadius: 3, + backgroundColor: $config.FONT_COLOR + ThemeConfig.EmphasisPlus.low, + }, + alignRight: { + alignSelf: 'flex-end', + }, +}); diff --git a/template/customization/polling/components/PollCardMoreActions.tsx b/template/customization/polling/components/PollCardMoreActions.tsx new file mode 100644 index 000000000..2704d76dd --- /dev/null +++ b/template/customization/polling/components/PollCardMoreActions.tsx @@ -0,0 +1,150 @@ +import React, {Dispatch, SetStateAction} from 'react'; +import {View, useWindowDimensions} from 'react-native'; +import { + ActionMenu, + ActionMenuItem, + calculatePosition, + ThemeConfig, + $config, + isMobileUA, +} from 'customization-api'; +import {PollStatus, PollTaskRequestTypes} from '../context/poll-context'; + +interface PollCardMoreActionsMenuProps { + status: PollStatus; + moreBtnRef: React.RefObject; + actionMenuVisible: boolean; + setActionMenuVisible: Dispatch>; + onCardActionSelect: (action: PollTaskRequestTypes) => void; +} +const PollCardMoreActions = (props: PollCardMoreActionsMenuProps) => { + const { + actionMenuVisible, + setActionMenuVisible, + moreBtnRef, + onCardActionSelect, + status, + } = props; + const actionMenuItems: ActionMenuItem[] = []; + const [modalPosition, setModalPosition] = React.useState({}); + const [isPosCalculated, setIsPosCalculated] = React.useState(false); + const {width: globalWidth, height: globalHeight} = useWindowDimensions(); + + status !== PollStatus.FINISHED && + actionMenuItems.push({ + icon: 'send', + iconColor: $config.SECONDARY_ACTION_COLOR, + textColor: $config.FONT_COLOR, + title: 'Launch Poll', + titleStyle: { + fontSize: ThemeConfig.FontSize.small, + }, + disabled: status !== PollStatus.LATER, + onPress: () => { + onCardActionSelect(PollTaskRequestTypes.SEND); + setActionMenuVisible(false); + }, + }); + + // status === PollStatus.ACTIVE && + // actionMenuItems.push({ + // icon: 'share', + // iconColor: $config.SECONDARY_ACTION_COLOR, + // textColor: $config.FONT_COLOR, + // title: 'Publish Result', + // titleStyle: { + // fontSize: ThemeConfig.FontSize.small, + // }, + // onPress: () => { + // onCardActionSelect(PollTaskRequestTypes.PUBLISH); + // setActionMenuVisible(false); + // }, + // }); + + // Export is only on web + !isMobileUA() && + status !== PollStatus.LATER && + actionMenuItems.push({ + icon: 'download', + iconColor: $config.SECONDARY_ACTION_COLOR, + textColor: $config.FONT_COLOR, + title: 'Export Results', + titleStyle: { + fontSize: ThemeConfig.FontSize.small, + }, + onPress: () => { + onCardActionSelect(PollTaskRequestTypes.EXPORT); + setActionMenuVisible(false); + }, + }); + + actionMenuItems.push({ + icon: 'close', + iconColor: $config.SECONDARY_ACTION_COLOR, + textColor: $config.FONT_COLOR, + title: 'End Poll', + titleStyle: { + fontSize: ThemeConfig.FontSize.small, + }, + disabled: status !== PollStatus.ACTIVE, + onPress: () => { + onCardActionSelect(PollTaskRequestTypes.FINISH_CONFIRMATION); + setActionMenuVisible(false); + }, + }); + + actionMenuItems.push({ + icon: 'delete', + iconColor: $config.SEMANTIC_ERROR, + textColor: $config.SEMANTIC_ERROR, + title: 'Delete Poll', + titleStyle: { + fontSize: ThemeConfig.FontSize.small, + }, + onPress: () => { + onCardActionSelect(PollTaskRequestTypes.DELETE_CONFIRMATION); + setActionMenuVisible(false); + }, + }); + + React.useEffect(() => { + if (actionMenuVisible && moreBtnRef.current) { + //getting btnRef x,y + moreBtnRef?.current?.measure( + ( + _fx: number, + _fy: number, + localWidth: number, + localHeight: number, + px: number, + py: number, + ) => { + const data = calculatePosition({ + px, + py, + localWidth, + localHeight, + globalHeight, + globalWidth, + }); + setModalPosition(data); + setIsPosCalculated(true); + }, + ); + } + }, [actionMenuVisible, globalWidth, globalHeight, moreBtnRef]); + + return ( + <> + + + ); +}; + +export {PollCardMoreActions}; diff --git a/template/customization/polling/components/PollList.tsx b/template/customization/polling/components/PollList.tsx new file mode 100644 index 000000000..5016f961f --- /dev/null +++ b/template/customization/polling/components/PollList.tsx @@ -0,0 +1,94 @@ +import React, {useState, useEffect} from 'react'; +import {View} from 'react-native'; +import {PollCard} from './PollCard'; +import {PollItem, PollStatus, usePoll} from '../context/poll-context'; +import { + BaseAccordion, + BaseAccordionItem, + BaseAccordionHeader, + BaseAccordionContent, +} from '../ui/BaseAccordian'; +import {useLocalUid} from 'customization-api'; + +type PollsGrouped = Array<{key: string; poll: PollItem}>; + +export default function PollList() { + const {polls} = usePoll(); + const localUid = useLocalUid(); + + // State to keep track of the currently open accordion + const [openAccordion, setOpenAccordion] = useState(null); + + // Group polls by their status + const groupedPolls = Object.entries(polls).reduce( + (acc, [key, poll]) => { + // Check if the poll should be included in the LATER group based on creator + if (poll.status === PollStatus.LATER && poll.createdBy.uid !== localUid) { + return acc; // Skip this poll if it doesn't match the localUid + } + // Otherwise, add the poll to the corresponding group + acc[poll.status].push({key, poll}); + return acc; + }, + { + [PollStatus.LATER]: [] as PollsGrouped, + [PollStatus.ACTIVE]: [] as PollsGrouped, + [PollStatus.FINISHED]: [] as PollsGrouped, + }, + ); + + // Destructure grouped polls for easy access + const { + LATER: draftPolls, + ACTIVE: activePolls, + FINISHED: finishedPolls, + } = groupedPolls; + + // Set default open accordion based on priority: Active > Draft > Completed + useEffect(() => { + if (activePolls.length > 0) { + setOpenAccordion('active-accordion'); + } else if (draftPolls.length > 0) { + setOpenAccordion('draft-accordion'); + } else if (finishedPolls.length > 0) { + setOpenAccordion('finished-accordion'); + } + }, [activePolls, draftPolls, finishedPolls]); + + // Function to handle accordion toggling + const handleAccordionToggle = (id: string) => { + setOpenAccordion(prev => (prev === id ? null : id)); + }; + + // Render a section with its corresponding Accordion + const renderPollList = ( + pollsGrouped: PollsGrouped, + title: string, + id: string, + ) => { + return pollsGrouped.length ? ( + + + handleAccordionToggle(id)} + /> + + {pollsGrouped.map(({key, poll}) => ( + + ))} + + + + ) : null; + }; + + return ( + + {renderPollList(activePolls, 'Active', 'active-accordion')} + {renderPollList(draftPolls, 'Saved as Draft', 'draft-accordion')} + {renderPollList(finishedPolls, 'Completed', 'finished-accordion')} + + ); +} diff --git a/template/customization/polling/components/PollSidebar.tsx b/template/customization/polling/components/PollSidebar.tsx new file mode 100644 index 000000000..9eb7de5b7 --- /dev/null +++ b/template/customization/polling/components/PollSidebar.tsx @@ -0,0 +1,151 @@ +import React from 'react'; +import {View, StyleSheet, ScrollView, Text} from 'react-native'; +import { + PrimaryButton, + ThemeConfig, + $config, + ImageIcon, + useLocalUid, +} from 'customization-api'; +import {Poll, PollStatus, usePoll} from '../context/poll-context'; +import PollList from './PollList'; +import pollIcons from '../poll-icons'; +import {isWebOnly} from '../helpers'; +import {usePollPermissions} from '../hook/usePollPermissions'; + +function checkPolls(poll: Poll, localUid: number) { + // Check if there is any poll that is not in 'LATER' status + const nonLaterPollExists = Object.keys(poll).some( + pollId => poll[pollId].status !== PollStatus.LATER, + ); + // If there is any poll that is not 'LATER', return "Poll exists" + if (nonLaterPollExists) { + return true; + } + // If all polls are 'LATER' status, check if any were created by the local user + const createdByLocalUser = Object.keys(poll).some( + pollId => poll[pollId].createdBy.uid === localUid, + ); + // If all polls are 'LATER' status and none are created by the local user, return "There are no polls" + if (!createdByLocalUser) { + return false; + } + // Otherwise, some polls exist + return true; +} + +const PollSidebar = () => { + const {startPollForm, isHost, polls} = usePoll(); + const {canCreate} = usePollPermissions({}); + const localUid = useLocalUid(); + + return ( + + {!checkPolls(polls, localUid) ? ( + + + {isHost && ( + + + + )} + + {isHost + ? isWebOnly + ? 'Visit our web platform to create and manage polls.' + : 'Create a new poll and boost interaction with your audience.' + : 'No polls here yet...'} + + + + ) : ( + + + + )} + {canCreate ? ( + + startPollForm()} + text="+ Create Poll" + /> + + ) : ( + <> + )} + + ); +}; + +const style = StyleSheet.create({ + pollSidebar: { + display: 'flex', + flex: 1, + }, + pollFooter: { + padding: 12, + backgroundColor: $config.CARD_LAYER_3_COLOR, + }, + emptyCard: { + maxWidth: 220, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + gap: 12, + }, + emptyCardIcon: { + width: 72, + height: 72, + borderRadius: 12, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + backgroundColor: $config.CARD_LAYER_3_COLOR, + }, + emptyView: { + display: 'flex', + flex: 1, + alignItems: 'center', + justifyContent: 'center', + padding: 8, + }, + emptyText: { + color: $config.FONT_COLOR + ThemeConfig.EmphasisPlus.low, + fontSize: ThemeConfig.FontSize.small, + fontFamily: ThemeConfig.FontFamily.sansPro, + fontWeight: '400', + lineHeight: 20, + textAlign: 'center', + }, + scrollViewContent: {}, + bodyXSmallText: { + color: $config.FONT_COLOR + ThemeConfig.EmphasisPlus.medium, + fontSize: ThemeConfig.FontSize.small, + fontFamily: ThemeConfig.FontFamily.sansPro, + fontWeight: '400', + lineHeight: 16, + }, + btnContainer: { + minWidth: 150, + minHeight: 36, + borderRadius: 4, + paddingVertical: 10, + paddingHorizontal: 8, + }, + btnText: { + color: $config.PRIMARY_ACTION_TEXT_COLOR, + fontSize: ThemeConfig.FontSize.small, + fontFamily: ThemeConfig.FontFamily.sansPro, + fontWeight: '600', + textTransform: 'capitalize', + }, +}); + +export default PollSidebar; diff --git a/template/customization/polling/components/PollTimer.tsx b/template/customization/polling/components/PollTimer.tsx new file mode 100644 index 000000000..1475954d6 --- /dev/null +++ b/template/customization/polling/components/PollTimer.tsx @@ -0,0 +1,54 @@ +import React, {useEffect, useState} from 'react'; +import {Text, View, StyleSheet} from 'react-native'; +import {useCountdown} from '../hook/useCountdownTimer'; +import {ThemeConfig, $config} from 'customization-api'; + +interface Props { + expiresAt: number; + setFreezeForm?: React.Dispatch>; +} + +const padZero = (value: number) => { + return value.toString().padStart(2, '0'); +}; + +export default function PollTimer({expiresAt}: Props) { + const [days, hours, minutes, seconds] = useCountdown(expiresAt); + const [freeze, setFreeze] = useState(false); + + const getTime = () => { + if (days) { + return `${padZero(days)} : ${padZero(hours)} : ${padZero( + minutes, + )} : ${padZero(seconds)}`; + } + if (hours) { + return `${padZero(hours)} : ${padZero(minutes)} : ${padZero(seconds)}`; + } + if (minutes || seconds) { + return `${padZero(minutes)} : ${padZero(seconds)}`; + } + return '00 : 00'; + }; + + useEffect(() => { + if (days + hours + minutes + seconds === 0) { + setFreeze(true); + } + }, [days, hours, minutes, seconds, freeze]); + + return ( + + {getTime()} + + ); +} + +export const style = StyleSheet.create({ + timer: { + color: $config.SEMANTIC_WARNING, + fontFamily: ThemeConfig.FontFamily.sansPro, + fontSize: 16, + lineHeight: 20, + }, +}); diff --git a/template/customization/polling/components/common-styles.ts b/template/customization/polling/components/common-styles.ts new file mode 100644 index 000000000..4763261d3 --- /dev/null +++ b/template/customization/polling/components/common-styles.ts @@ -0,0 +1,19 @@ +import {StyleSheet} from 'react-native'; + +const CommonStyles = StyleSheet.create({ + btnContainerWeb: { + minWidth: 150, + minHeight: 36, + paddingVertical: 9, + paddingHorizontal: 8, + borderRadius: 4, + }, + btnContainerNative: { + minWidth: 100, + minHeight: 36, + paddingVertical: 9, + paddingHorizontal: 8, + borderRadius: 4, + }, +}); +export default CommonStyles; diff --git a/template/customization/polling/components/form/DraftPollFormView.tsx b/template/customization/polling/components/form/DraftPollFormView.tsx new file mode 100644 index 000000000..20b23cb61 --- /dev/null +++ b/template/customization/polling/components/form/DraftPollFormView.tsx @@ -0,0 +1,493 @@ +import { + Text, + View, + StyleSheet, + TextInput, + TouchableOpacity, +} from 'react-native'; +import React from 'react'; +import { + BaseModalTitle, + BaseModalContent, + BaseModalActions, + BaseModalCloseIcon, +} from '../../ui/BaseModal'; +import { + IconButton, + PrimaryButton, + ThemeConfig, + $config, + TertiaryButton, + ImageIcon, + PlatformWrapper, + isMobileUA, +} from 'customization-api'; +import {PollFormErrors, PollItem, PollKind} from '../../context/poll-context'; +import {nanoid} from 'nanoid'; +import BaseButtonWithToggle from '../../ui/BaseButtonWithToggle'; +import CommonStyles from '../common-styles'; + +function FormTitle({title}: {title: string}) { + return ( + + {title} + + ); +} +interface Props { + form: PollItem; + setForm: React.Dispatch>; + onPreview: () => void; + onSave: (launch?: boolean) => void; + errors: Partial; + onClose: () => void; +} + +export default function DraftPollFormView({ + form, + setForm, + onPreview, + errors, + onClose, + onSave, +}: Props) { + const handleInputChange = (field: string, value: string | boolean) => { + setForm({ + ...form, + [field]: value, + }); + }; + + const handleCheckboxChange = (field: keyof PollItem, value: boolean) => { + if (field === 'anonymous' && value) { + setForm({ + ...form, + [field]: value, + share_attendee: false, + share_host: false, + }); + return; + } else if (field === 'share_attendee' || field === 'share_host') { + if (value) { + setForm({ + ...form, + [field]: value, + anonymous: false, + }); + return; + } + } + setForm({ + ...form, + [field]: value, + }); + }; + + const updateFormOption = ( + action: 'update' | 'delete' | 'add', + value: string, + index: number, + ) => { + if (action === 'add') { + setForm({ + ...form, + options: [ + ...(form.options || []), + { + text: '', + value: '', + votes: [], + percent: '0', + }, + ], + }); + } + if (action === 'update') { + setForm(prevForm => ({ + ...prevForm, + options: prevForm.options?.map((option, i) => { + if (i === index) { + const text = value; + const lowerText = text + .replace(/\s+/g, '-') + .toLowerCase() + .concat('-') + .concat(nanoid(2)); + return { + ...option, + text: text, + value: lowerText, + }; + } + return option; + }), + })); + } + if (action === 'delete') { + setForm({ + ...form, + options: form.options?.filter((option, i) => i !== index) || [], + }); + } + }; + + return ( + <> + + + + + + {/* Question section */} + + {errors?.global && ( + {errors.global.message} + )} + + + + + { + handleInputChange('question', text); + }} + placeholder="Enter your question here..." + placeholderTextColor={ + $config.FONT_COLOR + ThemeConfig.EmphasisPlus.low + } + /> + {errors?.question && ( + {errors.question.message} + )} + + + {/* MCQ section */} + {form.type === PollKind.MCQ ? ( + + + + + + { + handleCheckboxChange('multiple_response', value); + }} + /> + + + + + {form.options?.map((option, index) => ( + + + + + { + updateFormOption('update', text, index); + }} + placeholder={`Option ${index + 1}`} + placeholderTextColor={ + $config.FONT_COLOR + ThemeConfig.EmphasisPlus.low + } + /> + {index > 1 ? ( + + { + updateFormOption('delete', '', index); + }} + /> + + ) : ( + <> + )} + + ))} + {form.options?.length < 5 ? ( + + {(isHovered: boolean) => ( + { + updateFormOption('add', '', -1); + }}> + + + Add option + + + )} + + ) : ( + <> + )} + {errors?.options && ( + {errors.options.message} + )} + + + ) : ( + <> + )} + {/* Yes / No section */} + {form.type === PollKind.YES_NO ? ( + + + + + + + + Yes + + + + + + No + + + + ) : ( + <> + )} + + + + + + { + try { + onSave(false); + } catch (error) { + console.error('Error saving form:', error); + } + }} + /> + + + { + try { + onPreview(); + } catch (error) { + console.error('Error previewing form:', error); + } + }} + /> + + + + + ); +} + +export const style = StyleSheet.create({ + pForm: { + display: 'flex', + flexDirection: 'column', + gap: 24, + }, + pFormSection: { + gap: 8, + }, + pFormSettings: { + display: 'flex', + flexDirection: 'column', + gap: 4, + padding: 16, + borderRadius: 8, + borderWidth: 1, + borderColor: $config.CARD_LAYER_2_COLOR, + }, + pFormTitle: { + color: $config.FONT_COLOR + ThemeConfig.EmphasisPlus.high, + fontSize: ThemeConfig.FontSize.small, + fontFamily: ThemeConfig.FontFamily.sansPro, + lineHeight: 16, + fontWeight: '600', + }, + pFormTitleRow: { + display: 'flex', + flexDirection: 'row', + alignItems: 'center', + }, + pFormTextarea: { + color: $config.FONT_COLOR + ThemeConfig.EmphasisPlus.high, + fontSize: ThemeConfig.FontSize.normal, + fontFamily: ThemeConfig.FontFamily.sansPro, + lineHeight: 16, + fontWeight: '400', + borderRadius: 8, + borderWidth: 1, + borderColor: $config.INPUT_FIELD_BORDER_COLOR, + backgroundColor: $config.INPUT_FIELD_BACKGROUND_COLOR, + height: 60, + outlineStyle: 'none', + padding: 20, + }, + pFormOptionText: { + color: $config.FONT_COLOR + ThemeConfig.EmphasisPlus.high, + fontSize: ThemeConfig.FontSize.normal, + fontFamily: ThemeConfig.FontFamily.sansPro, + lineHeight: 24, + fontWeight: '400', + }, + pFormOptionPrefix: { + color: $config.FONT_COLOR + ThemeConfig.EmphasisPlus.low, + paddingRight: 4, + }, + pFormOptionLink: { + color: $config.PRIMARY_ACTION_BRAND_COLOR, + height: 48, + paddingVertical: 12, + }, + pFormOptions: { + gap: 8, + }, + pFormInput: { + flex: 1, + color: $config.FONT_COLOR + ThemeConfig.EmphasisPlus.high, + fontSize: ThemeConfig.FontSize.normal, + fontFamily: ThemeConfig.FontFamily.sansPro, + lineHeight: 24, + fontWeight: '400', + outlineStyle: 'none', + borderColor: $config.INPUT_FIELD_BORDER_COLOR, + backgroundColor: $config.INPUT_FIELD_BACKGROUND_COLOR, + borderRadius: 8, + paddingVertical: 12, + height: 48, + }, + pFormSettingsText: { + color: $config.FONT_COLOR + ThemeConfig.EmphasisPlus.high, + fontSize: ThemeConfig.FontSize.tiny, + fontFamily: ThemeConfig.FontFamily.sansPro, + lineHeight: 12, + fontWeight: '400', + }, + pFormOptionCard: { + display: 'flex', + paddingHorizontal: 12, + flexDirection: 'row', + justifyContent: 'flex-start', + alignItems: 'center', + gap: 4, + backgroundColor: $config.INPUT_FIELD_BACKGROUND_COLOR, + borderColor: $config.INPUT_FIELD_BORDER_COLOR, + borderRadius: 8, + borderWidth: 1, + }, + noBorder: { + borderColor: 'transparent', + }, + pFormToggle: { + display: 'flex', + alignItems: 'center', + flexDirection: 'row', + gap: 12, + justifyContent: 'space-between', + position: 'relative', + }, + verticalPadding: { + paddingVertical: 12, + }, + pFormCheckboxContainer: {}, + previewActions: { + flex: 1, + display: 'flex', + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'flex-end', + gap: 16, + }, + btnText: { + color: $config.PRIMARY_ACTION_TEXT_COLOR, + fontSize: ThemeConfig.FontSize.small, + fontFamily: ThemeConfig.FontFamily.sansPro, + fontWeight: '600', + textTransform: 'capitalize', + }, + errorBorder: { + borderColor: $config.SEMANTIC_ERROR, + }, + hoverBorder: { + borderColor: $config.PRIMARY_ACTION_BRAND_COLOR, + }, + errorText: { + color: $config.SEMANTIC_ERROR, + fontSize: ThemeConfig.FontSize.tiny, + fontFamily: ThemeConfig.FontFamily.sansPro, + lineHeight: 12, + fontWeight: '400', + paddingTop: 8, + paddingLeft: 8, + }, + pushRight: { + marginLeft: 'auto', + }, +}); diff --git a/template/customization/polling/components/form/PreviewPollFormView.tsx b/template/customization/polling/components/form/PreviewPollFormView.tsx new file mode 100644 index 000000000..0c91c2971 --- /dev/null +++ b/template/customization/polling/components/form/PreviewPollFormView.tsx @@ -0,0 +1,224 @@ +import {Text, StyleSheet, View, TouchableOpacity} from 'react-native'; +import React from 'react'; +import { + BaseModalTitle, + BaseModalContent, + BaseModalActions, + BaseModalCloseIcon, +} from '../../ui/BaseModal'; +import {PollItem, PollKind} from '../../context/poll-context'; +import { + PrimaryButton, + TertiaryButton, + ThemeConfig, + $config, + ImageIcon, + isMobileUA, +} from 'customization-api'; +import CommonStyles from '../common-styles'; + +interface Props { + form: PollItem; + onEdit: () => void; + onSave: (launch: boolean) => void; + onClose: () => void; +} + +export default function PreviewPollFormView({ + form, + onEdit, + onSave, + onClose, +}: Props) { + return ( + <> + + + + + + + + + Here is a preview of the poll you will be sending + + + + { + try { + onEdit(); + } catch (error) { + console.error('Error editing form:', error); + } + }}> + + + Edit + + + + + + + + {form.question} + + {form.type === PollKind.MCQ || form.type === PollKind.YES_NO ? ( + + {form.options?.map((option, index) => ( + + {option.text} + + ))} + + ) : ( + <> + )} + + + + + + + + { + try { + onSave(false); + } catch (error) { + console.error('Error saving form:', error); + } + }} + /> + + + { + try { + onSave(true); + } catch (error) { + console.error('Error launching form:', error); + } + }} + /> + + + + + ); +} + +export const style = StyleSheet.create({ + previewContainer: { + // width: 550, + }, + previewInfoContainer: { + display: 'flex', + flexDirection: 'row', + justifyContent: 'space-between', + padding: 20, + }, + previewInfoText: { + color: $config.FONT_COLOR + ThemeConfig.EmphasisPlus.high, + fontSize: ThemeConfig.FontSize.normal, + fontFamily: ThemeConfig.FontFamily.sansPro, + lineHeight: 20, + fontWeight: '400', + }, + editSection: { + display: 'flex', + flexDirection: 'row', + alignItems: 'center', + gap: 5, + }, + editText: { + color: $config.PRIMARY_ACTION_BRAND_COLOR, + fontSize: ThemeConfig.FontSize.normal, + fontFamily: ThemeConfig.FontFamily.sansPro, + lineHeight: 20, + fontWeight: '600', + }, + previewFormContainer: { + backgroundColor: $config.BACKGROUND_COLOR, + paddingVertical: 40, + paddingHorizontal: 60, + }, + previewFormCard: { + display: 'flex', + flexDirection: 'column', + padding: 20, + gap: 12, + borderRadius: 20, + borderWidth: 1, + borderColor: $config.CARD_LAYER_4_COLOR, + backgroundColor: $config.CARD_LAYER_2_COLOR, + }, + previewQuestion: { + color: $config.FONT_COLOR + ThemeConfig.EmphasisPlus.high, + fontSize: ThemeConfig.FontSize.medium, + fontFamily: ThemeConfig.FontFamily.sansPro, + lineHeight: 24, + fontWeight: '600', + fontStyle: 'italic', + }, + previewOptionSection: { + display: 'flex', + flexDirection: 'column', + gap: 8, + }, + previewOptionCard: { + display: 'flex', + paddingHorizontal: 16, + paddingVertical: 10, + borderRadius: 6, + backgroundColor: $config.CARD_LAYER_4_COLOR, + }, + previewOptionText: { + color: $config.SECONDARY_ACTION_COLOR, + fontSize: ThemeConfig.FontSize.small, + fontFamily: ThemeConfig.FontFamily.sansPro, + fontWeight: '400', + lineHeight: 20, + fontStyle: 'italic', + }, + previewActions: { + flex: 1, + display: 'flex', + flexDirection: 'row', + justifyContent: 'flex-end', + gap: 16, + }, + btnContainer: { + minWidth: 150, + minHeight: 36, + borderRadius: 4, + paddingVertical: 9, + paddingHorizontal: 8, + }, + btnText: { + color: $config.PRIMARY_ACTION_TEXT_COLOR, + fontSize: ThemeConfig.FontSize.small, + fontFamily: ThemeConfig.FontFamily.sansPro, + fontWeight: '600', + textTransform: 'capitalize', + }, +}); diff --git a/template/customization/polling/components/form/SelectNewPollTypeFormView.tsx b/template/customization/polling/components/form/SelectNewPollTypeFormView.tsx new file mode 100644 index 000000000..b00714d48 --- /dev/null +++ b/template/customization/polling/components/form/SelectNewPollTypeFormView.tsx @@ -0,0 +1,179 @@ +import {Text, StyleSheet, View, TouchableOpacity} from 'react-native'; +import React from 'react'; +import { + BaseModalTitle, + BaseModalContent, + BaseModalCloseIcon, +} from '../../ui/BaseModal'; +import {PollKind} from '../../context/poll-context'; +import { + ThemeConfig, + $config, + ImageIcon, + hexadecimalTransparency, + PlatformWrapper, +} from 'customization-api'; +import {getPollTypeIcon} from '../../helpers'; + +interface newPollType { + key: PollKind; + image: null; + title: string; + description: string; +} + +const newPollTypeConfig: newPollType[] = [ + { + key: PollKind.YES_NO, + image: null, + title: 'Yes or No Question', + description: + 'A straightforward question that requires a simple Yes or No answer.', + }, + { + key: PollKind.MCQ, + image: null, + title: 'Multiple Choice Question', + description: + 'A question with several predefined answer options, allowing users to select one or more responses.', + }, + // { + // key: PollKind.OPEN_ENDED, + // image: 'question', + // title: 'Open Ended Question', + // description: + // 'A question that invites users to provide a detailed, free-form response, encouraging more in-depth feedback.', + // }, +]; + +export default function SelectNewPollTypeFormView({ + setType, + onClose, +}: { + setType: React.Dispatch>; + onClose: () => void; +}) { + return ( + <> + + + + + + + + What type of question would you like to ask? + + + + {newPollTypeConfig.map((item: newPollType) => ( + + {(isHovered: boolean) => { + return ( + { + setType(item.key); + }} + style={[style.card, isHovered ? style.cardHover : {}]}> + + + + + + {item.title} + + + {item.description} + + + + ); + }} + + ))} + + + + + ); +} + +export const style = StyleSheet.create({ + section: { + display: 'flex', + flexDirection: 'column', + gap: 20, + }, + sectionHeader: { + color: $config.FONT_COLOR, + fontSize: ThemeConfig.FontSize.normal, + fontFamily: ThemeConfig.FontFamily.sansPro, + lineHeight: 20, + fontWeight: '400', + }, + pollTypeList: { + display: 'flex', + flexDirection: 'column', + gap: 12, + }, + card: { + padding: 12, + flexDirection: 'row', + gap: 20, + outlineStyle: 'none', + alignItems: 'center', + borderWidth: 1, + borderColor: $config.CARD_LAYER_3_COLOR, + borderRadius: 8, + width: '100%', + }, + cardHover: { + backgroundColor: $config.SEMANTIC_NEUTRAL + hexadecimalTransparency['10%'], + }, + cardImage: { + width: 100, + height: 60, + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + borderRadius: 8, + backgroundColor: $config.CARD_LAYER_3_COLOR, + }, + cardImageHover: { + backgroundColor: $config.CARD_LAYER_4_COLOR, + }, + cardContent: { + display: 'flex', + flexDirection: 'column', + gap: 4, + flexShrink: 1, + }, + cardContentTitle: { + color: $config.FONT_COLOR + ThemeConfig.EmphasisPlus.high, + fontSize: ThemeConfig.FontSize.small, + fontFamily: ThemeConfig.FontFamily.sansPro, + lineHeight: 16, + fontWeight: '700', + }, + cardContentDesc: { + color: $config.FONT_COLOR + ThemeConfig.EmphasisPlus.low, + fontSize: ThemeConfig.FontSize.tiny, + fontFamily: ThemeConfig.FontFamily.sansPro, + lineHeight: 16, + fontWeight: '400', + }, +}); diff --git a/template/customization/polling/components/form/form-config.ts b/template/customization/polling/components/form/form-config.ts new file mode 100644 index 000000000..98a900844 --- /dev/null +++ b/template/customization/polling/components/form/form-config.ts @@ -0,0 +1,106 @@ +import {nanoid} from 'nanoid'; +import {PollKind, PollItem, PollStatus} from '../../context/poll-context'; + +const POLL_DURATION = 600; // takes seconds + +const getPollExpiresAtTime = (interval: number): number => { + const t = new Date(); + const expiresAT = t.setSeconds(t.getSeconds() + interval); + return expiresAT; +}; + +const initPollForm = ( + kind: PollKind, + user: {uid: number; name: string}, +): PollItem => { + if (kind === PollKind.OPEN_ENDED) { + return { + id: nanoid(4), + type: PollKind.OPEN_ENDED, + status: PollStatus.LATER, + question: '', + answers: null, + options: null, + multiple_response: false, + share_attendee: true, + share_host: true, + anonymous: false, + duration: false, + expiresAt: 0, + createdAt: Date.now(), + createdBy: {...user}, + }; + } + if (kind === PollKind.MCQ) { + return { + id: nanoid(4), + type: PollKind.MCQ, + status: PollStatus.LATER, + question: '', + answers: null, + options: [ + { + text: '', + value: '', + votes: [], + percent: '0', + }, + { + text: '', + value: '', + votes: [], + percent: '0', + }, + { + text: '', + value: '', + votes: [], + percent: '0', + }, + ], + multiple_response: false, + share_attendee: true, + share_host: true, + anonymous: false, + duration: false, + expiresAt: 0, + createdAt: Date.now(), + createdBy: {...user}, + }; + } + if (kind === PollKind.YES_NO) { + return { + id: nanoid(4), + type: PollKind.YES_NO, + status: PollStatus.LATER, + question: '', + answers: null, + options: [ + { + text: 'Yes', + value: 'yes', + votes: [], + percent: '0', + }, + { + text: 'No', + value: 'no', + votes: [], + percent: '0', + }, + ], + multiple_response: false, + share_attendee: true, + share_host: true, + anonymous: false, + duration: false, + expiresAt: 0, + createdAt: Date.now(), + createdBy: {...user}, + }; + } + // If none of the above conditions are met, throw an error or return a default value + throw new Error(`Unknown PollKind: ${kind}`); +}; + +export {getPollExpiresAtTime, initPollForm, POLL_DURATION}; diff --git a/template/customization/polling/components/form/poll-response-forms.tsx b/template/customization/polling/components/form/poll-response-forms.tsx new file mode 100644 index 000000000..4da941231 --- /dev/null +++ b/template/customization/polling/components/form/poll-response-forms.tsx @@ -0,0 +1,409 @@ +import { + Text, + View, + StyleSheet, + TextInput, + TouchableWithoutFeedback, +} from 'react-native'; +import React, {useState} from 'react'; +import {PollKind, PollStatus} from '../../context/poll-context'; +import { + ImageIcon, + Checkbox, + PrimaryButton, + ThemeConfig, + $config, + useLocalUid, + PlatformWrapper, + isMobileUA, +} from 'customization-api'; +import BaseRadioButton from '../../ui/BaseRadioButton'; +import { + PollOptionList, + PollOptionInputListItem, + PollItemFill, +} from '../poll-option-item-ui'; +import {PollFormButton, PollFormInput} from '../../hook/usePollForm'; +import CommonStyles from '../common-styles'; + +function PollResponseFormComplete() { + return ( + + + + + + Thank you for your response + + + ); +} + +function PollRenderResponseFormBody( + props: PollFormInput & { + submitted: boolean; + submitting: boolean; + }, +): JSX.Element { + // Directly use switch case logic inside the render + switch (props.pollItem.type) { + // case PollKind.OPEN_ENDED: + // return ( + // + // ); + case PollKind.MCQ: + case PollKind.YES_NO: + return ; + default: + console.error('Unknown poll type:', props.pollItem.type); + return Unknown poll type; + } +} + +function PollResponseQuestionForm() { + const [answer, setAnswer] = useState(''); + + return ( + + + + + + ); +} + +function PollResponseMCQForm({ + pollItem, + selectedOptions, + submitted, + handleCheckboxToggle, + selectedOption, + handleRadioSelect, + submitting, +}: Partial & { + submitted: boolean; + submitting: boolean; +}) { + const localUid = useLocalUid(); + return ( + + + {pollItem.multiple_response + ? pollItem.options?.map((option, index) => { + const myVote = option.votes.some(item => item.uid === localUid); + const checked = selectedOptions.includes(option?.value) || myVote; + submitted = submitted || pollItem.status === PollStatus.FINISHED; + return ( + + + + {(isHovered: boolean) => ( + + <> + + + handleCheckboxToggle(option?.value) + } + /> + + + )} + + + + ); + }) + : pollItem.options?.map((option, index) => { + const myVote = option.votes.some(item => item.uid === localUid); + const checked = selectedOption === option.value || myVote; + submitted = submitted || pollItem.status === PollStatus.FINISHED; + return ( + + + + {(isHovered: boolean) => ( + + <> + + + + + )} + + + + ); + })} + + + ); +} + +function PollFormSubmitButton({ + buttonText, + submitDisabled, + onSubmit, + buttonStatus, +}: Partial) { + // Define the styles based on button states + const getButtonColor = () => { + switch (buttonStatus) { + case 'initial': + return {backgroundColor: $config.SEMANTIC_NEUTRAL}; + case 'selected': + return {backgroundColor: $config.PRIMARY_ACTION_BRAND_COLOR}; + case 'submitting': + return { + backgroundColor: $config.PRIMARY_ACTION_BRAND_COLOR, + opacity: 0.7, + }; + case 'submitted': + return {backgroundColor: $config.SEMANTIC_SUCCESS}; + default: + return {}; + } + }; + return ( + { + if (buttonStatus === 'submitted') { + return; + } else { + onSubmit(); + } + }} + text={buttonText} + /> + ); +} + +export { + PollResponseQuestionForm, + PollResponseMCQForm, + PollResponseFormComplete, + PollRenderResponseFormBody, + PollFormSubmitButton, +}; + +export const style = StyleSheet.create({ + optionsForm: { + display: 'flex', + flexDirection: 'column', + gap: 20, + width: '100%', + }, + thankyouText: { + color: $config.FONT_COLOR + ThemeConfig.EmphasisPlus.high, + fontSize: ThemeConfig.FontSize.medium, + fontFamily: ThemeConfig.FontFamily.sansPro, + lineHeight: 24, + fontWeight: '600', + }, + pFormTextarea: { + color: $config.FONT_COLOR + ThemeConfig.EmphasisPlus.high, + fontSize: ThemeConfig.FontSize.small, + fontFamily: ThemeConfig.FontFamily.sansPro, + lineHeight: 16, + fontWeight: '400', + borderRadius: 8, + borderWidth: 1, + borderColor: $config.INPUT_FIELD_BORDER_COLOR, + backgroundColor: $config.INPUT_FIELD_BACKGROUND_COLOR, + height: 110, + outlineStyle: 'none', + padding: 16, + }, + responseActions: { + flex: 1, + display: 'flex', + flexDirection: 'row', + alignItems: 'center', + }, + btnContainer: { + minWidth: 150, + minHeight: 36, + borderRadius: 4, + paddingVertical: 9, + paddingHorizontal: 8, + }, + submittedBtn: { + backgroundColor: $config.SEMANTIC_SUCCESS, + cursor: 'default', + }, + btnText: { + color: $config.PRIMARY_ACTION_TEXT_COLOR, + fontSize: ThemeConfig.FontSize.small, + fontFamily: ThemeConfig.FontFamily.sansPro, + fontWeight: '600', + textTransform: 'capitalize', + }, + optionListItem: { + display: 'flex', + flexDirection: 'row', + padding: 12, + alignItems: 'center', + borderWidth: 1, + borderColor: $config.CARD_LAYER_3_COLOR, + backgroundColor: $config.CARD_LAYER_3_COLOR, + }, + optionText: { + color: $config.FONT_COLOR, + fontSize: ThemeConfig.FontSize.normal, + fontFamily: ThemeConfig.FontFamily.sansPro, + fontWeight: '400', + lineHeight: 24, + flexBasis: '73%', + }, + pFormInput: { + flex: 1, + color: $config.FONT_COLOR + ThemeConfig.EmphasisPlus.high, + fontSize: ThemeConfig.FontSize.small, + fontFamily: ThemeConfig.FontFamily.sansPro, + lineHeight: 16, + fontWeight: '400', + outlineStyle: 'none', + backgroundColor: $config.INPUT_FIELD_BACKGROUND_COLOR, + borderRadius: 9, + paddingVertical: 12, + }, + centerAlign: { + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + justifyContent: 'center', + gap: 12, + }, + mediumHeight: { + height: 272, + }, + checkboxContainer: { + display: 'flex', + flexDirection: 'row', + alignItems: 'center', + padding: 12, + width: '100%', + }, + checkBox: { + borderColor: $config.FONT_COLOR, + alignSelf: 'flex-start', + marginTop: 6, + }, + checkboxVoted: { + borderColor: $config.PRIMARY_ACTION_BRAND_COLOR, + backgroundColor: $config.PRIMARY_ACTION_BRAND_COLOR, + alignSelf: 'flex-start', + marginTop: 6, + }, + checkboxSubmittedAndVoted: { + borderColor: $config.FONT_COLOR, + backgroundColor: $config.FONT_COLOR, + alignSelf: 'flex-start', + marginTop: 6, + }, + checkboxSubmittedAndNotVoted: { + borderColor: $config.FONT_COLOR, + alignSelf: 'flex-start', + marginTop: 6, + }, +}); diff --git a/template/customization/polling/components/modals/PollEndConfirmModal.tsx b/template/customization/polling/components/modals/PollEndConfirmModal.tsx new file mode 100644 index 000000000..5554d51d8 --- /dev/null +++ b/template/customization/polling/components/modals/PollEndConfirmModal.tsx @@ -0,0 +1,116 @@ +import {Text, StyleSheet, View} from 'react-native'; +import React from 'react'; +import { + BaseModal, + BaseModalTitle, + BaseModalContent, + BaseModalCloseIcon, + BaseModalActions, +} from '../../ui/BaseModal'; +import { + ThemeConfig, + $config, + TertiaryButton, + PrimaryButton, + isMobileUA, +} from 'customization-api'; +import {PollTaskRequestTypes, usePoll} from '../../context/poll-context'; +import CommonStyles from '../common-styles'; + +interface PollConfirmModalProps { + pollId: string; + actionType: 'end' | 'delete'; // Define the type of action (end or delete) +} + +export default function PollConfirmModal({ + pollId, + actionType, +}: PollConfirmModalProps) { + const {handlePollTaskRequest, closeCurrentModal} = usePoll(); + + const modalTitle = actionType === 'end' ? 'End Poll?' : 'Delete Poll?'; + const description = + actionType === 'end' + ? 'This will stop the poll for everyone in this call.' + : 'This will permanently delete the poll and its results. This action cannot be undone.'; + + const confirmButtonText = + actionType === 'end' ? 'End for all' : 'Delete Poll'; + + return ( + + + + + + + {description} + + + + + + + + { + if (actionType === 'delete') { + handlePollTaskRequest(PollTaskRequestTypes.DELETE, pollId); + } + if (actionType === 'end') { + handlePollTaskRequest(PollTaskRequestTypes.FINISH, pollId); + } + }} + /> + + + + ); +} + +export const style = StyleSheet.create({ + section: { + padding: 20, + paddingBottom: 60, + }, + descriptionText: { + color: $config.FONT_COLOR + ThemeConfig.EmphasisPlus.high, + fontSize: ThemeConfig.FontSize.normal, + fontFamily: ThemeConfig.FontFamily.sansPro, + lineHeight: 20, + fontWeight: '400', + }, + btnContainer: { + minWidth: 150, + minHeight: 36, + borderRadius: 4, + paddingVertical: 9, + paddingHorizontal: 8, + }, + btnText: { + color: $config.PRIMARY_ACTION_TEXT_COLOR, + fontSize: ThemeConfig.FontSize.small, + fontFamily: ThemeConfig.FontFamily.sansPro, + fontWeight: '600', + textTransform: 'capitalize', + }, + textCenter: { + textAlign: 'center', + }, +}); diff --git a/template/customization/polling/components/modals/PollFormWizardModal.tsx b/template/customization/polling/components/modals/PollFormWizardModal.tsx new file mode 100644 index 000000000..e67ef4f10 --- /dev/null +++ b/template/customization/polling/components/modals/PollFormWizardModal.tsx @@ -0,0 +1,199 @@ +import React, {useEffect, useState, useRef} from 'react'; +import {BaseModal} from '../../ui/BaseModal'; +import SelectNewPollTypeFormView from '../form/SelectNewPollTypeFormView'; +import DraftPollFormView from '../form/DraftPollFormView'; +import PreviewPollFormView from '../form/PreviewPollFormView'; +import { + PollItem, + PollKind, + PollStatus, + PollFormErrors, +} from '../../context/poll-context'; +import {usePoll} from '../../context/poll-context'; +import {initPollForm} from '../form/form-config'; +import {useLocalUid, useContent} from 'customization-api'; +import {getAttributeLengthInKb, log} from '../../helpers'; + +type FormWizardStep = 'SELECT' | 'DRAFT' | 'PREVIEW'; + +interface PollFormWizardModalProps { + formObject?: PollItem; // Optional prop to initialize form in edit mode + formStep?: FormWizardStep; +} + +export default function PollFormWizardModal({ + formObject, + formStep, +}: PollFormWizardModalProps) { + const {polls, savePoll, sendPoll, closeCurrentModal} = usePoll(); + const [savedPollId, setSavedPollId] = useState(null); + const [step, setStep] = useState(formStep); + const [type, setType] = useState( + formObject ? formObject.type : PollKind.NONE, + ); + const [form, setForm] = useState(formObject || null); + const [formErrors, setFormErrors] = useState>({}); + + const localUid = useLocalUid(); + const localUidRef = useRef(localUid); + const {defaultContent} = useContent(); + const defaultContentRef = useRef(defaultContent); + + // Monitor savedPollId to send poll when it's updated + useEffect(() => { + if (savedPollId) { + sendPoll(savedPollId); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [savedPollId]); + + // Only set form and step once when the component is mounted, not on every re-render. + useEffect(() => { + if (formObject) { + // If formObject is passed, skip the SELECT step and initialize the form + setForm(formObject); + if (formStep) { + setStep(formStep); + } else { + setStep('DRAFT'); + } + } + }, [formObject, formStep]); + + // Initialize the form when the type is set + useEffect(() => { + if (!formObject && type !== PollKind.NONE) { + const user = { + uid: localUidRef.current, + name: defaultContentRef?.current[localUidRef.current]?.name || 'user', + }; + setForm(initPollForm(type, user)); + setStep('DRAFT'); + } + }, [type, formObject]); + + const onSave = (launch?: boolean) => { + try { + if (!validateForm()) { + return; + } + const payload = { + ...form, + status: launch ? PollStatus.ACTIVE : PollStatus.LATER, + }; + savePoll(payload); + if (launch) { + setSavedPollId(payload.id); + } + } catch (error) { + log('error while saving form: ', error); + } + }; + + const onEdit = () => { + setStep('DRAFT'); + }; + + const onPreview = () => { + if (validateForm()) { + setStep('PREVIEW'); + } + }; + + const validateForm = () => { + // 1. Check if form is null + if (!form) { + return false; + } + // 2. Start with an empty errors object + let errors: Partial = {}; + + // 3. Validate the question field + if (form.question.trim() === '') { + errors = { + ...errors, + question: {message: 'This field cannot be empty.'}, + }; + } + + // 4. Validate the options for MCQ type poll + if ( + form.type === PollKind.MCQ && + form.options && + (form.options.length === 0 || + form.options.some(item => item.text.trim() === '')) + ) { + errors = { + ...errors, + options: {message: 'Option can’t be empty.'}, + }; + } + // Validate the attribute size + const formLength = getAttributeLengthInKb(form) || 0; + const pollLength = getAttributeLengthInKb(polls) || 0; + const attributeLength = +formLength + +pollLength; + log('current attributeLength is: ', attributeLength); + if (attributeLength > 8) { + errors = { + ...errors, + global: { + message: + 'The poll size has exceeded the maximum allowable limit and cannot be saved. Please reduce the content or number of options and try again ', + }, + }; + } + // 5. Set formErrors to the collected errors + setFormErrors(errors); + + // 6. If there are no errors, return true, otherwise return false + return Object.keys(errors).length === 0; + }; + + const onClose = () => { + setFormErrors({}); + setForm(null); + setType(PollKind.NONE); + closeCurrentModal(); + }; + + function renderSwitch() { + switch (step) { + case 'SELECT': + return ( + + ); + case 'DRAFT': + return ( + form && ( + + ) + ); + case 'PREVIEW': + return ( + form && ( + + ) + ); + default: + return <>; + } + } + + return ( + + {renderSwitch()} + + ); +} diff --git a/template/customization/polling/components/modals/PollItemNotFound.tsx b/template/customization/polling/components/modals/PollItemNotFound.tsx new file mode 100644 index 000000000..774a00e4c --- /dev/null +++ b/template/customization/polling/components/modals/PollItemNotFound.tsx @@ -0,0 +1,28 @@ +import React from 'react'; +import {Text} from 'react-native'; +import { + BaseModal, + BaseModalTitle, + BaseModalCloseIcon, + BaseModalContent, +} from '../../ui/BaseModal'; +import {usePoll} from '../../context/poll-context'; +import {$config} from 'customization-api'; + +export default function PollItemNotFound() { + const {closeCurrentModal} = usePoll(); + + return ( + + + + + + + This poll has been deleted by the host. Your response was not + submitted. + + + + ); +} diff --git a/template/customization/polling/components/modals/PollResponseFormModal.tsx b/template/customization/polling/components/modals/PollResponseFormModal.tsx new file mode 100644 index 000000000..c7f316904 --- /dev/null +++ b/template/customization/polling/components/modals/PollResponseFormModal.tsx @@ -0,0 +1,194 @@ +import React, {useState} from 'react'; +import {Text, View, StyleSheet} from 'react-native'; +import { + BaseModal, + BaseModalActions, + BaseModalCloseIcon, + BaseModalContent, + BaseModalTitle, +} from '../../ui/BaseModal'; +import { + PollResponseFormComplete, + PollRenderResponseFormBody, + PollFormSubmitButton, +} from '../form/poll-response-forms'; +import { + PollStatus, + PollTaskRequestTypes, + usePoll, +} from '../../context/poll-context'; +import {getPollTypeDesc} from '../../helpers'; +import { + ThemeConfig, + $config, + TertiaryButton, + useSidePanel, + isMobileUA, +} from 'customization-api'; +import {usePollForm} from '../../hook/usePollForm'; +import {POLL_SIDEBAR_NAME} from '../PollButtonSidePanelTrigger'; +import CommonStyles from '../common-styles'; + +export default function PollResponseFormModal({pollId}: {pollId: string}) { + const {polls, sendResponseToPoll, closeCurrentModal, handlePollTaskRequest} = + usePoll(); + const {setSidePanel} = useSidePanel(); + const [hasResponded, setHasResponded] = useState(false); + + const pollItem = polls[pollId]; + + const onFormSubmit = (responses: string | string[]) => { + sendResponseToPoll(pollItem, responses); + }; + + const onFormSubmitComplete = () => { + if (pollItem.share_attendee || pollItem.share_host) { + handlePollTaskRequest(PollTaskRequestTypes.VIEW_DETAILS, pollItem.id); + } else { + setHasResponded(true); + } + }; + + const { + onSubmit, + selectedOption, + handleRadioSelect, + selectedOptions, + handleCheckboxToggle, + answer, + setAnswer, + buttonText, + buttonStatus, + submitDisabled, + } = usePollForm({ + pollItem, + initialSubmitted: false, + onFormSubmit, + onFormSubmitComplete, + }); + + const onClose = () => { + if (!hasResponded) { + setSidePanel(POLL_SIDEBAR_NAME); + closeCurrentModal(); + } else { + closeCurrentModal(); + } + }; + + return ( + + + + + + {hasResponded ? ( + + ) : ( + <> + {pollItem.status === PollStatus.FINISHED && ( + + + This poll has ended. You can no longer submit the response + + + )} + + + {getPollTypeDesc(pollItem.type, pollItem.multiple_response)} + + {pollItem.question} + + + + )} + + + {hasResponded && ( + + + + )} + + + + + + ); +} +export const style = StyleSheet.create({ + header: { + display: 'flex', + flexDirection: 'column', + gap: 8, + }, + heading: { + color: $config.FONT_COLOR + ThemeConfig.EmphasisPlus.high, + fontSize: ThemeConfig.FontSize.medium, + fontFamily: ThemeConfig.FontFamily.sansPro, + lineHeight: 24, + fontWeight: '600', + }, + info: { + color: $config.FONT_COLOR + ThemeConfig.EmphasisPlus.low, + fontSize: ThemeConfig.FontSize.tiny, + fontFamily: ThemeConfig.FontFamily.sansPro, + fontWeight: '600', + lineHeight: 12, + }, + warning: { + color: $config.SEMANTIC_ERROR, + fontSize: ThemeConfig.FontSize.small, + fontFamily: ThemeConfig.FontFamily.sansPro, + fontWeight: '600', + lineHeight: 21, + }, + btnContainer: { + minWidth: 150, + minHeight: 36, + borderRadius: 4, + paddingVertical: 9, + paddingHorizontal: 8, + }, + submittedBtn: { + backgroundColor: $config.SEMANTIC_SUCCESS, + cursor: 'default', + }, + btnText: { + color: $config.FONT_COLOR, + fontSize: ThemeConfig.FontSize.small, + fontFamily: ThemeConfig.FontFamily.sansPro, + fontWeight: '600', + textTransform: 'capitalize', + }, +}); diff --git a/template/customization/polling/components/modals/PollResultModal.tsx b/template/customization/polling/components/modals/PollResultModal.tsx new file mode 100644 index 000000000..660024a5e --- /dev/null +++ b/template/customization/polling/components/modals/PollResultModal.tsx @@ -0,0 +1,392 @@ +import {Text, StyleSheet, View} from 'react-native'; +import React from 'react'; +import { + BaseModal, + BaseModalTitle, + BaseModalContent, + BaseModalCloseIcon, + BaseModalActions, +} from '../../ui/BaseModal'; +import { + ThemeConfig, + $config, + UserAvatar, + TertiaryButton, + useContent, + useLocalUid, + ImageIcon, + isMobileUA, +} from 'customization-api'; +import { + PollItemOptionItem, + PollTaskRequestTypes, + usePoll, +} from '../../context/poll-context'; +import { + formatTimestampToTime, + calculateTotalVotes, + getPollTypeDesc, + capitalizeFirstLetter, + getPollTypeIcon, +} from '../../helpers'; +import {usePollPermissions} from '../../hook/usePollPermissions'; +import CommonStyles from '../common-styles'; + +export default function PollResultModal({pollId}: {pollId: string}) { + const {polls, closeCurrentModal, handlePollTaskRequest} = usePoll(); + const localUid = useLocalUid(); + const {defaultContent} = useContent(); + + const pollItem = polls[pollId]; + const {canViewWhoVoted} = usePollPermissions({pollItem}); + + return ( + + + + + + + + + + {capitalizeFirstLetter(pollItem.question)} + + + Total Responses {calculateTotalVotes(pollItem.options)} + + + + + Created{' '} + + {formatTimestampToTime(pollItem.createdAt)}{' '} + + by{' '} + + {localUid === pollItem.createdBy.uid + ? 'You' + : defaultContent[pollItem.createdBy.uid]?.name || + pollItem.createdBy?.name || + 'user'} + + + {!isMobileUA() && } + + + + + + + {getPollTypeDesc(pollItem.type, pollItem.multiple_response)} + + + + + + + {pollItem.options?.map((option: PollItemOptionItem, index) => ( + + + + + {`Option ${index + 1}`} + + + {option.text} + + + + + {option.percent}% + + + + {option.votes.length} votes + + + + {canViewWhoVoted && ( + + {option.votes.length > 0 ? ( + option.votes.map((item, i) => ( + + + + + {defaultContent[item.uid]?.name || + item?.name || + 'user'} + + + + + Voted {formatTimestampToTime(item.timestamp)} + + + + )) + ) : ( + + No votes here + + )} + + )} + + ))} + {!canViewWhoVoted && ( + + Individual responses are anonymous + + )} + + + + + + + + {!isMobileUA() && ( + + { + handlePollTaskRequest(PollTaskRequestTypes.EXPORT, pollItem.id); + }} + /> + + )} + + + ); +} + +export const style = StyleSheet.create({ + resultContainer: { + display: 'flex', + flexDirection: 'column', + gap: 16, + backgroundColor: $config.BACKGROUND_COLOR, + }, + resultInfoContainer: { + paddingVertical: 12, + paddingHorizontal: 32, + backgroundColor: $config.CARD_LAYER_1_COLOR, + display: 'flex', + flexDirection: 'column', + minHeight: 68, + }, + resultInfoContainerGapWeb: { + gap: 4, + }, + resultInfoContainerGapMobile: { + gap: 10, + }, + headerWeb: { + display: 'flex', + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + gap: 12, + }, + headerMobile: { + display: 'flex', + flexDirection: 'column', + alignItems: 'flex-start', + justifyContent: 'center', + gap: 4, + }, + subheaderWeb: { + display: 'flex', + flexDirection: 'row', + alignItems: 'center', + gap: 12, + }, + subheaderMobile: { + display: 'flex', + flexDirection: 'column', + alignItems: 'flex-start', + gap: 4, + }, + optionText: { + display: 'flex', + flex: 0.9, + }, + percentText: { + display: 'flex', + flexDirection: 'row', + alignItems: 'center', + gap: 12, + }, + rowCenter: { + display: 'flex', + flexDirection: 'row', + alignItems: 'center', + }, + resultSummaryContainer: { + paddingVertical: 16, + paddingHorizontal: 20, + gap: 16, + }, + summaryCard: { + display: 'flex', + flexDirection: 'column', + borderRadius: 8, + overflow: 'hidden', + borderWidth: 1, + borderColor: $config.CARD_LAYER_2_COLOR, + }, + summaryCardHeader: { + display: 'flex', + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + backgroundColor: $config.CARD_LAYER_2_COLOR, + paddingHorizontal: 20, + paddingVertical: 12, + }, + summaryCardBody: { + paddingHorizontal: 20, + paddingVertical: 12, + backgroundColor: $config.CARD_LAYER_1_COLOR, + display: 'flex', + flexDirection: 'column', + gap: 8, + }, + summaryItem: { + display: 'flex', + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + }, + titleAvatar: { + display: 'flex', + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + gap: 8, + }, + titleAvatarContainer: { + width: 24, + height: 24, + borderRadius: 12, + backgroundColor: $config.VIDEO_AUDIO_TILE_AVATAR_COLOR, + }, + titleAvatarContainerText: { + fontSize: ThemeConfig.FontSize.tiny, + lineHeight: 12, + fontWeight: '600', + color: $config.BACKGROUND_COLOR, + }, + questionText: { + color: $config.FONT_COLOR + ThemeConfig.EmphasisPlus.high, + fontSize: ThemeConfig.FontSize.medium, + fontFamily: ThemeConfig.FontFamily.sansPro, + lineHeight: 24, + fontWeight: '600', + flexBasis: '75%', + }, + totalText: { + color: $config.FONT_COLOR + ThemeConfig.EmphasisPlus.high, + fontSize: ThemeConfig.FontSize.normal, + fontFamily: ThemeConfig.FontFamily.sansPro, + lineHeight: 20, + fontWeight: '400', + }, + descriptionText: { + color: $config.FONT_COLOR + ThemeConfig.EmphasisPlus.low, + fontSize: ThemeConfig.FontSize.small, + fontFamily: ThemeConfig.FontFamily.sansPro, + lineHeight: 16, + fontWeight: '400', + fontStyle: 'italic', + }, + bold: { + fontWeight: '600', + }, + youText: { + color: $config.VIDEO_AUDIO_TILE_AVATAR_COLOR, + }, + light: { + color: $config.FONT_COLOR + ThemeConfig.EmphasisPlus.low, + }, + smallText: { + color: $config.FONT_COLOR, + fontSize: ThemeConfig.FontSize.tiny, + fontWeight: '400', + lineHeight: 16, + }, + username: { + color: $config.FONT_COLOR + ThemeConfig.EmphasisPlus.high, + fontSize: ThemeConfig.FontSize.small, + fontWeight: '400', + lineHeight: 20, + }, + alignRight: { + textAlign: 'right', + }, + btnContainer: { + minWidth: 150, + height: 36, + borderRadius: 4, + minHeight: 36, + paddingVertical: 9, + paddingHorizontal: 8, + }, + dot: { + width: 5, + height: 5, + borderRadius: 3, + backgroundColor: $config.FONT_COLOR + ThemeConfig.EmphasisPlus.low, + }, + textCenter: { + textAlign: 'center', + }, + imageIconBox: { + width: 15, + height: 15, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + marginRight: 5, + }, +}); diff --git a/template/customization/polling/components/poll-option-item-ui.tsx b/template/customization/polling/components/poll-option-item-ui.tsx new file mode 100644 index 000000000..89db63c79 --- /dev/null +++ b/template/customization/polling/components/poll-option-item-ui.tsx @@ -0,0 +1,126 @@ +import React from 'react'; +import {Text, View, StyleSheet, DimensionValue} from 'react-native'; +import {ThemeConfig, $config, hexadecimalTransparency} from 'customization-api'; + +function PollOptionList({children}: {children: React.ReactNode}) { + return {children}; +} + +interface Props { + submitting: boolean; + submittedMyVote: boolean; + percent: string; +} +function PollItemFill({submitting, submittedMyVote, percent}: Props) { + return ( + <> + + + {`${percent}%`} + + + ); +} + +interface PollOptionInputListItem { + index: number; + checked: boolean; + hovered: boolean; + children: React.ReactChild; +} + +function PollOptionInputListItem({ + index, + checked, + hovered, + children, +}: PollOptionInputListItem) { + return ( + + {children} + + ); +} + +const OPTION_LIST_ITEM_PADDING = 12; + +const style = StyleSheet.create({ + optionsList: { + display: 'flex', + flexDirection: 'column', + gap: 8, + width: '100%', + }, + optionListItem: { + display: 'flex', + flexDirection: 'row', + padding: OPTION_LIST_ITEM_PADDING, + alignItems: 'center', + justifyContent: 'space-between', + borderRadius: 8, + borderWidth: 1, + borderColor: $config.CARD_LAYER_3_COLOR, + backgroundColor: $config.CARD_LAYER_1_COLOR, + overflow: 'hidden', + width: '100%', + position: 'relative', + }, + optionListItemInput: { + backgroundColor: $config.CARD_LAYER_3_COLOR, + padding: 0, + }, + optionListItemChecked: { + borderColor: $config.PRIMARY_ACTION_BRAND_COLOR, + }, + optionListItemHovered: { + borderColor: $config.SEMANTIC_NEUTRAL + hexadecimalTransparency['25%'], + }, + optionFillBackground: { + position: 'absolute', + top: 0, + bottom: 0, + left: 0, + }, + optionFillText: { + position: 'absolute', + top: OPTION_LIST_ITEM_PADDING, + bottom: 0, + right: OPTION_LIST_ITEM_PADDING, + }, + optionText: { + color: $config.FONT_COLOR, + fontSize: ThemeConfig.FontSize.normal, + fontFamily: ThemeConfig.FontFamily.sansPro, + fontWeight: '700', + lineHeight: 24, + }, + myVote: { + color: $config.PRIMARY_ACTION_BRAND_COLOR, + }, + pushRight: { + marginLeft: 'auto', + }, +}); + +export {PollOptionList, PollOptionInputListItem, PollItemFill}; diff --git a/template/customization/polling/context/poll-context.tsx b/template/customization/polling/context/poll-context.tsx new file mode 100644 index 000000000..ed992abbf --- /dev/null +++ b/template/customization/polling/context/poll-context.tsx @@ -0,0 +1,857 @@ +import React, { + createContext, + useReducer, + useEffect, + useState, + useMemo, + useRef, + useCallback, +} from 'react'; +import {usePollEvents} from './poll-events'; +import { + useLocalUid, + useRoomInfo, + useSidePanel, + SidePanelType, + useContent, +} from 'customization-api'; +import { + getPollExpiresAtTime, + POLL_DURATION, +} from '../components/form/form-config'; +import { + addVote, + arrayToCsv, + calculatePercentage, + debounce, + downloadCsv, + log, + mergePolls, + shouldDeleteCreatorPolls, + isWebOnly, +} from '../helpers'; +import {POLL_SIDEBAR_NAME} from '../components/PollButtonSidePanelTrigger'; + +enum PollStatus { + ACTIVE = 'ACTIVE', + FINISHED = 'FINISHED', + LATER = 'LATER', +} + +enum PollKind { + OPEN_ENDED = 'OPEN_ENDED', + MCQ = 'MCQ', + YES_NO = 'YES_NO', + NONE = 'NONE', +} + +enum PollModalType { + NONE = 'NONE', + DRAFT_POLL = 'DRAFT_POLL', + PREVIEW_POLL = 'PREVIEW_POLL', + RESPOND_TO_POLL = 'RESPOND_TO_POLL', + VIEW_POLL_RESULTS = 'VIEW_POLL_RESULTS', + END_POLL_CONFIRMATION = 'END_POLL_CONFIRMATION', + DELETE_POLL_CONFIRMATION = 'DELETE_POLL_CONFIRMATION', +} + +interface PollModalState { + modalType: PollModalType; + id: string; +} + +enum PollTaskRequestTypes { + SAVE = 'SAVE', + SEND = 'SEND', + PUBLISH = 'PUBLISH', + EXPORT = 'EXPORT', + VIEW_DETAILS = 'VIEW_DETAILS', + FINISH = 'FINISH', + FINISH_CONFIRMATION = 'FINISH_CONFIRMATION', + DELETE = 'DELETE', + DELETE_CONFIRMATION = 'DELETE_CONFIRMATION', + SHARE = 'SHARE', + SYNC_COMPLETE = 'SYNC_COMPLETE', +} + +interface PollItemOptionItem { + text: string; + value: string; + votes: Array<{uid: number; name: string; timestamp: number}>; + percent: string; +} +interface PollItem { + id: string; + type: PollKind; + status: PollStatus; + question: string; + answers: Array<{ + uid: number; + response: string; + timestamp: number; + }> | null; + options: Array | null; + multiple_response: boolean; + share_attendee: boolean; + share_host: boolean; + anonymous: boolean; + duration: boolean; + expiresAt: number; + createdBy: {uid: number; name: string}; + createdAt: number; +} + +type Poll = Record; + +interface PollFormErrors { + question?: { + message: string; + }; + options?: { + message: string; + }; + global?: { + message: string; + }; +} + +enum PollActionKind { + SAVE_POLL_ITEM = 'SAVE_POLL_ITEM', + ADD_POLL_ITEM = 'ADD_POLL_ITEM', + SEND_POLL_ITEM = 'SEND_POLL_ITEM', + SUBMIT_POLL_ITEM_RESPONSES = 'SUBMIT_POLL_ITEM_RESPONSES', + RECEIVE_POLL_ITEM_RESPONSES = 'RECEIVE_POLL_ITEM_RESPONSES', + PUBLISH_POLL_ITEM = 'PUBLISH_POLL_ITEM', + DELETE_POLL_ITEM = 'DELETE_POLL_ITEM', + EXPORT_POLL_ITEM = 'EXPORT_POLL_ITEM', + FINISH_POLL_ITEM = 'FINISH_POLL_ITEM', + RESET = 'RESET', + SYNC_COMPLETE = 'SYNC_COMPLETE', +} + +type PollAction = + | { + type: PollActionKind.ADD_POLL_ITEM; + payload: { + item: PollItem; + }; + } + | { + type: PollActionKind.SAVE_POLL_ITEM; + payload: {item: PollItem}; + } + | { + type: PollActionKind.SEND_POLL_ITEM; + payload: {pollId: string}; + } + | { + type: PollActionKind.SUBMIT_POLL_ITEM_RESPONSES; + payload: { + id: string; + responses: string | string[]; + user: {name: string; uid: number}; + timestamp: number; + }; + } + | { + type: PollActionKind.RECEIVE_POLL_ITEM_RESPONSES; + payload: { + id: string; + responses: string | string[]; + user: {name: string; uid: number}; + timestamp: number; + }; + } + | { + type: PollActionKind.PUBLISH_POLL_ITEM; + payload: {pollId: string}; + } + | { + type: PollActionKind.FINISH_POLL_ITEM; + payload: {pollId: string}; + } + | { + type: PollActionKind.EXPORT_POLL_ITEM; + payload: {pollId: string}; + } + | { + type: PollActionKind.DELETE_POLL_ITEM; + payload: {pollId: string}; + } + | { + type: PollActionKind.RESET; + payload: null; + } + | { + type: PollActionKind.SYNC_COMPLETE; + payload: { + latestTask: PollTaskRequestTypes; + latestPollId: string; + }; + }; + +function pollReducer(state: Poll, action: PollAction): Poll { + switch (action.type) { + case PollActionKind.SAVE_POLL_ITEM: { + const pollId = action.payload.item.id; + return { + ...state, + [pollId]: {...action.payload.item}, + }; + } + case PollActionKind.ADD_POLL_ITEM: { + const pollId = action.payload.item.id; + return { + ...state, + [pollId]: {...action.payload.item}, + }; + } + case PollActionKind.SEND_POLL_ITEM: { + const pollId = action.payload.pollId; + return { + ...state, + [pollId]: { + ...state[pollId], + status: PollStatus.ACTIVE, + expiresAt: getPollExpiresAtTime(POLL_DURATION), + }, + }; + } + case PollActionKind.SUBMIT_POLL_ITEM_RESPONSES: { + const {id: pollId, user, responses, timestamp} = action.payload; + const poll = state[pollId]; + if (poll.type === PollKind.OPEN_ENDED && typeof responses === 'string') { + return { + ...state, + [pollId]: { + ...poll, + answers: poll.answers + ? [ + ...poll.answers, + { + ...user, + response: responses, + timestamp, + }, + ] + : [{...user, response: responses, timestamp}], + }, + }; + } + if ( + (poll.type === PollKind.MCQ || poll.type === PollKind.YES_NO) && + Array.isArray(responses) + ) { + const newCopyOptions = poll.options?.map(item => ({...item})) || []; + const withVotesOptions = addVote( + responses, + newCopyOptions, + user, + timestamp, + ); + const withPercentOptions = calculatePercentage(withVotesOptions); + return { + ...state, + [pollId]: { + ...poll, + options: withPercentOptions, + }, + }; + } + return state; + } + case PollActionKind.RECEIVE_POLL_ITEM_RESPONSES: { + const {id: pollId, user, responses, timestamp} = action.payload; + const poll = state[pollId]; + if (poll.type === PollKind.OPEN_ENDED && typeof responses === 'string') { + return { + ...state, + [pollId]: { + ...poll, + answers: poll.answers + ? [ + ...poll.answers, + { + ...user, + response: responses, + timestamp, + }, + ] + : [{...user, response: responses, timestamp}], + }, + }; + } + if ( + (poll.type === PollKind.MCQ || poll.type === PollKind.YES_NO) && + Array.isArray(responses) + ) { + const newCopyOptions = poll.options?.map(item => ({...item})) || []; + const withVotesOptions = addVote( + responses, + newCopyOptions, + user, + timestamp, + ); + const withPercentOptions = calculatePercentage(withVotesOptions); + return { + ...state, + [pollId]: { + ...poll, + options: withPercentOptions, + }, + }; + } + return state; + } + case PollActionKind.PUBLISH_POLL_ITEM: + // No action need just return the state + return state; + case PollActionKind.FINISH_POLL_ITEM: + { + const pollId = action.payload.pollId; + if (pollId) { + return { + ...state, + [pollId]: {...state[pollId], status: PollStatus.FINISHED}, + }; + } + } + return state; + case PollActionKind.EXPORT_POLL_ITEM: + { + const pollId = action.payload.pollId; + if (pollId && state[pollId]) { + const data = state[pollId].options || []; // Provide a fallback in case options is null + let csv = arrayToCsv(state[pollId].question, data); + downloadCsv(csv, 'polls.csv'); + } + } + return state; + case PollActionKind.DELETE_POLL_ITEM: + { + const pollId = action.payload.pollId; + if (pollId) { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const {[pollId]: _, ...newItems} = state; + return { + ...newItems, + }; + } + } + return state; + case PollActionKind.RESET: { + return {}; + } + default: { + return state; + } + } +} + +interface PollContextValue { + polls: Poll; + startPollForm: () => void; + editPollForm: (pollId: string) => void; + savePoll: (item: PollItem) => void; + sendPoll: (pollId: string) => void; + onPollReceived: ( + polls: Poll, + pollId: string, + task: PollTaskRequestTypes, + isInitialized: boolean, + ) => void; + sendResponseToPoll: (item: PollItem, responses: string | string[]) => void; + onPollResponseReceived: ( + pollId: string, + responses: string | string[], + user: { + uid: number; + name: string; + }, + timestamp: number, + ) => void; + sendPollResults: (pollId: string) => void; + modalState: PollModalState; + closeCurrentModal: () => void; + isHost: boolean; + handlePollTaskRequest: (task: PollTaskRequestTypes, pollId: string) => void; +} + +const PollContext = createContext(null); +PollContext.displayName = 'PollContext'; + +function PollProvider({children}: {children: React.ReactNode}) { + const [polls, dispatch] = useReducer(pollReducer, {}); + const [modalState, setModalState] = useState({ + modalType: PollModalType.NONE, + id: null, + }); + const [lastAction, setLastAction] = useState(null); + const {setSidePanel} = useSidePanel(); + const { + data: {isHost}, + } = useRoomInfo(); + const localUid = useLocalUid(); + const {defaultContent} = useContent(); + const {syncPollEvt, sendResponseToPollEvt} = usePollEvents(); + + const callDebouncedSyncPoll = useMemo( + () => debounce(syncPollEvt, 800), + [syncPollEvt], + ); + + const pollsRef = useRef(polls); + + useEffect(() => { + pollsRef.current = polls; // Update the ref whenever polls changes + }, [polls]); + + useEffect(() => { + // Delete polls created by the user + const deleteMyPolls = () => { + Object.values(pollsRef.current).forEach(poll => { + if (poll.createdBy.uid === localUid) { + enhancedDispatch({ + type: PollActionKind.DELETE_POLL_ITEM, + payload: {pollId: poll.id}, + }); + } + }); + }; + const handleBeforeUnload = (event: BeforeUnloadEvent) => { + event.preventDefault(); + deleteMyPolls(); + event.returnValue = ''; // Chrome requires returnValue to be set + }; + + if (shouldDeleteCreatorPolls) { + if (isWebOnly()) { + window.addEventListener('beforeunload', handleBeforeUnload); + } + } + return () => { + if (shouldDeleteCreatorPolls) { + if (isWebOnly()) { + window.removeEventListener('beforeunload', handleBeforeUnload); + } + } + }; + }, [localUid]); + + const enhancedDispatch = (action: PollAction) => { + log(`Dispatching action: ${action.type} with payload:`, action.payload); + dispatch(action); + setLastAction(action); + }; + + const closeCurrentModal = useCallback(() => { + log('Closing current modal.'); + setModalState({ + modalType: PollModalType.NONE, + id: null, + }); + }, []); + + useEffect(() => { + log('useEffect for lastAction triggered', lastAction); + + if (!lastAction) { + log('No lastAction to process. Exiting useEffect.'); + return; + } + if (!pollsRef?.current) { + log('PollsRef.current is undefined or null'); + return; + } + + try { + switch (lastAction.type) { + case PollActionKind.SAVE_POLL_ITEM: + if (lastAction?.payload?.item?.status === PollStatus.LATER) { + log('Handling SAVE_POLL_ITEM saving poll item and syncing states'); + const {item} = lastAction.payload; + syncPollEvt(pollsRef.current, item.id, PollTaskRequestTypes.SAVE); + closeCurrentModal(); + } + break; + case PollActionKind.SEND_POLL_ITEM: + { + log('Handling SEND_POLL_ITEM'); + const {pollId} = lastAction.payload; + if (pollId && pollsRef.current[pollId]) { + syncPollEvt(pollsRef.current, pollId, PollTaskRequestTypes.SEND); + closeCurrentModal(); + } else { + log('Invalid pollId or poll not found in state:', pollId); + } + } + break; + case PollActionKind.SUBMIT_POLL_ITEM_RESPONSES: + log('Handling SUBMIT_POLL_ITEM_RESPONSES'); + const {id, responses, user, timestamp} = lastAction.payload; + if (localUid === pollsRef.current[id]?.createdBy.uid) { + log( + 'No need to send event. User is the poll creator. We only sync data', + ); + syncPollEvt(pollsRef.current, id, PollTaskRequestTypes.SAVE); + return; + } + if (localUid && user?.uid && pollsRef.current[id]) { + sendResponseToPollEvt( + pollsRef.current[id], + responses, + user, + timestamp, + ); + } else { + log('Missing uid, localUid, or poll data for submit response.'); + } + break; + case PollActionKind.RECEIVE_POLL_ITEM_RESPONSES: + log('Handling RECEIVE_POLL_ITEM_RESPONSES'); + const {id: receivedPollId} = lastAction.payload; + const pollCreator = pollsRef.current[receivedPollId]?.createdBy.uid; + if (localUid === pollCreator) { + log('Received poll response, user is the creator. Syncing...'); + callDebouncedSyncPoll( + pollsRef.current, + receivedPollId, + PollTaskRequestTypes.SAVE, + ); + } + break; + case PollActionKind.PUBLISH_POLL_ITEM: + log('Handling PUBLISH_POLL_ITEM'); + { + const {pollId} = lastAction.payload; + syncPollEvt(pollsRef.current, pollId, PollTaskRequestTypes.PUBLISH); + } + break; + case PollActionKind.FINISH_POLL_ITEM: + log('Handling FINISH_POLL_ITEM'); + { + const {pollId} = lastAction.payload; + syncPollEvt(pollsRef.current, pollId, PollTaskRequestTypes.FINISH); + closeCurrentModal(); + } + break; + case PollActionKind.DELETE_POLL_ITEM: + log('Handling DELETE_POLL_ITEM'); + { + const {pollId} = lastAction.payload; + syncPollEvt(pollsRef.current, pollId, PollTaskRequestTypes.DELETE); + closeCurrentModal(); + } + break; + case PollActionKind.SYNC_COMPLETE: + log('Handling SYNC_COMPLETE'); + const {latestTask, latestPollId} = lastAction.payload; + if ( + latestPollId && + latestTask && + pollsRef.current[latestPollId] && + latestTask === PollTaskRequestTypes.SEND + ) { + setSidePanel(SidePanelType.None); + setModalState({ + modalType: PollModalType.RESPOND_TO_POLL, + id: latestPollId, + }); + } + break; + default: + log(`Unhandled action type: ${lastAction.type}`); + break; + } + } catch (error) { + log('Error processing last action:', error); + } + }, [ + lastAction, + localUid, + setSidePanel, + syncPollEvt, + sendResponseToPollEvt, + callDebouncedSyncPoll, + closeCurrentModal, + ]); + + const startPollForm = () => { + log('Opening draft poll modal.'); + setModalState({ + modalType: PollModalType.DRAFT_POLL, + id: null, + }); + }; + + const editPollForm = (pollId: string) => { + if (polls[pollId]) { + log(`Editing poll form for pollId: ${pollId}`); + setModalState({ + modalType: PollModalType.DRAFT_POLL, + id: pollId, + }); + } else { + log(`Poll not found for edit: ${pollId}`); + } + }; + + const savePoll = (item: PollItem) => { + log('Saving poll item:', item); + enhancedDispatch({ + type: PollActionKind.SAVE_POLL_ITEM, + payload: {item: {...item}}, + }); + }; + + const addPoll = (item: PollItem) => { + log('Adding poll item:', item); + enhancedDispatch({ + type: PollActionKind.ADD_POLL_ITEM, + payload: {item: {...item}}, + }); + }; + + const sendPoll = (pollId: string) => { + if (!pollId || !polls[pollId]) { + log('Invalid pollId or poll not found for sending:', pollId); + return; + } + log(`Sending poll with id: ${pollId}`); + enhancedDispatch({ + type: PollActionKind.SEND_POLL_ITEM, + payload: {pollId}, + }); + }; + + const onPollReceived = ( + newPoll: Poll, + pollId: string, + task: PollTaskRequestTypes, + initialLoad: boolean, + ) => { + log('onPollReceived', newPoll, pollId, task); + + if (!newPoll || !pollId) { + log('Invalid newPoll or pollId in onPollReceived:', {newPoll, pollId}); + return; + } + const {mergedPolls, deletedPollIds} = mergePolls(newPoll, polls); + + log('Merged polls:', mergedPolls); + log('Deleted poll IDs:', deletedPollIds); + + if (Object.keys(mergedPolls).length === 0) { + log('No polls left after merge. Resetting state.'); + enhancedDispatch({type: PollActionKind.RESET, payload: null}); + return; + } + + if (localUid === newPoll[pollId]?.createdBy.uid) { + log('I am the creator, no further action needed.'); + return; + } + + deletedPollIds?.forEach((id: string) => { + log(`Deleting poll ID: ${id}`); + handlePollTaskRequest(PollTaskRequestTypes.DELETE, id); + }); + + log('Updating state with merged polls.'); + Object.values(mergedPolls).forEach(pollItem => { + log(`Adding poll ID ${pollItem.id} with status ${pollItem.status}`); + addPoll(pollItem); + }); + + log('Is it an initial load ?:', initialLoad); + if (!initialLoad) { + enhancedDispatch({ + type: PollActionKind.SYNC_COMPLETE, + payload: { + latestTask: task, + latestPollId: pollId, + }, + }); + } else { + if (Object.keys(mergedPolls).length > 0) { + // Check if there is an active poll + log('It is an initial load.'); + const activePoll = Object.values(mergedPolls).find( + pollItem => pollItem.status === PollStatus.ACTIVE, + ); + if (activePoll) { + log('It is an initial load. There is an active poll'); + setSidePanel(POLL_SIDEBAR_NAME); + } else { + log('It is an initial load. There are no active poll'); + } + } + } + }; + + const sendResponseToPoll = (item: PollItem, responses: string | string[]) => { + log('Sending response to poll:', item, responses); + if ( + (item.type === PollKind.OPEN_ENDED && typeof responses === 'string') || + (item.type !== PollKind.OPEN_ENDED && Array.isArray(responses)) + ) { + enhancedDispatch({ + type: PollActionKind.SUBMIT_POLL_ITEM_RESPONSES, + payload: { + id: item.id, + responses, + user: { + uid: localUid, + name: defaultContent[localUid]?.name || 'user', + }, + timestamp: Date.now(), + }, + }); + } else { + throw new Error( + 'sendResponseToPoll received incorrect type response. Unable to send poll response', + ); + } + }; + + const onPollResponseReceived = ( + pollId: string, + responses: string | string[], + user: { + uid: number; + name: string; + }, + timestamp: number, + ) => { + log('Received poll response:', {pollId, responses, user, timestamp}); + enhancedDispatch({ + type: PollActionKind.RECEIVE_POLL_ITEM_RESPONSES, + payload: { + id: pollId, + responses, + user, + timestamp, + }, + }); + }; + + const sendPollResults = (pollId: string) => { + log(`Sending poll results for pollId: ${pollId}`); + syncPollEvt(polls, pollId, PollTaskRequestTypes.SHARE); + }; + + const handlePollTaskRequest = ( + task: PollTaskRequestTypes, + pollId: string, + ) => { + if (!pollId || !polls[pollId]) { + log( + 'handlePollTaskRequest: Invalid pollId or poll not found for handling', + pollId, + ); + return; + } + if (!(task in PollTaskRequestTypes)) { + log('handlePollTaskRequest: Invalid valid task', task); + return; + } + log(`Handling poll task request: ${task} for pollId: ${pollId}`); + switch (task) { + case PollTaskRequestTypes.SEND: + if (polls[pollId].status === PollStatus.LATER) { + setModalState({ + modalType: PollModalType.PREVIEW_POLL, + id: pollId, + }); + } else { + sendPoll(pollId); + } + break; + case PollTaskRequestTypes.SHARE: + break; + case PollTaskRequestTypes.VIEW_DETAILS: + setModalState({ + modalType: PollModalType.VIEW_POLL_RESULTS, + id: pollId, + }); + break; + case PollTaskRequestTypes.PUBLISH: + enhancedDispatch({ + type: PollActionKind.PUBLISH_POLL_ITEM, + payload: {pollId}, + }); + break; + case PollTaskRequestTypes.DELETE_CONFIRMATION: + setModalState({ + modalType: PollModalType.DELETE_POLL_CONFIRMATION, + id: pollId, + }); + break; + case PollTaskRequestTypes.DELETE: + enhancedDispatch({ + type: PollActionKind.DELETE_POLL_ITEM, + payload: {pollId}, + }); + break; + case PollTaskRequestTypes.FINISH_CONFIRMATION: + setModalState({ + modalType: PollModalType.END_POLL_CONFIRMATION, + id: pollId, + }); + break; + case PollTaskRequestTypes.FINISH: + enhancedDispatch({ + type: PollActionKind.FINISH_POLL_ITEM, + payload: {pollId}, + }); + break; + case PollTaskRequestTypes.EXPORT: + enhancedDispatch({ + type: PollActionKind.EXPORT_POLL_ITEM, + payload: {pollId}, + }); + break; + default: + log(`Unhandled task type: ${task}`); + break; + } + }; + + const value = { + polls, + startPollForm, + editPollForm, + sendPoll, + savePoll, + onPollReceived, + onPollResponseReceived, + sendResponseToPoll, + sendPollResults, + handlePollTaskRequest, + modalState, + closeCurrentModal, + isHost, + }; + + return {children}; +} + +function usePoll() { + const context = React.useContext(PollContext); + if (!context) { + throw new Error('usePoll must be used within a PollProvider'); + } + return context; +} + +export { + PollProvider, + usePoll, + PollActionKind, + PollKind, + PollStatus, + PollModalType, + PollTaskRequestTypes, +}; + +export type {Poll, PollItem, PollFormErrors, PollItemOptionItem}; diff --git a/template/customization/polling/context/poll-events.tsx b/template/customization/polling/context/poll-events.tsx new file mode 100644 index 000000000..2c2fdf7fb --- /dev/null +++ b/template/customization/polling/context/poll-events.tsx @@ -0,0 +1,232 @@ +import React, { + createContext, + useContext, + useEffect, + useState, + useCallback, + useMemo, + useRef, +} from 'react'; +import {Poll, PollItem, PollTaskRequestTypes, usePoll} from './poll-context'; +import {customEvents as events, PersistanceLevel} from 'customization-api'; +import {getAttributeLengthInKb, log} from '../helpers'; + +enum PollEventNames { + polls = 'POLLS', + pollResponse = 'POLL_RESPONSE', +} + +type sendResponseToPollEvtFunction = ( + item: PollItem, + responses: string | string[], + user: {name: string; uid: number}, + timestamp: number, +) => void; + +interface PollEventsContextValue { + syncPollEvt: ( + polls: Poll, + pollId: string, + task: PollTaskRequestTypes, + ) => void; + sendResponseToPollEvt: sendResponseToPollEvtFunction; +} + +const PollEventsContext = createContext(null); +PollEventsContext.displayName = 'PollEventsContext'; + +// Event Dispatcher +function PollEventsProvider({children}: {children?: React.ReactNode}) { + // Sync poll event handler + const syncPollEvt = useCallback( + (polls: Poll, pollId: string, task: PollTaskRequestTypes) => { + log('syncPollEvt called', {polls, pollId, task}); + try { + if (!polls || !pollId || !task) { + throw new Error('Invalid arguments provided to syncPollEvt.'); + } + const attributeLength = getAttributeLengthInKb(polls) || 0; + log('syncPollEvt check attribute length', attributeLength); + if (attributeLength <= '8') { + events.send( + PollEventNames.polls, + JSON.stringify({ + state: {...polls}, + pollId: pollId, + task, + }), + PersistanceLevel.Channel, + ); + log('syncPollEvt: Poll sync successful', {pollId, task}); + } else { + console.error( + 'syncPollEvt: Error while syncin poll, attribute length exceeded', + ); + } + } catch (error) { + console.error('Error while syncing poll: ', error); + } + }, + [], + ); + + // Send response to poll handler + const sendResponseToPollEvt: sendResponseToPollEvtFunction = useCallback( + (item, responses, user, timestamp) => { + log('sendResponseToPollEvt called', {item, responses, user, timestamp}); + try { + if (!item || !item.id || !responses || !user.uid) { + throw new Error( + 'Invalid arguments provided to sendResponseToPollEvt.', + ); + } + if (!item?.createdBy?.uid) { + throw new Error( + 'Poll createdBy is null, cannot send response to creator', + ); + } + events.send( + PollEventNames.pollResponse, + JSON.stringify({ + id: item.id, + responses, + user, + timestamp, + }), + PersistanceLevel.None, + item.createdBy.uid, + ); + log('Poll response sent successfully', {pollId: item.id}); + } catch (error) { + console.error('Error while sending a poll response: ', error); + } + }, + [], + ); + + const value = useMemo( + () => ({ + syncPollEvt, + sendResponseToPollEvt, + }), + [syncPollEvt, sendResponseToPollEvt], + ); + + return ( + + {children} + + ); +} + +function usePollEvents() { + const context = useContext(PollEventsContext); + if (!context) { + throw new Error('usePollEvents must be used within PollEventsProvider.'); + } + return context; +} + +// Event Subscriber +const PollEventsSubscriberContext = createContext(null); +PollEventsSubscriberContext.displayName = 'PollEventsContext'; + +function PollEventsSubscriber({children}: {children?: React.ReactNode}) { + const {onPollReceived, onPollResponseReceived} = usePoll(); + // State variable to track whether the initial load has occurred + // State variable to track whether the initial load has occurred + const [initialized, setInitialized] = useState(false); + const [initialLoadComplete, setInitialLoadComplete] = useState(false); + + // Use refs to hold the stable references of callbacks + const onPollReceivedRef = useRef(onPollReceived); + const onPollResponseReceivedRef = useRef(onPollResponseReceived); + + // Keep refs updated with latest callbacks + useEffect(() => { + onPollReceivedRef.current = onPollReceived; + onPollResponseReceivedRef.current = onPollResponseReceived; + }, [onPollReceived, onPollResponseReceived]); + + useEffect(() => { + if (!onPollReceivedRef.current || !onPollResponseReceivedRef.current) { + log('PollEventsSubscriber ref not intialized.'); + return; + } + log('PollEventsSubscriber useEffect triggered.'); + log('PollEventsSubscriber is app initialized ?', initialized); + let initialLoadTimeout: ReturnType; + // Set initialLoadTimeout only if initialLoadComplete is false + if (!initialLoadComplete) { + log('Setting initial load timeout.'); + initialLoadTimeout = setTimeout(() => { + log('Initial load timeout reached. Marking initial load as complete.'); + setInitialLoadComplete(true); + }, 3000); // Adjust the timeout duration as necessary + } + + events.on(PollEventNames.polls, args => { + try { + log('PollEventNames.polls event received', args); + const {payload} = args; + const data = JSON.parse(payload); + const {state, pollId, task} = data; + log('Poll data received and parsed successfully:', data); + // Determine if it's the initial load or a runtime update + if (!initialized && !initialLoadComplete) { + log('Initial load detected.'); + // Call onPollReceived with an additional parameter or flag for initial load + onPollReceivedRef.current(state, pollId, task, true); // true indicates it's an initial load + setInitialized(true); + // Clear the initial load timeout since we have received the initial state + clearTimeout(initialLoadTimeout); + } else { + log('Runtime update detected'); + onPollReceivedRef.current(state, pollId, task, false); // false indicates it's a runtime update + } + // switch (action) { + // case PollEventActions.savePoll: + // log('on poll saved'); + // onPollReceived(state, pollId, task); + // break; + // case PollEventActions.sendPoll: + // log('on poll received'); + // onPollReceived(state, pollId, task); + // break; + // default: + // break; + // } + } catch (error) { + log('Error handling poll event:', error); + } + }); + events.on(PollEventNames.pollResponse, args => { + try { + log('PollEventNames.pollResponse event received', args); + const {payload} = args; + const data = JSON.parse(payload); + log('poll response received', data); + const {id, responses, user, timestamp} = data; + log('Poll response data parsed successfully:', data); + onPollResponseReceivedRef.current(id, responses, user, timestamp); + } catch (error) { + log('Error handling poll response event:', error); + } + }); + + return () => { + log('Cleaning up PollEventsSubscriber event listeners.'); + events.off(PollEventNames.polls); + events.off(PollEventNames.pollResponse); + clearTimeout(initialLoadTimeout); + }; + }, [initialized, initialLoadComplete]); + + return ( + + {children} + + ); +} + +export {usePollEvents, PollEventsProvider, PollEventsSubscriber}; diff --git a/template/customization/polling/helpers.ts b/template/customization/polling/helpers.ts new file mode 100644 index 000000000..75f84cfc9 --- /dev/null +++ b/template/customization/polling/helpers.ts @@ -0,0 +1,244 @@ +import {Platform} from 'react-native'; +import {$config, isMobileUA, isWeb} from 'customization-api'; +import {Poll, PollItemOptionItem, PollKind} from './context/poll-context'; +import pollIcons from './poll-icons'; + +function log(...args: any[]) { + console.log('[Custom-Polling::]', ...args); +} + +function addVote( + responses: string[], + options: PollItemOptionItem[], + user: {name: string; uid: number}, + timestamp: number, +): PollItemOptionItem[] { + return options.map((option: PollItemOptionItem) => { + // Count how many times the value appears in the strings array + const exists = responses.includes(option.value); + const isVoted = option.votes.find(item => item.uid === user.uid); + if (exists && !isVoted) { + // Creating a new object explicitly + const newOption: PollItemOptionItem = { + ...option, + ...option, + votes: [ + ...option.votes, + { + ...user, + timestamp, + }, + ], + }; + return newOption; + } + // If no matches, return the option as is + return option; + }); +} + +function calculatePercentage( + options: PollItemOptionItem[], +): PollItemOptionItem[] { + const totalVotes = options.reduce( + (total, item) => total + item.votes.length, + 0, + ); + if (totalVotes === 0) { + // As none of the users have voted, there is no need to calulate the percentage, + // we can return the options as it is + return options; + } + return options.map((option: PollItemOptionItem) => { + let percentage = 0; + if (option.votes.length > 0) { + percentage = (option.votes.length / totalVotes) * 100; + } + // Creating a new object explicitly + const newOption: PollItemOptionItem = { + ...option, + percent: percentage.toFixed(0), + }; + return newOption; + }) as PollItemOptionItem[]; +} + +function arrayToCsv(question: string, data: PollItemOptionItem[]): string { + const headers = ['Option', 'Votes', 'Percent']; // Define the headers + const rows = data.map(item => { + const count = item.votes.length; + // Handle missing or undefined value + const voteText = item.text ? `"${item.text}"` : '""'; + const votesCount = count !== undefined ? count : '0'; + const votePercent = item.percent !== undefined ? `${item.percent}%` : '0%'; + + return `${voteText},${votesCount},${votePercent}`; + }); + // Include poll question at the top + const pollQuestion = `Poll Question: "${question}"`; + return [pollQuestion, '', headers.join(','), ...rows].join('\n'); +} + +function downloadCsv(data: string, filename: string = 'data.csv'): void { + const blob = new Blob([data], {type: 'text/csv;charset=utf-8;'}); + const link = document.createElement('a'); + const url = URL.createObjectURL(blob); + + link.setAttribute('href', url); + link.setAttribute('download', filename); + link.setAttribute('target', '_blank'); + link.style.visibility = 'hidden'; + + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); +} + +function capitalizeFirstLetter(sentence: string): string { + return sentence.charAt(0).toUpperCase() + sentence.slice(1).toLowerCase(); +} + +function hasUserVoted(options: PollItemOptionItem[], uid: number): boolean { + // Loop through each option and check the votes array + return options.some(option => option.votes.some(vote => vote.uid === uid)); +} + +type MergePollsResult = { + mergedPolls: Poll; + deletedPollIds: string[]; +}; + +function mergePolls(newPoll: Poll, oldPoll: Poll): MergePollsResult { + // Merge and discard absent properties + + // 1. Start with a copy of the current polls state + const mergedPolls: Poll = {...oldPoll}; + + // 2. Array to track deleted poll IDs + const deletedPollIds: string[] = []; + + // 3. Add or update polls from newPolls + Object.keys(newPoll).forEach(pollId => { + mergedPolls[pollId] = newPoll[pollId]; // Add or update each poll from newPolls + }); + + // 4. Remove polls that are not in newPolls and track deleted poll IDs + Object.keys(oldPoll).forEach(pollId => { + if (!(pollId in newPoll)) { + delete mergedPolls[pollId]; // Delete polls that are no longer present in newPolls + deletedPollIds.push(pollId); // Track deleted poll ID + } + }); + + // 5. Return the merged polls and deleted poll IDs + return {mergedPolls, deletedPollIds}; +} + +function getPollTypeIcon(type: PollKind): string { + if (type === PollKind.OPEN_ENDED) { + return pollIcons.question; + } + if (type === PollKind.YES_NO) { + return pollIcons['like-dislike']; + } + if (type === PollKind.MCQ) { + return pollIcons.mcq; + } + return pollIcons.question; +} + +function getPollTypeDesc(type: PollKind, multiple_response?: boolean): string { + if (type === PollKind.OPEN_ENDED) { + return 'Open Ended'; + } + if (type === PollKind.YES_NO) { + return 'Select Any One'; + } + if (type === PollKind.MCQ) { + if (multiple_response) { + return 'MCQ - Select One or More'; + } + return 'MCQ - Select Any One'; + } + return 'None'; +} + +function formatTimestampToTime(timestamp: number): string { + // Create a new Date object using the timestamp + const date = new Date(timestamp); + // Get hours and minutes from the Date object + let hours = date.getHours(); + const minutes = date.getMinutes(); + // Determine if it's AM or PM + const ampm = hours >= 12 ? 'PM' : 'AM'; + // Convert hours to 12-hour format + hours = hours % 12; + hours = hours ? hours : 12; // The hour '0' should be '12' + // Format minutes to always have two digits + const formattedMinutes = minutes < 10 ? `0${minutes}` : minutes; + // Construct the formatted time string + return `${hours}:${formattedMinutes} ${ampm}`; +} + +function calculateTotalVotes(options: Array): number { + // Use reduce to sum up the length of the votes array for each option + return options.reduce((total, option) => total + option.votes.length, 0); +} + +const debounce = void>( + func: T, + delay: number = 300, +) => { + let debounceTimer: ReturnType; + return function (this: ThisParameterType, ...args: Parameters) { + clearTimeout(debounceTimer); + debounceTimer = setTimeout(() => { + func.apply(this, args); + }, delay); + }; +}; + +const isWebOnly = () => isWeb() && !isMobileUA(); + +const getAttributeLengthInKb = (attribute: any): string => { + const jsonString = JSON.stringify(attribute); + let byteSize = 0; + if (Platform.OS === 'web') { + byteSize = new Blob([jsonString]).size; + } else { + // For iOS and Android + byteSize = jsonString.length * 2; + } + const kbSize = (byteSize / 1024).toFixed(2); + return kbSize; +}; + +const isAttributeLengthValid = (attribute: any) => { + if (getAttributeLengthInKb(attribute) > '8') { + return false; + } + return true; +}; + +const shouldDeleteCreatorPolls = + !$config.ENABLE_IDP_AUTH && !$config.ENABLE_TOKEN_AUTH; + +export { + log, + mergePolls, + hasUserVoted, + downloadCsv, + arrayToCsv, + addVote, + calculatePercentage, + capitalizeFirstLetter, + getPollTypeDesc, + formatTimestampToTime, + calculateTotalVotes, + debounce, + getPollTypeIcon, + isWebOnly, + getAttributeLengthInKb, + isAttributeLengthValid, + shouldDeleteCreatorPolls, +}; diff --git a/template/customization/polling/hook/useButtonState.tsx b/template/customization/polling/hook/useButtonState.tsx new file mode 100644 index 000000000..8ca5914ea --- /dev/null +++ b/template/customization/polling/hook/useButtonState.tsx @@ -0,0 +1,68 @@ +// useButtonState.ts +import {useState, useCallback, useRef, useEffect} from 'react'; + +interface useButtonStateReturn { + buttonText: string; + isSubmitting: boolean; + submitted: boolean; + handleSubmit: (submitFunction?: () => Promise | void) => void; + resetState: () => void; +} + +export function useButtonState( + initialText: string = 'Submit', + submittingText: string = 'Submitting...', + submittedText: string = 'Submitted', +): useButtonStateReturn { + const [buttonText, setButtonText] = useState(initialText); + const [isSubmitting, setIsSubmitting] = useState(false); + const [submitted, setIsSubmitted] = useState(false); + const timeoutRef = useRef(null); // Reference to store timeout ID + + // Handles the submission process + const handleSubmit = useCallback( + async (submitFunction?: () => Promise | void) => { + setIsSubmitting(true); + setButtonText(submittingText); + + try { + // Execute the submit function if provided + if (submitFunction) { + await submitFunction(); + } + + // After submission, update the text to "Submitted" with delay + timeoutRef.current = setTimeout(() => { + setIsSubmitted(true); + setButtonText(submittedText); + }, 1000); + } catch (error) { + // Handle error (e.g., reset button text or show error) + setButtonText(initialText); + } finally { + // Restore the submit state after completion + timeoutRef.current = setTimeout(() => { + setIsSubmitting(false); + }, 1000); + } + }, + [initialText, submittingText, submittedText], + ); + + // Cleanup function to clear timeouts if the component unmounts + useEffect(() => { + return () => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + }; + }, []); + + // Reset the state to the initial values + const resetState = () => { + setButtonText(initialText); + setIsSubmitting(false); + }; + + return {buttonText, isSubmitting, submitted, handleSubmit, resetState}; +} diff --git a/template/customization/polling/hook/useCountdownTimer.tsx b/template/customization/polling/hook/useCountdownTimer.tsx new file mode 100644 index 000000000..238f3adbe --- /dev/null +++ b/template/customization/polling/hook/useCountdownTimer.tsx @@ -0,0 +1,45 @@ +import {useEffect, useState, useRef} from 'react'; + +const useCountdown = (targetDate: number) => { + const countDownDate = new Date(targetDate).getTime(); + const intervalRef = useRef(null); // Add a ref to store the interval id + + const [countDown, setCountDown] = useState( + countDownDate - new Date().getTime(), + ); + + useEffect(() => { + intervalRef.current = setInterval(() => { + setCountDown(_ => { + const newCountDown = countDownDate - new Date().getTime(); + if (newCountDown <= 0) { + clearInterval(intervalRef.current!); + return 0; + } + return newCountDown; + }); + }, 1000); + + return () => { + if (intervalRef.current) { + clearInterval(intervalRef.current); + } + }; + }, [countDownDate]); + + return getReturnValues(countDown); +}; + +const getReturnValues = countDown => { + // calculate time left + const days = Math.floor(countDown / (1000 * 60 * 60 * 24)); + const hours = Math.floor( + (countDown % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60), + ); + const minutes = Math.floor((countDown % (1000 * 60 * 60)) / (1000 * 60)); + const seconds = Math.floor((countDown % (1000 * 60)) / 1000); + + return [days, hours, minutes, seconds]; +}; + +export {useCountdown}; diff --git a/template/customization/polling/hook/usePollForm.tsx b/template/customization/polling/hook/usePollForm.tsx new file mode 100644 index 000000000..b794fa339 --- /dev/null +++ b/template/customization/polling/hook/usePollForm.tsx @@ -0,0 +1,162 @@ +import {useState, useEffect, useRef, useCallback, SetStateAction} from 'react'; +import {PollItem, PollKind} from '../context/poll-context'; +// import {useLocalUid} from 'customization-api'; + +interface UsePollFormProps { + pollItem: PollItem; + initialSubmitted?: boolean; + onFormSubmit: (responses: string | string[]) => void; + onFormSubmitComplete?: () => void; +} + +interface PollFormInput { + pollItem: PollItem; + selectedOption: string | null; + selectedOptions: string[]; + handleRadioSelect: (option: string) => void; + handleCheckboxToggle: (option: string) => void; + answer: string; + setAnswer: React.Dispatch>; +} +interface PollFormButton { + onSubmit: () => void; + buttonVisible: boolean; + buttonStatus: ButtonStatus; + buttonText: string; + submitDisabled: boolean; +} +interface UsePollFormReturn + extends Omit, + PollFormButton {} + +type ButtonStatus = 'initial' | 'selected' | 'submitting' | 'submitted'; + +export function usePollForm({ + pollItem, + initialSubmitted = false, + onFormSubmit, + onFormSubmitComplete, +}: UsePollFormProps): UsePollFormReturn { + const [selectedOption, setSelectedOption] = useState(null); + const [selectedOptions, setSelectedOptions] = useState([]); + const [buttonVisible, setButtonVisible] = useState(!initialSubmitted); + + const [answer, setAnswer] = useState(''); + const [buttonStatus, setButtonStatus] = useState( + initialSubmitted ? 'submitted' : 'initial', + ); + + const timeoutRef = useRef(null); + // const localUid = useLocalUid(); + + // Set state for radio button selection + const handleRadioSelect = useCallback((option: string) => { + setSelectedOption(option); + setButtonStatus('selected'); // Mark the button state as selected + }, []); + + // Set state for checkbox toggle + const handleCheckboxToggle = useCallback((value: string) => { + setSelectedOptions(prevSelectedOptions => { + const newSelectedOptions = prevSelectedOptions.includes(value) + ? prevSelectedOptions.filter(option => option !== value) + : [...prevSelectedOptions, value]; + setButtonStatus(newSelectedOptions.length > 0 ? 'selected' : 'initial'); + return newSelectedOptions; + }); + }, []); + + // Handle form submission + const onSubmit = useCallback(() => { + setButtonStatus('submitting'); + + // Logic to handle form submission + if (pollItem.multiple_response) { + if (selectedOptions.length === 0) { + return; + } + onFormSubmit(selectedOptions); + } else { + if (!selectedOption) { + return; + } + onFormSubmit([selectedOption]); + } + + // Simulate submission delay and complete the process + timeoutRef.current = setTimeout(() => { + setButtonStatus('submitted'); + + // Trigger the form submit complete callback, if provided + if (onFormSubmitComplete) { + timeoutRef.current = setTimeout(() => { + // Call the onFormSubmitComplete callback + onFormSubmitComplete(); + // Hide the button after submission + setButtonVisible(false); + }, 2000); + } else { + // If no callback is provided, immediately hide the button without waiting + setButtonVisible(false); + // Time for displaying "Submitted" before calling onFormSubmitComplete + } + }, 1000); + }, [ + selectedOption, + selectedOptions, + pollItem, + onFormSubmit, + onFormSubmitComplete, + ]); + + useEffect(() => { + return () => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + }; + }, []); + + // Derive button text from button status + const buttonText = (() => { + switch (buttonStatus) { + case 'initial': + return 'Submit'; + case 'selected': + return 'Submit'; + case 'submitting': + return 'Submitting...'; + case 'submitted': + return 'Submitted'; + } + })(); + + // Define when the submit button should be disabled + const submitDisabled = + buttonStatus === 'submitting' || + buttonStatus === 'submitted' || + (pollItem.type === PollKind.OPEN_ENDED && answer?.trim() === '') || + (pollItem.type === PollKind.YES_NO && !selectedOption) || + (pollItem.type === PollKind.MCQ && + !pollItem.multiple_response && + !selectedOption) || + (pollItem.type === PollKind.MCQ && + pollItem.multiple_response && + selectedOptions.length === 0); + + return { + selectedOption, + selectedOptions, + handleRadioSelect, + handleCheckboxToggle, + onSubmit, + buttonVisible, + buttonStatus, + buttonText, + answer, + setAnswer, + submitDisabled, + }; +} + +export type {PollFormInput, PollFormButton}; diff --git a/template/customization/polling/hook/usePollPermissions.tsx b/template/customization/polling/hook/usePollPermissions.tsx new file mode 100644 index 000000000..fbca9e229 --- /dev/null +++ b/template/customization/polling/hook/usePollPermissions.tsx @@ -0,0 +1,73 @@ +import {useMemo} from 'react'; +import {useLocalUid, useRoomInfo} from 'customization-api'; +import {PollItem, PollStatus} from '../context/poll-context'; +import {isWebOnly} from '../helpers'; + +interface PollPermissions { + canCreate: boolean; + canEdit: boolean; + canEnd: boolean; + canViewWhoVoted: boolean; + canViewVotesPercent: boolean; + canViewPollDetails: boolean; +} + +interface UsePollPermissionsProps { + pollItem?: PollItem; // The current poll object +} + +export const usePollPermissions = ({ + pollItem, +}: UsePollPermissionsProps): PollPermissions => { + const localUid = useLocalUid(); + const { + data: {isHost}, + } = useRoomInfo(); + // Calculate permissions using useMemo to optimize performance + const permissions = useMemo(() => { + // Check if the current user is the creator of the poll + const isPollCreator = pollItem?.createdBy.uid === localUid || false; + // Determine if the user is both a host and the creator of the poll + const isPollHost = isHost && isPollCreator; + // Determine if the user is a host but not the creator of the poll (co-host) + // const isPollCoHost = isHost && !isPollCreator; + // Determine if the user is an attendee (not a host and not the creator) + const isPollAttendee = !isHost && !isPollCreator; + + // Determine if the user can create the poll (only the host can create) + const canCreate = isHost && isWebOnly(); + // Determine if the user can edit the poll (only the poll host can edit) + const canEdit = isPollHost && isWebOnly(); + // Determine if the user can end the poll (only the poll host can end an active poll) + const canEnd = isPollHost && pollItem?.status === PollStatus.ACTIVE; + + // Determine if the user can view the percentage of votes + // - Hosts can always view the percentage of votes + // - Co-hosts and attendees can view it if share_host or share_attendee is true respectively + const canViewVotesPercent = true; + // isPollHost || + // (isPollCoHost && pollItem.share_host) || + // (isPollAttendee && pollItem.share_attendee); + + // Determine if the user can view poll details (all hosts can view details, attendees cannot) + const canViewPollDetails = true; + // isPollHost || isPollCoHost; + + // Determine if the user can view who voted + // - If `pollItem.anonymous` is true, no one can view who voted + // - If `pollItem.anonymous` is false, only hosts and co-hosts can view who voted, attendees cannot + const canViewWhoVoted = !isPollAttendee; + // canViewPollDetails && !pollItem?.anonymous; + + return { + canCreate, + canEdit, + canEnd, + canViewVotesPercent, + canViewWhoVoted, + canViewPollDetails, + }; + }, [localUid, pollItem, isHost]); + + return permissions; +}; diff --git a/template/customization/polling/poll-icons.ts b/template/customization/polling/poll-icons.ts new file mode 100644 index 000000000..93cf523bb --- /dev/null +++ b/template/customization/polling/poll-icons.ts @@ -0,0 +1,31 @@ +interface PollIconsInterface { + mcq: string; + 'like-dislike': string; + question: string; + 'bar-chart': string; + anonymous: string; + 'stop-watch': string; + group: string; + 'co-host': string; +} + +const pollIcons: PollIconsInterface = { + mcq: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAGAAAABgCAYAAADimHc4AAACtUlEQVR4Ae3bT27TUBDH8XEI+xwhSBCJXXwDKgpb6A3aEwAngJ6g6glSbsAaCRlO4O6QClJzhBzAsZlHXTVtE/v5X8e1vh+pi7auU/3Gsd/YGREAAAAAAAAAAAB0KpAeiuK/71ORd1ffpb9eh7MzGajeFSCKLxapBIebP9N/8mcg44O98NlKBmYkPbItfCcTeZVK8lEGqDcF2BX+hg8yQL0ogEf4zkQGyLwAnuHraSg7lwEyLYBv+I5eiE9lgMwKUCV8XZIeD3UpWqsAUXw5lQaqhv8mfPFFBqpSHxDFfz5rIG45mF8Qg7ORPDnW9fnSfx+Ev8m7ABr+SR7+3V0stQh7PkUg/Pu8CuBOOdoIXRbsprQIDxm+vtY8k+BEG7i5GCxf8879yOeg9LwGJNPi32daoHW069pQJXxdbp42C98dLEHkumcx6h3yzj3+rgdC2baeBRh73IPZXoSK4X/dD2eNbjlkkiykH03bZKzvwrKNfFdBS/2qXIQa4XttW7yf/0d+L+SnwEJeBXB3IfWceiRerovw8OHn+nTHtPSd6N0H7IfPv2lUFYpgEr67APbmloW7GJdtU6kRu+pGfYtQru3wnUSyT9KPd8EqkKQ0q8qdcFtF6CJ85204Ox9JEvocfR1ZudceSabL8pfLso1rPxH7EV8c6p8vpIauwn+MGj2SrFMEwr+t8TPhKkUg/PtaeSjvUwTC3661T0UUFYHwd2vtgYxbHWmzdqBxLzd/7u7tEP5urX8uKIp/T9fydB5IOllrU+SWhQIAAADcwnyAMeYDjDEfYIz5AGPMBxhjPsAY8wHGmA8wxnyAMeYDjDEfYIz5gA4wH8B8wA3mA4oxH9Ah5gPsMR9gifkAW8wHiA3mAx4T5gOMMR9gjPkAY8wHGGM+wBjzAQAAAAAAAAAAAAAAYJj+AR0qLt391Uy/AAAAAElFTkSuQmCC', + 'like-dislike': + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAGAAAABgCAYAAADimHc4AAAEg0lEQVR4Ae2dT24TSxDGv+74rV9u8IL0yNNbvcwNsB6wxZyAcALgBCEnQJwgcAPYIqKYE4xZIRGkmBuwx56magYPthn/m+5kulr1W2URuaPvq/LUdFV3AKVTDAJzkV/eKYAj+uj/APfVohj1s3/eQGkkmAEX+dV+gekZiT5oWGZssdfvZ7fGUBYIYgCJf0DiX5D4B2t+7dsUrn8/OxxBqfE2YEvxZ8txJmSUCd+glFh4sJv4jOPfH0CpaW3A7uLPKI6g1LQyoL345ZJ/QqnZ2QA/8ZniA5SanQzwF58XnA6h1GxtQAjxqQp61c/+HUOp2cqAMOLzYt9PoSyw0YBQ4mv0N7PWgHDia/SvYqUBIcWn1+2hRn8zjQaEFJ+ZAhr9K/htLyi0+PQ54/+zw1tQGlnIgPDil2j0r6HOgOsRX6N/E2UGXFPkMxr9Gygz4H1+mZuyjSgDrqoMeo9T6LDZ8/zzsSTxGQdQ33mSv8s/i9/atgbmEWSy34N5AeFYjiYIxQnL3Ca8WpIRsA/hSDdAPGpAx6gBHaMGdIwa0DGiDXBw4sccRRtAb/AvIRzRBqQw4iLYgDSa/GINsCjEf/0wIg2omvxpnDMQaQBVP6+RCAINKNucr5AIAg0wSR34E2eAxSSJh++MHkThV3rySU5g8oQaOYObbOZQ0TByMCOLvdPlPraoDCg8Hr4/Jz/yAnh+0520aj13TH3sq/P88kUVCBWCDHDje9ntIVpwjWM3bXjqMLmYmSApA1rPGBVmehKJ+CWcEZQNJ/yzoZRwiB6/Cbvz/MtVTAbMoK/DvpAMsEO0hOeeYhSfsXADEQb4He6wJ4gW+0CAAe1LT765Jdbor3AH0RvgU3oWJv6pP8sPOESLX+kJ544ROWSAfYt48Sw948dSike6ueXGFn+0/9ucjJlXyylO+xQRbnDZYdt7hWIuPZcpH8J72HvOG0aIBo7+9qUnBdQTCKE0gCPNoNePIxNYfDz0KT0lHTipy1A24W7291N6O8u45XfTGWFIeHo1P6Xv/cyn3yuh9Jwn+LWVXVLtek6uIIikZkNJ/GMII7HhXHnn3ZIxQFLpOU8yBkg97ZnEQ5gevtxhyiGQJDKA9n3EvHgtIz4DJJae84jPgALf70Aw4g2QtO/ThHgDpF00soyekuwYNaBj1ICOUQM6Rg3omBQMEP3/aFIoQ8WeluSrFsQbMIF7BqFZwFctJLIb+unAoXcm6f47+ltHd7PbWVI94eugavSYMwSFJz+mfZ780CpoI6HH23+JX346lJWEb3Muis+oAWsJGf2/i1+uAKWRsIc7msVn1IAVUJUSKPpXi89oFdRAuDbnevEZzYAGwhzu2Cw+oxmwRJjo3058RjNgCf/o3158RjNgDv/o3018RjNgDr8Rl93FZ9SAOUzrzbx24jNqwBwOtsW2dnvxGTVggWLH5o6f+IwaMEd1LnnbmwP8xa/WVGr4oOIUeIiNHbYw4jNqwBL3s0Pq006y1Zlg3lQnOcPcW63vAWt4n38ZGLgjkukvMuRjQW3EtpeHKJHyA5zFFNQe/m1UAAAAAElFTkSuQmCC', + question: + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAGAAAABgCAYAAADimHc4AAACqklEQVR4Ae3dQU7bQBgF4Dd22HOEsCBSd5kTNBZt9z1CT5DeoOEGcIPcoPuqFekJzK4SrVQfgT3Ew4wiSwhBmDG2/5n4fRISikwWfs7z5I+YAERERERERERjotDCVfn/GLhfGmBhf+b2oWOMlD2B1wbmMsPRptAnFQIFB3BV3sxrZN8BMwU9oqot6s+f9Ow66K9CDt6dfFWCXrSF0SEhZL4H2tqZ7q582ie352hX0X68A6hxt2Dt+DD2Qr3/6nu0dwAGagnyYnv9ve+x3gGo3WqHPJiAc+UdAAXp/h5A/WAAwhiAMAYgjAEIYwDCGICwCYZxjjR9Q88GCeBMn66QoF/l394DYAUJYwDCGIAwBiBskJuwvZmtQM8aahna+2oiVawgYQxAGAMQxgCEcRa0H2dBkjgLGgEGIIwBCGMAwjgLEsZZkDBWkDAGIIwBCGMAwg59FhT9zf+gZ0FDzHLeihUkjAEIYwDCGIAwzoKEcRYkjBUkjAEIYwDCGIAwBiCMAQhjAMJCArgFdS5kw6ag3QDHzMB0v2mfAX6DvNiL9dL3WO8AMkwubAwV6BWmyrDd+B4dtG/oj/JmnnPf0L0yGF30sW+o4zYkdRuT8pXwHHflh518p+Xe0X+mNfKFglqace+meOsWJ1t7f5zYii70SfBKsVUAQ/hZ/rtQMJ3sVeo21/6gZ96bqQ4p2jdiOfJVN1VnqhxHK0Qq2gDcy7mG+oI3cs/RphqGEvUo4qM+3Rgo7zX1U6563HMgYtHPgtpXUdzV04g+gLZVFHv1NJKYhoZXkVrHXj2NZMbR/lXk3hDdJfOP4ckEEFBF54V+VyERSX0g83oVqfWZnq2RkOQ+EXu5itKqnkZyAbgqsuPews5QNs1j7nf3WErVczBCvrGIiIiIiIiIiIhI0gO4lLiwnKDxlQAAAABJRU5ErkJggg==', + 'bar-chart': + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAGAAAABgCAYAAADimHc4AAABlUlEQVR4Ae3c/0nDQBiH8bfiALpBN3AF3aAjZBPdpCM4QusEOkI20A3ON0RBkHI/eNtvL3k+EPLP9aD3NKHkIGYAAAAAAAAAAAAArkBKaefHe8qbxgyGONOCpno7Q4xU9sv/dyVYBzbWgWk1rcHG2ZW7MUgRQIwAYgQQI4AYAcQIIEYAMQKIEUCMAGIEECOAGAHECCB2a8H80f2jn7aZYaM/qj8a4vjC3/lxqNixep0+Uzh3E1sT/777VO+lcO7FBgjbsvPv++mnol/0H19+K7rPDWpdzB62JCMDnG2RlhyAf0FiBBAjgBgBxAggRgAxAogRQIwAYgQQI4AYAcTCN2R6lOZ9icGPh8zQNz+O/oxvtCCrfxrqU2/9dLD8Lt6v0Y+nqAjcgsyerXzx7Wfs3oJwBbTNXbSRVIIroE3tzt9JBBAjgBgBxAggRgAxAogRQIwAYgQQI4AYAcQIIEYAMQKIEUCMAGKRAT6sXulnep37ctL8Zttaw5Lnvrg0v+G29PXCVW+27XVuAAAAAAAAAAAAYN2+ASMa54HX/RGeAAAAAElFTkSuQmCC', + anonymous: + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAFAAAABQCAYAAACOEfKtAAAEGElEQVR4Ae2bjXWbQAzH5bwOkA16naDuBCEbuBPEmaDpBHEmcDOBvUHSCUwmSDIBZIJmA1XKyc8XBzAfAgPR7z09bMAc/NHppMMAGIZhGIZhGIZhGIZhGIZhGKWYQI9AxFNasDlZnsqmVJav/HkymbxCTziagCLWjOw7WQQ70crAAj6JPfCSRE1h7JBoEdk1WYL6PJItyRyMCfY0siuyDXbHhmwOQ0aEuyb7h8cjwSEKSSf968jC7ZPgEISkk5xit121Kgn2NUai765D4Rr6Ap2Mw357XR53qOCNjfJAOoEpLe7A53BDJCU7b5JD1haQxLugxR8on/yWJSWLyV7k87Yi4YSbb5gDXTgpZxGfoCtYPNRnRRaVaJsHqjXqwhnDFLqAGpqhLo91Th597E1Qj/ZFRH/3NfM79qTaIQB9sq7pjXxtDtoA9e/4GpRAXRETbENE9F1N8yRPc9rhCYclvr9Z3PYKc2Ikek/UPL8NaIL6SbLLEWFZ4rcrzBAffXjR5Ao0QN91NVnliFfFg3jfLBFV4yFogLpdg5lmtLHC6iwzjhOhItAUOsYcdUmULzrKOF6COiygBF8ObNcuup8z1l1Affi38d46nuJ3Ofu/PVMJlnm8UGWygBLkCkh3YAb6ZVNWudQkeY0y1qV732Oyv2T3bTw3KfLANrLyFHTbcQVt3JPdkmgxtMihLjxEtpMDMXTAScG2GPRxGetSqM+HkEDC3XclHpMroJxECrp8zVj3APWpPAWFPrPY4K6m5zRtDm2AA0xjDlzPXcGxNthgYqOo0Q3qEmW0UaeCWFW8jrBMZO9boRc0CdYvQRvUrzFbK+UKriEsR5Pwt7ItbLsVL1ygLlnlXNm5vcpziOj/GbElytgehpE5tAHqduVcD5KLWeN7r0iw5JR/zjFDB3AZ20MPXUAFquSBl2Q8T+agOeyBSznmO2T0j2EgnJTdUcqgn+ATVQ14hK80EPSR0gIy8ujvN+gxx5pT6OgHN/1RsyKVSzkSkeMTf9TyHkfGIq7B166FybHEQZ6FmZOtD+wXydezYBMPKPu9KIzHZ0EcXB+agGjyYH0OPo618WCdRXyGXSXkwFcxs732+AIvM86N909Ah8K6uvZkgngiX6j2Xzuc2AzqE4EermhjpRi4j3S3c6hRk46FRgIyHCPIftDHG/iENBZwi0yBfwP9GZxeoyYgI97IInJgT+EToCrgFh5gwMdG7tYpjJhWBGTEGxeBR45yoGlNwBD2SBlo2G5BT8wms9kqdPpQSdKet/+cSLIbgZ9Y2P77tMqrXjcSKo7K0Z7KSYm0DtfJFJeDnZBOlq+h9em9uF491pS3MAcVKzuJgWNmrAJqeTH3iLhoh169cK1JMEg14anT1x8MwzAMwzAMwzAMwxg//wGKZd5AVILLIgAAAABJRU5ErkJggg==', + 'stop-watch': + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAFAAAABQCAYAAACOEfKtAAAD5UlEQVR4Ae2cjXHbMAyF4U6QEZQJ6hHUDdoJqmzQThBlgqQTyJ0g2UDeIO0E8gbJBq9kSV9cR7JIAiKpHr87ns6JSYPP4I9AyESFQqFQKBQKhUKhUCgUggBwpcq9Ks+qNJQZyqav1rZOlYpywoqnjTulpkxQtmzPbBuyEXFCPM0DZYKy5duIfelFvCCeZkuZYD3wJSsRZ8RrKDOWEnFDAWjx1KVXZczLbjabzY4CsZ3R7R+vmsPxqto+UCB2VPQn7dJJ+584bfsYIep5qk4Ns3r3Ex5yzot9r65TkydIOZylxDsRzUWwOQZ4bk2SiCghnhWux3J0rgIgpohgioe3TXYsOjiIgGkRdV+vSALwxashM1R9GRztW1ZEmG9zjMah7i3Sc+tg55SI/JsB1cjjSMONQ70O+dA52Dsm4jNxgRmCpzQOdTrkR+9g97mIDUmgGqpUaeGw70Ke4h1x8UTnvoqDPOa8OW5JkKBbuTFg3H32G84Efdu2JwFEBITZc+k5pqJ18KrKtRLxlZh8IBn0sKhoPej9nchQZnug9b6B1gl7KEt4oOikHBm27SwPzMj79mRielsaj1FeQmxB8QZ57PnaM5tav+oJz25gbthT0k7Y5RO8eCEGwXMgzE69onTcqaHXTvzvF7lzBcZdB2cR+UzpmBQPZl72nQeD+8IR8COl4ZLnae7p/aHRHPH7gjRB0nbGpg5hsOZBb2AiFqH0MCvlo2e9dsamDjwqigXexwiDRFCvm5B6I/Z04BMvkwLhAlYjbc2J2M7Y0kGGeIsi3D3nHwLaa2fs6CBHQwFIRWOcwMR+y6aC3Jz9+eJqCxNdbmiNIHwID7gwWePNE9uZz+8gT02xwPuERR8GXBZxO/PZHZahpliAt43RDAjYNmDZ4EVFMQF/Iz3ALyGow3IEb6Q5i4jPDfsYlSo93HJYll4wgvvCEfA38aloRkTEWW0l+uIHwlfiMQaMb7I7xKGmFEA2oDDArsAwGWE94sA6kuBupH+SHJUqf9Po1FVP6jXFYU8MuIdK2mP4GUxpueYkl7M8UH2wXr32tF523Mx8iYP1mkxaxxq55grIDibYM9UftD7Y3qeRSi7SZxB6NZNJzF6eAwk9WCMSzrJZTl9oPXyXeipJLB5oh/Id5Y+OMz5RrqjhvEO+7GgNIE8Rd7QmkJeIO1oj8M+UWoKW1ozqwGekyeIakNHvNrCAOQKIOaQfIPWwYE5EELLH/+J1lzgRcgAfHZPUHpfkRy7EHrQJxXqMTqvQKWZahLmhp+96dBRIh+GfkuU3W5ILeA5MaL+yL4/XAxnhXqP8MEShUCgUCoVCoVBYlj99KuiCnAiYZQAAAABJRU5ErkJggg==', + group: + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAFAAAABQCAYAAACOEfKtAAACoklEQVR4Ae3ajVHbMBgG4Dcs0GxQjZAN6hG6ATBBu0HCBA0TuJ2g3cBhArKB3QnIBl+lk3IOqWRLiv/ueJ87HWCwJH+x/gGIiIiIiIiIiIiIiIiIiIgoaIUFEpFCfzHps7v0V6fDarU6gMJ04DY6VRJW66RA/9OBuZd496CWeask3QZkuaaZqgLZAUPyFZjZHeZXIN9XzGwJAVTI9wkzyw6gbj7fpJ1yvLnvF9+xuy6jcnU2XnV6wJR0gWVHv7RNzGsv+XaJZW078ioxhZ5KnBUJ+U0yiEjcVGmHsUnclONPYp6VpKsTyygj8jTNeo2xSPyEN/Xhct5ClVjG6xj5LmEUhtskeEy45VHf0yBN7Js13htoSDt6dUlqwhd5m82EuiNf87sCGfR9PyUCxqbL2EXUo8ANxDZpMzqXLu0HyrPPHmPThayluz/ZYaF6PnzzTOM2X09l6osKVLKAtWkfXccHeT/qv7lnmS541FrUlr7YKcT6Khknl4wmYwQezWwBdE3G7KZ8gd2RuQxYjKNLL7DnJQ0+AmkX80P7veg+WOzoazrg0o1Yvvmguf49cL8aKXC+QKpAHUz9a889592k0v3NcAOK2MBtJW4CLb7Ki50gx94/BFPWBv4PMVYpt54Aim1uKQ9eBio9ZfDOTJnKU58qIY9aevY4g2thV7g5uEl5nV88135g7PWlnynTt8f3C/GUTpXkbBRL3knZ5iqPnOPKoa0HqFPwBPAuELwNMs4q9FTieHVJYX7F1c8npCsk8BaGmrDCMBTm9+4N1B9yTgCNpAByXRgpFMAGwzhifgcMo/Fd9AbQ7RA/40auT3zCfJ4GWuI9h/61rnMt7DrO6OYcKkTslEhhWsFNB0lb8p08gyMRERERERERERERERERTeYfzhuku/ea/78AAAAASUVORK5CYII=', + 'co-host': + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAFAAAABQCAYAAACOEfKtAAADBElEQVR4Ae3b/3HaMBQH8Eev/5dMUGWCZIPSCZoNSicInQA2aDcgmaDZwN4gdAKrE0AneH06KT0OLNkykn9w38+djsTkGfkhBUkWRAAAAAAAAAAAAAAAfZjRAJh5Lg9LKXfukPldS/ktpZzNZrohfiEPj1IOLualKeYqyIXfSym42VaKajjX6XmKpphJk4t75HjrwPmUlH1MzGSZi+LuQkncxMZMjlzMki/31XPuOde3QmNFU8e2m1V8OZOkuec1fgZiFGX2jvIyXUnR5UzyfC3qpUPMNCRqfW+qwOvsY1tuKtlaoFT8ntK0vjcq0CW157hJ3j1llLML53jnlef4oUNMEjkTqCg95Tn+h/ym2YUp3Cq60p7jH4l6rcd/OROoKb0uydCUUdbFBPMpSOm60EEWDG46vM6NxGVrhbnHgc+UTu14z33a+5JX5kxednJxC05HeV4jNFVc0NSxf6oVYxM4f+WJ2dK14HZrgD5F4Ly+1ldx5hlIr9iumvzieE+hRHB963vla11YlQtbcbv5sZnDrhrOta6J2dC1Y7vEtXQt8vUoAZU7tuKG7sf21kBUDAAAAFyb7Fs73PBiQXYx9M49Kve0OvlzTXbJ6uB+NgulpZTdWBcFsiSQ7ST+k5QHSndPYkc2mc+SzF1MoNTH1MMsh5U0Vmyna+uTwXEuFdvBuIqoX+Hitjymqd5R4vY8jG2bhPD5LoaCh1zu4uETd2rLzTu66lZvCu67RbLdaTWWxB2rpCwb6l55Yn9QH/iynVZ96bKjyygoJ06z06ovD55rUA1xUftpooYxbPenKJoGLcOW27onOHwXzxtXp/VdOU6/1yU35epcRzfEKWop5ramoulRnuOawloP/mMSOMUVX1+d/1K3uDO5b6wPzTd//kDd4s7EJDBq/jkSvjo3tTBNLbVOoJvAlzQdoS/fhP7H6ZjFitgu/I0y73ZKREv5XvcE2/nvPBD3mSK8j/lj947esp0ufaHxfbBosl/9egqsH5rWV3aIAwAAAAAAAAAAAAAY0D97LMe5x6ElcQAAAABJRU5ErkJggg==', +}; + +export default pollIcons; +export type {PollIconsInterface}; diff --git a/template/customization/polling/ui/BaseAccordian.tsx b/template/customization/polling/ui/BaseAccordian.tsx new file mode 100644 index 000000000..f8f859727 --- /dev/null +++ b/template/customization/polling/ui/BaseAccordian.tsx @@ -0,0 +1,157 @@ +import React, {useState, ReactNode, useEffect} from 'react'; +import { + View, + Text, + TouchableOpacity, + LayoutAnimation, + UIManager, + Platform, + StyleSheet, +} from 'react-native'; +import {ThemeConfig, $config, ImageIcon} from 'customization-api'; + +// Enable Layout Animation for Android +if (Platform.OS === 'android') { + UIManager.setLayoutAnimationEnabledExperimental && + UIManager.setLayoutAnimationEnabledExperimental(true); +} + +// TypeScript Interfaces +interface BaseAccordionProps { + children: ReactNode; +} + +interface BaseAccordionHeaderProps { + title: string; + expandIcon?: React.ReactNode; + id: string; + isOpen: boolean; // Pass this prop explicitly + onPress: () => void; // Handle toggle functionality + children: React.ReactNode; +} + +interface BaseAccordionContentProps { + children: ReactNode; +} + +// Main Accordion Component to render multiple AccordionItems +const BaseAccordion: React.FC = ({children}) => { + return {children}; +}; + +// AccordionItem Component to manage isOpen state +const BaseAccordionItem: React.FC<{children: ReactNode; open?: boolean}> = ({ + children, + open = false, +}) => { + const [isOpen, setIsOpen] = useState(open); + + useEffect(() => { + setIsOpen(open); + }, [open]); + + const toggleAccordion = () => { + LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut); + setIsOpen(!isOpen); + }; + + // Separate AccordionHeader and AccordionContent components from children + const header = React.Children.toArray(children).find( + (child: any) => child.type === BaseAccordionHeader, + ); + const content = React.Children.toArray(children).find( + (child: any) => child.type === BaseAccordionContent, + ); + + return ( + + {/* Clone and pass props to AccordionHeader */} + {header && + React.cloneElement(header as React.ReactElement, { + isOpen, // Pass the isOpen state + onPress: toggleAccordion, // Pass the toggleAccordion function + })} + {isOpen && content} + + ); +}; + +// AccordionHeader Component for the Accordion Header +const BaseAccordionHeader: React.FC> = ({ + title, + isOpen, + onPress, + children, +}) => { + return ( + + + {title} + {children && {children}} + + + + + + ); +}; + +// AccordionContent Component for the Accordion Content +const BaseAccordionContent: React.FC = ({ + children, +}) => { + return {children}; +}; + +export { + BaseAccordion, + BaseAccordionItem, + BaseAccordionHeader, + BaseAccordionContent, +}; + +// Styles for Accordion Components +const styles = StyleSheet.create({ + accordionContainer: { + // marginVertical: 10, + }, + accordionItem: { + marginBottom: 8, + borderRadius: 8, + }, + accordionHeader: { + paddingVertical: 8, + paddingHorizontal: 19, + backgroundColor: $config.CARD_LAYER_3_COLOR, + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + height: 36, + }, + headerContent: { + flexDirection: 'row', + alignItems: 'center', + }, + expandIcon: { + marginLeft: 8, + }, + accordionContent: { + paddingVertical: 20, + paddingHorizontal: 12, + backgroundColor: $config.CARD_LAYER_1_COLOR, + borderBottomLeftRadius: 8, + borderBottomRightRadius: 8, + }, + accordionTitle: { + color: $config.FONT_COLOR + ThemeConfig.EmphasisPlus.high, + fontSize: ThemeConfig.FontSize.tiny, + fontFamily: ThemeConfig.FontFamily.sansPro, + fontWeight: '700', + lineHeight: 12, + }, +}); diff --git a/template/customization/polling/ui/BaseButtonWithToggle.tsx b/template/customization/polling/ui/BaseButtonWithToggle.tsx new file mode 100644 index 000000000..3e5d9ba1e --- /dev/null +++ b/template/customization/polling/ui/BaseButtonWithToggle.tsx @@ -0,0 +1,104 @@ +import {StyleSheet, Text, View, TouchableOpacity} from 'react-native'; +import React from 'react'; +import { + ThemeConfig, + $config, + ImageIcon, + hexadecimalTransparency, +} from 'customization-api'; +import Toggle from '../../../src/atoms/Toggle'; +// import Tooltip from '../../../src/atoms/Tooltip'; +import PlatformWrapper from '../../../src/utils/PlatformWrapper'; + +interface Props { + text: string; + value: boolean; + onPress: (value: boolean) => void; + tooltip?: boolean; + tooltTipText?: string; + hoverEffect?: boolean; + icon?: string; +} + +const BaseButtonWithToggle = ({ + text, + value, + onPress, + hoverEffect = false, + icon, +}: Props) => { + return ( + + {/* { + return ( */} + + {(isHovered: boolean) => { + return ( + { + onPress(value); + }}> + + + {text} + + + { + onPress(toggle); + }} + /> + + + ); + }} + + {/* ); + }} + /> */} + + ); +}; + +export default BaseButtonWithToggle; + +const styles = StyleSheet.create({ + toggleButton: { + width: '100%', + }, + container: { + display: 'flex', + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + padding: 8, + borderRadius: 4, + }, + hover: { + backgroundColor: $config.SEMANTIC_NEUTRAL + hexadecimalTransparency['25%'], + }, + text: { + color: $config.FONT_COLOR + ThemeConfig.EmphasisPlus.high, + fontSize: ThemeConfig.FontSize.tiny, + fontFamily: ThemeConfig.FontFamily.sansPro, + lineHeight: 12, + fontWeight: '400', + marginRight: 12, + }, + centerRow: { + display: 'flex', + flexDirection: 'row', + alignItems: 'center', + gap: 8, + }, +}); diff --git a/template/customization/polling/ui/BaseModal.tsx b/template/customization/polling/ui/BaseModal.tsx new file mode 100644 index 000000000..39550af98 --- /dev/null +++ b/template/customization/polling/ui/BaseModal.tsx @@ -0,0 +1,215 @@ +import { + Modal, + View, + StyleSheet, + Text, + TouchableWithoutFeedback, + ScrollView, +} from 'react-native'; +import React, {ReactNode} from 'react'; +import { + ThemeConfig, + hexadecimalTransparency, + IconButton, + isMobileUA, + $config, +} from 'customization-api'; + +interface TitleProps { + title?: string; + children?: ReactNode | ReactNode[]; +} + +function BaseModalTitle({title, children}: TitleProps) { + return ( + + {title && ( + + {title} + + )} + {children} + + ); +} + +interface ContentProps { + children: ReactNode; + noPadding?: boolean; +} + +function BaseModalContent({children, noPadding}: ContentProps) { + return ( + + + {children} + + + ); +} + +interface ActionProps { + children: ReactNode; + alignRight?: boolean; +} +function BaseModalActions({children, alignRight}: ActionProps) { + return ( + + {children} + + ); +} + +type BaseModalProps = { + visible?: boolean; + onClose: () => void; + children: ReactNode; + width?: number; + cancelable?: boolean; +}; + +const BaseModal = ({ + children, + visible = false, + width = 650, + cancelable = false, + onClose, +}: BaseModalProps) => { + return ( + + + { + cancelable && onClose(); + }}> + + + {children} + + + ); +}; + +type BaseModalCloseIconProps = { + onClose: () => void; +}; + +const BaseModalCloseIcon = ({onClose}: BaseModalCloseIconProps) => { + return ( + + + + ); +}; +export { + BaseModal, + BaseModalTitle, + BaseModalContent, + BaseModalActions, + BaseModalCloseIcon, +}; + +const style = StyleSheet.create({ + baseModalContainer: { + flex: 1, + position: 'relative', + justifyContent: 'center', + alignItems: 'center', + paddingHorizontal: 20, + }, + baseModal: { + zIndex: 2, + backgroundColor: $config.CARD_LAYER_1_COLOR, + borderWidth: 1, + borderColor: $config.CARD_LAYER_3_COLOR, + borderRadius: ThemeConfig.BorderRadius.large, + shadowColor: $config.HARD_CODED_BLACK_COLOR, + shadowOffset: { + width: 0, + height: 2, + }, + shadowOpacity: 0.1, + shadowRadius: 4, + elevation: 5, + minWidth: 340, + maxWidth: '100%', + minHeight: 220, + maxHeight: '80%', // Set a maximum height for the modal + overflow: 'hidden', + }, + baseModalBody: { + flex: 1, + }, + baseBackdrop: { + zIndex: 1, + position: 'absolute', + top: 0, + bottom: 0, + left: 0, + right: 0, + backgroundColor: + $config.HARD_CODED_BLACK_COLOR + hexadecimalTransparency['60%'], + }, + scrollgrow: { + flexGrow: 1, + }, + header: { + display: 'flex', + paddingHorizontal: 20, + paddingVertical: 12, + alignItems: 'center', + gap: 20, + height: 60, + justifyContent: 'space-between', + flexDirection: 'row', + borderBottomWidth: 1, + borderColor: $config.CARD_LAYER_3_COLOR, + }, + title: { + color: $config.FONT_COLOR + ThemeConfig.EmphasisPlus.high, + fontSize: ThemeConfig.FontSize.xLarge, + fontFamily: ThemeConfig.FontFamily.sansPro, + lineHeight: 32, + fontWeight: '600', + letterSpacing: -0.48, + }, + content: { + padding: 20, + gap: 20, + display: 'flex', + }, + noPadding: { + padding: 0, + }, + actions: { + display: 'flex', + flexDirection: 'row', + height: 60, + paddingVertical: 12, + paddingHorizontal: 16, + gap: 16, + alignItems: 'center', + flexShrink: 0, + borderTopWidth: 1, + borderTopColor: $config.CARD_LAYER_3_COLOR, + backgroundColor: $config.CARD_LAYER_2_COLOR, + }, + alignRight: { + justifyContent: 'flex-end', + }, +}); diff --git a/template/customization/polling/ui/BaseMoreButton.tsx b/template/customization/polling/ui/BaseMoreButton.tsx new file mode 100644 index 000000000..39a6bede6 --- /dev/null +++ b/template/customization/polling/ui/BaseMoreButton.tsx @@ -0,0 +1,44 @@ +import {StyleSheet, View} from 'react-native'; +import React, {forwardRef} from 'react'; +import {hexadecimalTransparency, IconButton} from 'customization-api'; + +interface MoreMenuProps { + setActionMenuVisible: (f: boolean) => void; +} + +const BaseMoreButton = forwardRef( + ({setActionMenuVisible}, ref) => { + return ( + + { + setActionMenuVisible(true); + }} + /> + + ); + }, +); + +export {BaseMoreButton}; + +const style = StyleSheet.create({ + hoverEffect: { + backgroundColor: + $config.CARD_LAYER_5_COLOR + hexadecimalTransparency['25%'], + borderRadius: 18, + }, + iconContainerStyle: { + padding: 4, + borderRadius: 18, + }, +}); diff --git a/template/customization/polling/ui/BaseRadioButton.tsx b/template/customization/polling/ui/BaseRadioButton.tsx new file mode 100644 index 000000000..3c9b6ae75 --- /dev/null +++ b/template/customization/polling/ui/BaseRadioButton.tsx @@ -0,0 +1,126 @@ +import { + TouchableOpacity, + View, + StyleSheet, + Text, + StyleProp, + TextStyle, +} from 'react-native'; +import React from 'react'; +import { + ThemeConfig, + $config, + ImageIcon, + hexadecimalTransparency, +} from 'customization-api'; + +interface Props { + option: { + label: string; + value: string; + }; + checked: boolean; + onChange: (option: string) => void; + labelStyle?: StyleProp; + filledColor?: string; + tickColor?: string; + disabled?: boolean; + ignoreDisabledStyle?: boolean; // Type for custom style prop +} +export default function BaseRadioButton(props: Props) { + const { + option, + checked, + onChange, + disabled = false, + labelStyle = {}, + filledColor = '', + tickColor = '', + ignoreDisabledStyle = false, + } = props; + return ( + { + if (disabled) { + return; + } + onChange(option.value); + }}> + + {checked && ( + + + + )} + + {option.label} + + ); +} + +const style = StyleSheet.create({ + optionsContainer: { + flexDirection: 'row', + alignItems: 'center', + width: '100%', + padding: 12, + }, + disabledContainer: { + opacity: 0.5, + }, + radioCircle: { + height: 22, + width: 22, + borderRadius: 11, + borderWidth: 2, + borderColor: $config.FONT_COLOR, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + alignSelf: 'flex-start', + marginTop: 5, + }, + disabledCircle: { + borderColor: $config.FONT_COLOR + hexadecimalTransparency['50%'], + }, + radioFilled: { + height: 22, + width: 22, + borderRadius: 12, + backgroundColor: $config.FONT_COLOR, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + }, + optionText: { + color: $config.FONT_COLOR, + fontSize: ThemeConfig.FontSize.normal, + fontFamily: ThemeConfig.FontFamily.sansPro, + fontWeight: '400', + lineHeight: 24, + marginLeft: 10, + }, +}); diff --git a/template/src/pages/video-call/VideoCallMobileView.tsx b/template/src/pages/video-call/VideoCallMobileView.tsx index 6d57f5734..f8868be69 100644 --- a/template/src/pages/video-call/VideoCallMobileView.tsx +++ b/template/src/pages/video-call/VideoCallMobileView.tsx @@ -184,6 +184,7 @@ const VideoCallMobileView = props => { const VideoCallView = React.memo(() => { //toolbar changes + const { BottombarComponent, BottombarProps, @@ -253,7 +254,7 @@ const VideoCallView = React.memo(() => { }); return ( - + <> {Object.keys(TopbarProps)?.length ? ( @@ -277,7 +278,7 @@ const VideoCallView = React.memo(() => { )} - + ); }); diff --git a/template/src/pages/video-call/VideoCallScreen.tsx b/template/src/pages/video-call/VideoCallScreen.tsx index 195272af6..646c90fd7 100644 --- a/template/src/pages/video-call/VideoCallScreen.tsx +++ b/template/src/pages/video-call/VideoCallScreen.tsx @@ -329,7 +329,9 @@ const VideoCallScreen = () => { ) : // ) : !isDesktop ? ( isMobileUA() ? ( // Mobile View - + + + ) : ( // Desktop View <>