From a75ccae1de24fa4e17958fa66529182bcc9c3752 Mon Sep 17 00:00:00 2001 From: Supriya Adep Date: Fri, 6 Sep 2024 13:38:39 +0530 Subject: [PATCH 1/4] add polling feature --- bottombar.tsx | 17 +- index.tsx | 14 + polling-ui.tsx | 71 +++ polling/components/Poll.tsx | 41 ++ polling/components/PollAvatarHeader.tsx | 84 ++++ polling/components/PollCard.tsx | 165 +++++++ polling/components/PollCardMoreActions.tsx | 138 ++++++ polling/components/PollList.tsx | 38 ++ polling/components/PollSidebar.tsx | 101 ++++ polling/components/PollTimer.tsx | 53 +++ polling/components/form/DraftPollFormView.tsx | 399 ++++++++++++++++ .../components/form/PreviewPollFormView.tsx | 153 ++++++ .../form/SelectNewPollTypeFormView.tsx | 119 +++++ polling/components/form/form-config.ts | 118 +++++ .../components/form/poll-response-forms.tsx | 387 +++++++++++++++ .../components/modals/PollFormWizardModal.tsx | 135 ++++++ .../modals/PollResponseFormModal.tsx | 57 +++ polling/components/modals/PollResultModal.tsx | 98 ++++ polling/components/poll-option-item-ui.tsx | 104 ++++ polling/context/poll-context.tsx | 445 ++++++++++++++++++ polling/context/poll-events.tsx | 142 ++++++ polling/helpers.ts | 101 ++++ polling/hook/useCountdownTimer.tsx | 45 ++ polling/ui/BaseModal.tsx | 160 +++++++ polling/ui/BaseMoreButton.tsx | 44 ++ polling/ui/BaseRadioButton.tsx | 74 +++ 26 files changed, 3302 insertions(+), 1 deletion(-) create mode 100644 polling-ui.tsx create mode 100644 polling/components/Poll.tsx create mode 100644 polling/components/PollAvatarHeader.tsx create mode 100644 polling/components/PollCard.tsx create mode 100644 polling/components/PollCardMoreActions.tsx create mode 100644 polling/components/PollList.tsx create mode 100644 polling/components/PollSidebar.tsx create mode 100644 polling/components/PollTimer.tsx create mode 100644 polling/components/form/DraftPollFormView.tsx create mode 100644 polling/components/form/PreviewPollFormView.tsx create mode 100644 polling/components/form/SelectNewPollTypeFormView.tsx create mode 100644 polling/components/form/form-config.ts create mode 100644 polling/components/form/poll-response-forms.tsx create mode 100644 polling/components/modals/PollFormWizardModal.tsx create mode 100644 polling/components/modals/PollResponseFormModal.tsx create mode 100644 polling/components/modals/PollResultModal.tsx create mode 100644 polling/components/poll-option-item-ui.tsx create mode 100644 polling/context/poll-context.tsx create mode 100644 polling/context/poll-events.tsx create mode 100644 polling/helpers.ts create mode 100644 polling/hook/useCountdownTimer.tsx create mode 100644 polling/ui/BaseModal.tsx create mode 100644 polling/ui/BaseMoreButton.tsx create mode 100644 polling/ui/BaseRadioButton.tsx diff --git a/bottombar.tsx b/bottombar.tsx index 323559a..fe3eb81 100644 --- a/bottombar.tsx +++ b/bottombar.tsx @@ -1,7 +1,13 @@ -import { ToolbarPreset, ToolbarComponents } from "customization-api"; +import { + ToolbarPreset, + ToolbarComponents, + useSidePanel, +} from "customization-api"; import React from "react"; +import { CustomMoreItem, POLL_SIDEBAR_NAME } from "./polling-ui"; const Bottombar = () => { + const { setSidePanel } = useSidePanel(); const { MeetingTitleToolbarItem, ParticipantCountToolbarItem, @@ -62,6 +68,15 @@ const Bottombar = () => { return w > 767 ? true : false; }, }, + poll: { + hide: (w) => { + return w > 767 ? true : false; + }, + component: CustomMoreItem, + onPress: () => { + setSidePanel(POLL_SIDEBAR_NAME); + }, + }, }, }, "meeting-title": { diff --git a/index.tsx b/index.tsx index 708c881..d247102 100644 --- a/index.tsx +++ b/index.tsx @@ -1,9 +1,23 @@ import { customize, isMobileUA } from "customization-api"; import Topbar from "./topbar"; import Bottombar from "./bottombar"; +import PollSidebar from "./polling/components/PollSidebar"; +import Poll from "./polling/components/Poll"; +import { POLL_SIDEBAR_NAME } from "./polling-ui"; const config = customize({ components: { + wrapper: Poll, + customSidePanel: () => { + return [ + { + name: POLL_SIDEBAR_NAME, + component: PollSidebar, + title: "Polls", + onClose: () => {}, + }, + ]; + }, videoCall: { topToolBar: Topbar, bottomToolBar: Bottombar, diff --git a/polling-ui.tsx b/polling-ui.tsx new file mode 100644 index 0000000..2673b4c --- /dev/null +++ b/polling-ui.tsx @@ -0,0 +1,71 @@ +import React from "react"; +import { + ToolbarPreset, + useSidePanel, + ToolbarItem, + ImageIcon, + ThemeConfig, + $config, +} from "customization-api"; +import { View, Text, StyleSheet } from "react-native"; + +const POLL_SIDEBAR_NAME = "side-panel-poll"; + +const CustomMoreItem = () => { + return ( + + + + + Polls + + ); +}; + +// const CustomBottomToolbar = () => { +// const {setSidePanel} = useSidePanel(); + +// return ( +// { +// setSidePanel(POLL_SIDEBAR_NAME); +// }, +// }, +// }, +// }, +// }} +// /> +// ); +// }; + +export { CustomMoreItem, 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, + }, +}); diff --git a/polling/components/Poll.tsx b/polling/components/Poll.tsx new file mode 100644 index 0000000..ca7fec0 --- /dev/null +++ b/polling/components/Poll.tsx @@ -0,0 +1,41 @@ +import React from 'react'; +import {PollModalState, 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'; +// const DraftPollModal = React.lazy(() => import('./DraftPollModal')); +// const RespondToPollModal = React.lazy(() => import('./RespondToPollModal')); +// const SharePollResultModal = React.lazy(() => import('./SharePollResultModal')); + +function Poll({children}: {children?: React.ReactNode}) { + return ( + + + {children} + + + + ); +} + +function PollModals() { + const {currentModal, launchPollId, viewResultPollId, polls} = usePoll(); + console.log('supriya polls data chnaged: ', polls); + return ( + <> + {currentModal === PollModalState.DRAFT_POLL && } + {currentModal === PollModalState.RESPOND_TO_POLL && launchPollId && ( + + )} + {currentModal === PollModalState.VIEW_POLL_RESULTS && + viewResultPollId && } + + // Loading...}> + // {activePollModal === PollAction.DraftPoll && } + // {activePollModal === PollAction.RespondToPoll && } + // {activePollModal === PollAction.SharePollResult && } + // + ); +} +export default Poll; diff --git a/polling/components/PollAvatarHeader.tsx b/polling/components/PollAvatarHeader.tsx new file mode 100644 index 0000000..3f036f8 --- /dev/null +++ b/polling/components/PollAvatarHeader.tsx @@ -0,0 +1,84 @@ +import {Text, View, StyleSheet} from 'react-native'; +import React from 'react'; +import {PollItem} from '../context/poll-context'; +import { + useContent, + UserAvatar, + ThemeConfig, + useString, + videoRoomUserFallbackText, + UidType, +} from 'customization-api'; + +interface Props { + pollItem: PollItem; +} + +function PollAvatarHeader({pollItem}: Props) { + const remoteUserDefaultLabel = useString(videoRoomUserFallbackText)(); + const {defaultContent} = useContent(); + const getPollCreaterName = (uid: UidType) => { + return defaultContent[uid]?.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/polling/components/PollCard.tsx b/polling/components/PollCard.tsx new file mode 100644 index 0000000..3f8f1ac --- /dev/null +++ b/polling/components/PollCard.tsx @@ -0,0 +1,165 @@ +import React from 'react'; +import {Text, View, StyleSheet} from 'react-native'; +import { + PollItem, + PollItemOptionItem, + PollStatus, + usePoll, +} from '../context/poll-context'; +import {ThemeConfig, TertiaryButton, useLocalUid} from 'customization-api'; +import {PollOptionList, PollOptionListItemResult} from './poll-option-item-ui'; +import {BaseMoreButton} from '../ui/BaseMoreButton'; +import {PollCardMoreActions, PollTaskRequestTypes} from './PollCardMoreActions'; +import {capitalizeFirstLetter, iVoted} from '../helpers'; +import {PollRenderResponseForm} from './form/poll-response-forms'; + +function PollCard({pollItem, isHost}: {pollItem: PollItem; isHost: boolean}) { + const {sendResponseToPoll, handlePollTaskRequest} = usePoll(); + const localUid = useLocalUid(); + + const moreBtnRef = React.useRef(null); + const [actionMenuVisible, setActionMenuVisible] = + React.useState(false); + + const resultView = + isHost || + pollItem.status === PollStatus.FINISHED || + iVoted(pollItem.options, localUid); + + return ( + + + + + {capitalizeFirstLetter(pollItem.status)} + + + {isHost ? ( + <> + + { + handlePollTaskRequest(action, pollItem.id); + }} + /> + + ) : ( + <> + )} + + + + + + {pollItem.question} + + + + {resultView ? ( + + {pollItem.options.map( + (item: PollItemOptionItem, index: number) => ( + + ), + )} + + ) : pollItem.status === PollStatus.ACTIVE ? ( + + { + sendResponseToPoll(pollItem, responses); + }} + /> + + ) : ( + Form not published yet. Incorrect state + )} + + + + + {resultView ? ( + + handlePollTaskRequest( + PollTaskRequestTypes.VIEW_DETAILS, + pollItem.id, + ) + } + /> + ) : ( + <> + )} + + + + + ); +} +const style = StyleSheet.create({ + fullWidth: { + alignSelf: 'stretch', + }, + pollItem: { + marginVertical: 12, + }, + pollCard: { + padding: 12, + display: 'flex', + flexDirection: 'column', + gap: 12, + alignSelf: 'stretch', + backgroundColor: $config.CARD_LAYER_3_COLOR, + borderRadius: 15, + }, + pollCardHeader: { + height: 24, + display: 'flex', + flexDirection: 'row', + justifyContent: 'space-between', + }, + pollCardHeaderText: { + color: '#04D000', + 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, + fontSize: ThemeConfig.FontSize.small, + fontFamily: ThemeConfig.FontFamily.sansPro, + fontWeight: '600', + lineHeight: 16, + }, + pollCardFooter: {}, + pollCardFooterActions: { + alignSelf: 'flex-start', + }, + pollResponseFormView: { + display: 'flex', + gap: 20, + }, +}); + +export {PollCard}; diff --git a/polling/components/PollCardMoreActions.tsx b/polling/components/PollCardMoreActions.tsx new file mode 100644 index 0000000..571be7f --- /dev/null +++ b/polling/components/PollCardMoreActions.tsx @@ -0,0 +1,138 @@ +import React, {Dispatch, SetStateAction} from 'react'; +import {View, useWindowDimensions} from 'react-native'; +import { + ActionMenu, + ActionMenuItem, + calculatePosition, + ThemeConfig, +} from 'customization-api'; +import {PollStatus} from '../context/poll-context'; + +export enum PollTaskRequestTypes { + PUBLISH = 'PUBLISH', + EXPORT = 'EXPORT', + FINISH = 'FINISH', + VIEW_DETAILS = 'VIEW_DETAILS', + DELETE = 'DELETE', + SHARE = 'SHARE', +} + +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(); + + actionMenuitems.push({ + icon: 'share', + iconColor: $config.SECONDARY_ACTION_COLOR, + textColor: $config.FONT_COLOR, + title: 'Publish Result', + titleStyle: { + fontSize: ThemeConfig.FontSize.small, + }, + disabled: status === PollStatus.LATER, + onPress: () => { + onCardActionSelect(PollTaskRequestTypes.PUBLISH); + setActionMenuVisible(false); + }, + }); + + actionMenuitems.push({ + icon: 'download', + iconColor: $config.SECONDARY_ACTION_COLOR, + textColor: $config.FONT_COLOR, + title: 'Export Resuts', + 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: 'Finish Poll', + titleStyle: { + fontSize: ThemeConfig.FontSize.small, + }, + disabled: status === PollStatus.LATER || status === PollStatus.FINISHED, + onPress: () => { + onCardActionSelect(PollTaskRequestTypes.FINISH); + 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); + setActionMenuVisible(false); + }, + }); + + React.useEffect(() => { + if (actionMenuVisible) { + //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]); + + return ( + <> + + + ); +}; + +export {PollCardMoreActions}; diff --git a/polling/components/PollList.tsx b/polling/components/PollList.tsx new file mode 100644 index 0000000..25f178b --- /dev/null +++ b/polling/components/PollList.tsx @@ -0,0 +1,38 @@ +import React from 'react'; +import {View, Text, StyleSheet} from 'react-native'; +import {PollCard} from './PollCard'; +import {usePoll} from '../context/poll-context'; +import {ThemeConfig} from 'customization-api'; + +export default function PollList() { + const {polls, isHost} = usePoll(); + + return ( + + + + Past Polls ({Object.keys(polls).length}) + + + + {polls && Object.keys(polls).length > 0 ? ( + Object.keys(polls).map((key: string) => ( + + )) + ) : ( + <> + )} + + + ); +} + +const style = StyleSheet.create({ + titleText: { + color: $config.FONT_COLOR, + fontSize: ThemeConfig.FontSize.tiny, + fontFamily: ThemeConfig.FontFamily.sansPro, + fontWeight: '600', + lineHeight: 12, + }, +}); diff --git a/polling/components/PollSidebar.tsx b/polling/components/PollSidebar.tsx new file mode 100644 index 0000000..8441719 --- /dev/null +++ b/polling/components/PollSidebar.tsx @@ -0,0 +1,101 @@ +/* +******************************************** + Copyright © 2021 Agora Lab, Inc., all rights reserved. + AppBuilder and all associated components, source code, APIs, services, and documentation + (the “Materials”) are owned by Agora Lab, Inc. and its licensors. The Materials may not be + accessed, used, modified, or distributed for any purpose without a license from Agora Lab, Inc. + Use without a license or in violation of any license terms and conditions (including use for + any purpose competitive to Agora Lab, Inc.’s business) is strictly prohibited. For more + information visit https://appbuilder.agora.io. +********************************************* +*/ +import React from 'react'; +import {Text, View, StyleSheet} from 'react-native'; +import {PrimaryButton, ThemeConfig} from 'customization-api'; +import {usePoll} from '../context/poll-context'; +import PollList from './PollList'; + +const PollSidebar = () => { + const {startPollForm, isHost} = usePoll(); + + return ( + + {/* Header */} + {isHost() ? ( + <> + + + + Create a new poll and boost interaction with your audience + members now! + + + startPollForm()} + text="Create Poll" + /> + + + + + + ) : ( + <> + )} + + + ); +}; + +const style = StyleSheet.create({ + pollSidebar: { + backgroundColor: $config.CARD_LAYER_1_COLOR, + }, + headerSection: { + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + justifyContent: 'center', + }, + headerCard: { + display: 'flex', + flexDirection: 'column', + justifyContent: 'center', + alignItems: 'flex-start', + gap: 16, + padding: 20, + backgroundColor: $config.CARD_LAYER_3_COLOR, + borderRadius: 15, + }, + bodyXSmallText: { + color: $config.FONT_COLOR + ThemeConfig.EmphasisPlus.medium, + fontSize: ThemeConfig.FontSize.small, + fontFamily: ThemeConfig.FontFamily.sansPro, + fontWeight: '400', + lineHeight: 16, + }, + btnContainer: { + minWidth: 150, + height: 36, + borderRadius: 4, + paddingVertical: 10, + paddingHorizontal: 8, + }, + btnText: { + color: $config.FONT_COLOR, + fontSize: ThemeConfig.FontSize.small, + fontFamily: ThemeConfig.FontFamily.sansPro, + fontWeight: '600', + textTransform: 'capitalize', + }, + separator: { + marginVertical: 24, + height: 1, + display: 'flex', + backgroundColor: $config.CARD_LAYER_3_COLOR, + }, +}); + +export default PollSidebar; diff --git a/polling/components/PollTimer.tsx b/polling/components/PollTimer.tsx new file mode 100644 index 0000000..efd60ac --- /dev/null +++ b/polling/components/PollTimer.tsx @@ -0,0 +1,53 @@ +import React, {useEffect} from 'react'; +import {Text, View, StyleSheet} from 'react-native'; +import {useCountdown} from '../hook/useCountdownTimer'; +import {ThemeConfig} 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, setFreezeForm}: Props) { + const [days, hours, minutes, seconds] = useCountdown(expiresAt); + + 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) { + setFreezeForm(true); + } + }, [days, hours, minutes, seconds, setFreezeForm]); + + return ( + + {getTime()} + + ); +} + +export const style = StyleSheet.create({ + timer: { + color: $config.SEMANTIC_WARNING, + fontFamily: ThemeConfig.FontFamily.sansPro, + fontSize: 16, + lineHeight: 20, + }, +}); diff --git a/polling/components/form/DraftPollFormView.tsx b/polling/components/form/DraftPollFormView.tsx new file mode 100644 index 0000000..3565060 --- /dev/null +++ b/polling/components/form/DraftPollFormView.tsx @@ -0,0 +1,399 @@ +import {Text, View, StyleSheet, TextInput} from 'react-native'; +import React from 'react'; +import { + BaseModalTitle, + BaseModalContent, + BaseModalActions, + BaseModalCloseIcon, +} from '../../ui/BaseModal'; +import { + LinkButton, + Checkbox, + IconButton, + PrimaryButton, + ThemeConfig, +} from 'customization-api'; +import {PollFormErrors, PollItem, PollKind} from '../../context/poll-context'; +import {nanoid} from 'nanoid'; + +function FormTitle({title}: {title: string}) { + return ( + + {title} + + ); +} +interface Props { + form: PollItem; + setForm: React.Dispatch>; + onPreview: () => void; + errors: Partial; + onClose: () => void; +} + +export default function DraftPollFormView({ + form, + setForm, + onPreview, + errors, + onClose, +}: Props) { + const handleInputChange = (field: string, value: string | boolean) => { + setForm({ + ...form, + [field]: value, + }); + }; + + const handleCheckboxChange = (field: keyof typeof form, value: boolean) => { + 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({ + ...form, + options: form.options.map((option, i) => { + if (i === index) { + const text = value.trim(); + const lowerText = text + .replace(/\s+/g, '-') + .toLowerCase() + .concat('-') + .concat(nanoid(2)); + return { + ...option, + text: text, + value: lowerText, + votes: [], + }; + } + return option; + }), + }); + } + if (action === 'delete') { + setForm({ + ...form, + options: form.options.filter((option, i) => i !== index), + }); + } + }; + + const getTitle = (type: PollKind) => { + if (type === PollKind.MCQ) { + return 'Multiple Choice'; + } + if (type === PollKind.OPEN_ENDED) { + return 'Open Ended Poll'; + } + if (type === PollKind.YES_NO) { + return 'Yes/No'; + } + }; + + return ( + <> + + + + + {/* Question section */} + + + + + { + handleInputChange('question', text); + }} + placeholder="Enter poll question here..." + placeholderTextColor={ + $config.FONT_COLOR + ThemeConfig.EmphasisPlus.low + } + /> + {errors?.question && ( + {errors.question.message} + )} + + + {/* Options section */} + {form.type === PollKind.MCQ || form.type === PollKind.YES_NO ? ( + + + + {form.type === PollKind.MCQ ? ( + <> + {form.options.map((option, index) => ( + + {index + 1} + { + updateFormOption('update', text, index); + }} + placeholder="Add text here..." + placeholderTextColor={ + $config.FONT_COLOR + ThemeConfig.EmphasisPlus.low + } + /> + {index > 1 ? ( + + { + updateFormOption('delete', option.text, index); + }} + /> + + ) : ( + <> + )} + + ))} + + { + updateFormOption('add', null, null); + }} + /> + + {errors?.options && ( + + {errors.options.message} + + )} + + ) : ( + <> + )} + {form.type === PollKind.YES_NO ? ( + <> + + Yes + + + No + + + ) : ( + <> + )} + + + ) : ( + <> + )} + {/* Sections templete */} + + + + {form.type === PollKind.MCQ ? ( + { + handleCheckboxChange( + 'multiple_response', + !form.multiple_response, + ); + }} + /> + ) : ( + <> + )} + + {/* + { + handleCheckboxChange('share', !form.share); + }} + /> + */} + {/* + { + handleCheckboxChange('duration', !form.duration); + }} + /> + */} + + + + + + + { + onPreview(); + }} + text="Preview" + /> + + + + ); +} + +export const style = StyleSheet.create({ + createPollBox: { + display: 'flex', + flexDirection: 'column', + gap: 20, + }, + pFormSection: { + gap: 12, + }, + pFormAddOptionLinkSection: { + marginTop: -8, + paddingVertical: 8, + paddingHorizontal: 16, + alignItems: 'flex-start', + }, + pFormTitle: { + color: $config.FONT_COLOR + ThemeConfig.EmphasisPlus.high, + fontSize: ThemeConfig.FontSize.small, + fontFamily: ThemeConfig.FontFamily.sansPro, + lineHeight: 16, + 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: 20, + }, + pFormOptionText: { + color: $config.FONT_COLOR + ThemeConfig.EmphasisPlus.high, + fontSize: ThemeConfig.FontSize.small, + fontFamily: ThemeConfig.FontFamily.sansPro, + lineHeight: 16, + fontWeight: '400', + }, + pFormOptionPrefix: { + color: $config.FONT_COLOR + ThemeConfig.EmphasisPlus.low, + paddingRight: 4, + }, + pFormOptionLink: { + fontWeight: '400', + lineHeight: 24, + }, + pFormOptions: { + paddingVertical: 8, + gap: 8, + }, + 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, + }, + pFormOptionCard: { + display: 'flex', + paddingHorizontal: 16, + flexDirection: 'row', + justifyContent: 'flex-start', + alignItems: 'center', + alignSelf: 'stretch', + gap: 8, + backgroundColor: $config.INPUT_FIELD_BACKGROUND_COLOR, + borderRadius: 9, + }, + verticalPadding: { + paddingVertical: 12, + }, + pFormCheckboxContainer: { + paddingHorizontal: 16, + paddingVertical: 8, + }, + previewActions: { + flex: 1, + display: 'flex', + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'flex-end', + }, + btnContainer: { + minWidth: 150, + height: 36, + borderRadius: 4, + }, + btnText: { + color: $config.FONT_COLOR, + fontSize: ThemeConfig.FontSize.small, + fontFamily: ThemeConfig.FontFamily.sansPro, + fontWeight: '600', + textTransform: 'capitalize', + }, + errorText: { + color: $config.SEMANTIC_ERROR, + fontSize: ThemeConfig.FontSize.tiny, + fontFamily: ThemeConfig.FontFamily.sansPro, + paddingLeft: 5, + paddingTop: 5, + }, +}); diff --git a/polling/components/form/PreviewPollFormView.tsx b/polling/components/form/PreviewPollFormView.tsx new file mode 100644 index 0000000..cc049a9 --- /dev/null +++ b/polling/components/form/PreviewPollFormView.tsx @@ -0,0 +1,153 @@ +import {Text, StyleSheet, View} from 'react-native'; +import React from 'react'; +import { + BaseModalTitle, + BaseModalContent, + BaseModalActions, + BaseModalCloseIcon, +} from '../../ui/BaseModal'; +import {PollItem} from '../../context/poll-context'; +import {POLL_DURATION} from './form-config'; +import BaseRadioButton from '../../ui/BaseRadioButton'; +import {TertiaryButton, Checkbox, ThemeConfig} from 'customization-api'; + +interface Props { + form: PollItem; + onEdit: () => void; + onSave: (launch: boolean) => void; + onClose: () => void; +} + +export default function PreviewPollFormView({ + form, + onEdit, + onSave, + onClose, +}: Props) { + return ( + <> + + + + + + {form.duration && ( + {POLL_DURATION} + )} + {form.question} + {form?.options ? ( + + {form.multiple_response + ? form.options.map((option, index) => ( + + {}} + /> + + )) + : form.options.map((option, index) => ( + + {}} + /> + + ))} + + ) : ( + <> + )} + + + + + + { + onEdit(); + }} + text="Edit" + /> + + + { + onSave(false); + }} + /> + + + { + onSave(true); + }} + /> + + + + + ); +} + +export const style = StyleSheet.create({ + previewContainer: { + width: 550, + }, + previewTimer: { + color: $config.SEMANTIC_WARNING, + fontFamily: ThemeConfig.FontFamily.sansPro, + fontSize: 16, + lineHeight: 20, + paddingBottom: 12, + }, + previewQuestion: { + color: $config.FONT_COLOR + ThemeConfig.EmphasisPlus.high, + fontSize: ThemeConfig.FontSize.medium, + fontFamily: ThemeConfig.FontFamily.sansPro, + lineHeight: 24, + fontWeight: '600', + paddingBottom: 20, + }, + previewOptionSection: { + backgroundColor: $config.INPUT_FIELD_BACKGROUND_COLOR, + borderRadius: 9, + paddingVertical: 8, + display: 'flex', + flexDirection: 'column', + gap: 4, + }, + previewOptionCard: { + display: 'flex', + paddingHorizontal: 16, + paddingVertical: 8, + }, + previewOptionText: { + color: $config.FONT_COLOR, + fontSize: ThemeConfig.FontSize.normal, + fontFamily: ThemeConfig.FontFamily.sansPro, + fontWeight: '400', + lineHeight: 24, + }, + previewActions: { + flex: 1, + display: 'flex', + flexDirection: 'row', + gap: 16, + }, + btnContainer: { + flex: 1, + }, +}); diff --git a/polling/components/form/SelectNewPollTypeFormView.tsx b/polling/components/form/SelectNewPollTypeFormView.tsx new file mode 100644 index 0000000..8134cdc --- /dev/null +++ b/polling/components/form/SelectNewPollTypeFormView.tsx @@ -0,0 +1,119 @@ +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} from 'customization-api'; + +interface newPollType { + key: PollKind; + image: null; + title: string; + description: string; +} + +const newPollTypeConfig: newPollType[] = [ + { + key: PollKind.MCQ, + image: null, + title: 'Multiple Choice', + description: 'Quick stand-alone question with different options', + }, + // { + // key: PollKind.OPEN_ENDED, + // image: null, + // title: 'Open Ended', + // description: 'Question with a descriptive, open text response', + // }, + { + key: PollKind.YES_NO, + image: null, + title: 'Yes / No', + description: 'A simple question with a binary Yes or No response', + }, +]; + +export default function SelectNewPollTypeFormView({ + setType, + onClose, +}: { + setType: React.Dispatch>; + onClose: () => void; +}) { + return ( + <> + + + + + + {newPollTypeConfig.map((item: newPollType) => ( + { + setType(item.key); + }}> + + + + {item.title} + {item.description} + + + + ))} + + + + ); +} + +export const style = StyleSheet.create({ + section: { + display: 'flex', + flexDirection: 'row', + gap: 20, + justifyContent: 'space-around', + }, + card: { + flexDirection: 'column', + gap: 12, + width: 140, + outlineStyle: 'none', + }, + cardImage: { + height: 90, + flexDirection: 'column', + justifyContent: 'center', + alignItems: 'center', + gap: 8, + borderRadius: 8, + borderWidth: 1, + borderColor: $config.CARD_LAYER_3_COLOR, + backgroundColor: $config.CARD_LAYER_4_COLOR, + }, + cardContent: { + display: 'flex', + flexDirection: 'column', + gap: 4, + }, + cardContentTitle: { + color: $config.FONT_COLOR + ThemeConfig.EmphasisPlus.high, + fontSize: ThemeConfig.FontSize.small, + fontFamily: ThemeConfig.FontFamily.sansPro, + lineHeight: 16, + fontWeight: '400', + }, + cardContentDesc: { + color: $config.FONT_COLOR + ThemeConfig.EmphasisPlus.low, + fontSize: ThemeConfig.FontSize.tiny, + fontFamily: ThemeConfig.FontFamily.sansPro, + lineHeight: 16, + fontWeight: '400', + }, +}); diff --git a/polling/components/form/form-config.ts b/polling/components/form/form-config.ts new file mode 100644 index 0000000..23dfe2c --- /dev/null +++ b/polling/components/form/form-config.ts @@ -0,0 +1,118 @@ +import {nanoid} from 'nanoid'; +import { + PollKind, + PollItem, + PollAccess, + 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): PollItem => { + if (kind === PollKind.OPEN_ENDED) { + return { + id: nanoid(4), + type: PollKind.OPEN_ENDED, + access: PollAccess.PUBLIC, + status: PollStatus.LATER, + question: '', + answers: null, + options: null, + multiple_response: false, + share: false, + duration: false, + expiresAt: null, + createdBy: -1, + }; + } + if (kind === PollKind.MCQ) { + return { + id: nanoid(4), + type: PollKind.MCQ, + access: PollAccess.PUBLIC, + status: PollStatus.LATER, + question: '', + answers: null, + options: [ + { + text: '', + value: '', + votes: [], + percent: '0', + }, + { + text: '', + value: '', + votes: [], + percent: '0', + }, + { + text: '', + value: '', + votes: [], + percent: '0', + }, + ], + multiple_response: true, + share: false, + duration: false, + expiresAt: null, + createdBy: -1, + }; + } + if (kind === PollKind.YES_NO) { + return { + id: nanoid(4), + type: PollKind.YES_NO, + access: PollAccess.PUBLIC, + 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: false, + duration: false, + expiresAt: null, + createdBy: -1, + }; + } +}; + +const getAttributeLengthInKb = (attribute: string): string => { + const b = attribute.length * 2; + const kb = (b / 1024).toFixed(2); + return kb; +}; + +const isAttributeLengthValid = (attribute: string) => { + if (getAttributeLengthInKb(attribute) > '8') { + return false; + } + return true; +}; + +export { + getPollExpiresAtTime, + initPollForm, + isAttributeLengthValid, + POLL_DURATION, +}; diff --git a/polling/components/form/poll-response-forms.tsx b/polling/components/form/poll-response-forms.tsx new file mode 100644 index 0000000..571733d --- /dev/null +++ b/polling/components/form/poll-response-forms.tsx @@ -0,0 +1,387 @@ +import {Text, View, StyleSheet, TextInput} from 'react-native'; +import React, {useState} from 'react'; +import {PollItem} from '../../context/poll-context'; +import PollTimer from '../PollTimer'; +import { + ImageIcon, + Checkbox, + PrimaryButton, + ThemeConfig, +} from 'customization-api'; +import BaseRadioButton from '../../ui/BaseRadioButton'; + +function PollResponseFormComplete() { + return ( + + + + + + Thank you for your response + + + ); +} + +interface PollResponseFormProps { + pollItem: PollItem; + isFormFreezed: boolean; + onComplete: (responses: string | string[]) => void; +} + +function PollRenderResponseForm({ + pollItem, + onFormComplete, +}: { + pollItem: PollItem; + onFormComplete: (responses: string | string[]) => void; +}): JSX.Element { + const [isFormFreezed, setFreezeForm] = useState(false); + + const renderSwitch = () => { + switch (pollItem.type) { + case 'OPEN_ENDED': + return ( + + ); + case 'MCQ': + case 'YES_NO': + return ( + + ); + default: + return Unknown type; + } + }; + return ( + <> + {pollItem.duration ? ( + + ) : null} + {pollItem.question} + {renderSwitch()} + + ); +} + +function PollResponseQuestionForm({ + pollItem, + isFormFreezed, + onComplete, +}: PollResponseFormProps) { + const [answer, setAnswer] = useState(''); + + return ( + + + + + + { + if (!answer || answer.trim() === '') { + return; + } + onComplete(answer); + }} + text="Submit" + /> + + + ); +} + +function PollResponseMCQForm({ + pollItem, + isFormFreezed, + onComplete, +}: PollResponseFormProps) { + const [selectedOption, setSelectedOption] = useState(null); + const [selectedOptions, setSelectedOptions] = useState([]); + + const handleCheckboxToggle = (value: string) => { + setSelectedOptions(prevSelectedOptions => { + if (prevSelectedOptions.includes(value)) { + return prevSelectedOptions.filter(option => option !== value); + } else { + return [...prevSelectedOptions, value]; + } + }); + }; + + const handleRadioSelect = (option: string) => { + setSelectedOption(option); + }; + + const handleSubmit = () => { + if (selectedOptions.length === 0 && !selectedOption) { + return; + } + if (pollItem.multiple_response) { + onComplete(selectedOptions); + } else { + onComplete(selectedOption); + } + }; + + return ( + + + {pollItem.multiple_response + ? pollItem.options.map((option, index) => ( + + handleCheckboxToggle(option.value)} + /> + + )) + : pollItem.options.map((option, index) => ( + + + + ))} + + + + + + ); +} + +export { + PollResponseQuestionForm, + PollResponseMCQForm, + PollResponseFormComplete, + PollRenderResponseForm, +}; + +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, + }, + heading4: { + 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, + height: 36, + borderRadius: 4, + }, + btnText: { + color: $config.FONT_COLOR, + fontSize: ThemeConfig.FontSize.small, + fontFamily: ThemeConfig.FontFamily.sansPro, + fontWeight: '600', + textTransform: 'capitalize', + }, + optionsSection: { + backgroundColor: $config.INPUT_FIELD_BACKGROUND_COLOR, + borderRadius: 9, + marginBottom: 32, + display: 'flex', + flexDirection: 'column', + gap: 4, + paddingVertical: 8, + }, + optionCard: { + display: 'flex', + flexDirection: 'row', + paddingHorizontal: 16, + paddingVertical: 8, + alignItems: 'center', + }, + optionCardText: { + color: $config.FONT_COLOR, + fontSize: ThemeConfig.FontSize.normal, + fontFamily: ThemeConfig.FontFamily.sansPro, + fontWeight: '400', + lineHeight: 24, + }, + // pFormOptionText: { + // color: $config.FONT_COLOR + ThemeConfig.EmphasisPlus.high, + // fontSize: ThemeConfig.FontSize.small, + // fontFamily: ThemeConfig.FontFamily.sansPro, + // lineHeight: 16, + // fontWeight: '400', + // }, + // pFormOptionPrefix: { + // color: $config.FONT_COLOR + ThemeConfig.EmphasisPlus.low, + // paddingRight: 4, + // }, + // pFormOptionLink: { + // fontWeight: '400', + // lineHeight: 24, + // }, + // pFormOptions: { + // paddingVertical: 8, + // gap: 8, + // }, + 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, + }, + // pFormOptionCard: { + // display: 'flex', + // paddingHorizontal: 16, + // flexDirection: 'row', + // justifyContent: 'flex-start', + // alignItems: 'center', + // alignSelf: 'stretch', + // gap: 8, + // backgroundColor: $config.INPUT_FIELD_BACKGROUND_COLOR, + // borderRadius: 9, + // }, + // verticalPadding: { + // paddingVertical: 12, + // }, + // pFormCheckboxContainer: { + // paddingHorizontal: 16, + // paddingVertical: 8, + // }, + // previewActions: { + // flex: 1, + // display: 'flex', + // flexDirection: 'row', + // alignItems: 'center', + // justifyContent: 'flex-end', + // }, + // btnContainer: { + // minWidth: 150, + // height: 36, + // borderRadius: 4, + // }, + // btnText: { + // color: $config.FONT_COLOR, + // fontSize: ThemeConfig.FontSize.small, + // fontFamily: ThemeConfig.FontFamily.sansPro, + // fontWeight: '600', + // textTransform: 'capitalize', + // }, +}); diff --git a/polling/components/modals/PollFormWizardModal.tsx b/polling/components/modals/PollFormWizardModal.tsx new file mode 100644 index 0000000..5cbcead --- /dev/null +++ b/polling/components/modals/PollFormWizardModal.tsx @@ -0,0 +1,135 @@ +import React, {useEffect, useState} 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, filterObject} from 'customization-api'; + +type FormWizardStep = 'SELECT' | 'DRAFT' | 'PREVIEW'; + +export default function PollFormWizardModal() { + const {polls, savePoll, sendPoll, closeCurrentModal} = usePoll(); + const [step, setStep] = useState('SELECT'); + const [type, setType] = useState(null); + const [form, setForm] = useState(null); + const [formErrors, setFormErrors] = useState(null); + + const localUid = useLocalUid(); + + useEffect(() => { + if (!type) { + return; + } + setForm(initPollForm(type)); + setStep('DRAFT'); + }, [type]); + + const onSave = (launch?: boolean) => { + if (launch) { + // check if there is an already launched poll + const isAnyPollActive = Object.keys( + filterObject(polls, ([_, v]) => v.status === PollStatus.ACTIVE), + ); + if (isAnyPollActive.length > 0) { + console.error( + 'Cannot publish poll now as there is already one poll active', + ); + return; + } + } + const payload = { + ...form, + status: launch ? PollStatus.ACTIVE : PollStatus.LATER, + createdBy: localUid, + }; + savePoll(payload); + if (launch) { + sendPoll(payload); + } + }; + + const onEdit = () => { + setStep('DRAFT'); + }; + + const onPreview = () => { + if (validateForm()) { + setStep('PREVIEW'); + } + }; + + const validateForm = () => { + setFormErrors(null); + if (form.question.trim() === '') { + setFormErrors({ + ...formErrors, + question: {message: 'Cannot be blank'}, + }); + return false; + } + if ( + form.type === PollKind.MCQ && + form.options && + (form.options.length === 0 || + form.options.find(item => item.text.trim() === '')) + ) { + setFormErrors({ + ...formErrors, + options: {message: 'Cannot be empty'}, + }); + return false; + } + return true; + }; + + const onClose = () => { + setFormErrors(null); + setForm(null); + setType(null); + closeCurrentModal(); + }; + + function renderSwitch() { + switch (step) { + case 'SELECT': + return ( + + ); + case 'DRAFT': + return ( + + ); + case 'PREVIEW': + return ( + + ); + default: + return <>; + } + } + + return ( + + {renderSwitch()} + + ); +} diff --git a/polling/components/modals/PollResponseFormModal.tsx b/polling/components/modals/PollResponseFormModal.tsx new file mode 100644 index 0000000..832c770 --- /dev/null +++ b/polling/components/modals/PollResponseFormModal.tsx @@ -0,0 +1,57 @@ +import React, {useState} from 'react'; +import { + BaseModal, + BaseModalCloseIcon, + BaseModalContent, + BaseModalTitle, +} from '../../ui/BaseModal'; +import { + PollResponseFormComplete, + PollRenderResponseForm, +} from '../form/poll-response-forms'; +import {usePoll} from '../../context/poll-context'; +import PollAvatarHeader from '../PollAvatarHeader'; +import {PollTaskRequestTypes} from '../PollCardMoreActions'; + +export default function PollResponseFormModal() { + const { + polls, + launchPollId, + sendResponseToPoll, + handlePollTaskRequest, + closeCurrentModal, + } = usePoll(); + const [hasResponded, setHasResponded] = useState(false); + + const pollItem = polls[launchPollId]; + + const onFormComplete = (responses: string | string[]) => { + sendResponseToPoll(pollItem, responses); + if (pollItem.share) { + handlePollTaskRequest(PollTaskRequestTypes.VIEW_DETAILS, pollItem.id); + } else { + setHasResponded(true); + } + }; + + return ( + + + + + + + {hasResponded ? ( + + ) : ( + <> + + + )} + + + ); +} diff --git a/polling/components/modals/PollResultModal.tsx b/polling/components/modals/PollResultModal.tsx new file mode 100644 index 0000000..716d72b --- /dev/null +++ b/polling/components/modals/PollResultModal.tsx @@ -0,0 +1,98 @@ +import {Text, StyleSheet, View} from 'react-native'; +import React from 'react'; +import { + BaseModal, + BaseModalTitle, + BaseModalContent, + BaseModalCloseIcon, +} from '../../ui/BaseModal'; +import {ThemeConfig} from 'customization-api'; +import PollAvatarHeader from '../PollAvatarHeader'; +import {PollItemOptionItem, usePoll} from '../../context/poll-context'; +import {PollOptionList, PollOptionListItemResult} from '../poll-option-item-ui'; + +export default function PollResultModal() { + const {polls, viewResultPollId, isHost, closeCurrentModal} = usePoll(); + + const pollItem = polls[viewResultPollId]; + + return ( + + + + + + + + {pollItem.question} + + + {pollItem.options.map((item: PollItemOptionItem) => ( + + ))} + + + + + + ); +} + +export const style = StyleSheet.create({ + shareBox: { + width: 550, + display: 'flex', + flexDirection: 'column', + gap: 20, + }, + 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, + }, + questionText: { + color: $config.FONT_COLOR + ThemeConfig.EmphasisPlus.high, + fontSize: ThemeConfig.FontSize.medium, + fontFamily: ThemeConfig.FontFamily.sansPro, + lineHeight: 24, + fontWeight: '600', + }, +}); diff --git a/polling/components/poll-option-item-ui.tsx b/polling/components/poll-option-item-ui.tsx new file mode 100644 index 0000000..1c11677 --- /dev/null +++ b/polling/components/poll-option-item-ui.tsx @@ -0,0 +1,104 @@ +import React from 'react'; +import {Text, View, StyleSheet, DimensionValue} from 'react-native'; +import {PollItemOptionItem} from '../context/poll-context'; +import {ThemeConfig, useLocalUid} from 'customization-api'; + +interface PollOptionListItem { + optionItem: PollItemOptionItem; + showYourVote?: boolean; +} + +function PollOptionList({children}: {children: React.ReactNode}) { + return {children}; +} + +function PollOptionListItemResult({ + optionItem, + showYourVote, +}: PollOptionListItem) { + const localUid = useLocalUid(); + return ( + + + {optionItem.text} + {showYourVote && + optionItem.votes.some(item => item.uid === localUid) && ( + Your Response + )} + + {optionItem.percent}% ({optionItem.votes.length}) + + + + + + + + + ); +} + +const style = StyleSheet.create({ + optionsList: { + backgroundColor: $config.INPUT_FIELD_BACKGROUND_COLOR, + borderRadius: 9, + paddingTop: 8, + paddingHorizontal: 12, + paddingBottom: 32, + display: 'flex', + flexDirection: 'column', + gap: 4, + }, + optionListItem: { + display: 'flex', + flexDirection: 'column', + gap: 4, + }, + optionListItemHeader: { + display: 'flex', + flexDirection: 'row', + paddingHorizontal: 16, + paddingVertical: 8, + alignItems: 'center', + }, + optionListItemFooter: {}, + optionText: { + color: $config.FONT_COLOR, + fontSize: ThemeConfig.FontSize.normal, + fontFamily: ThemeConfig.FontFamily.sansPro, + fontWeight: '400', + lineHeight: 24, + }, + yourResponseText: { + color: $config.SEMANTIC_SUCCESS, + fontSize: ThemeConfig.FontSize.tiny, + fontFamily: ThemeConfig.FontFamily.sansPro, + fontWeight: '600', + lineHeight: 12, + paddingLeft: 16, + }, + pushRight: { + marginLeft: 'auto', + }, + progressBar: { + height: 4, + borderRadius: 8, + backgroundColor: $config.CARD_LAYER_3_COLOR, + width: '100%', + }, + progressBarFill: { + borderRadius: 8, + backgroundColor: $config.PRIMARY_ACTION_BRAND_COLOR, + }, +}); + +export {PollOptionList, PollOptionListItemResult}; diff --git a/polling/context/poll-context.tsx b/polling/context/poll-context.tsx new file mode 100644 index 0000000..a27a9a8 --- /dev/null +++ b/polling/context/poll-context.tsx @@ -0,0 +1,445 @@ +import React, {createContext, useReducer, Dispatch, useState} from 'react'; +import {usePollEvents} from './poll-events'; +import {useLocalUid, useLiveStreamDataContext} from 'customization-api'; +import { + getPollExpiresAtTime, + POLL_DURATION, +} from '../components/form/form-config'; +import {PollTaskRequestTypes} from '../components/PollCardMoreActions'; +import { + addVote, + arrayToCsv, + calculatePercentage, + downloadCsv, +} from '../helpers'; + +enum PollAccess { + PUBLIC = 'PUBLIC', +} + +enum PollStatus { + ACTIVE = 'ACTIVE', + FINISHED = 'FINISHED', + LATER = 'LATER', +} + +enum PollKind { + OPEN_ENDED = 'OPEN_ENDED', + MCQ = 'MCQ', + YES_NO = 'YES_NO', +} + +enum PollModalState { + DRAFT_POLL = 'DRAFT_POLL', + RESPOND_TO_POLL = 'RESPOND_TO_POLL', + VIEW_POLL_RESULTS = 'VIEW_POLL_RESULTS', +} + +interface PollItemOptionItem { + text: string; + value: string; + votes: Array<{uid: number; access: PollAccess; timestamp: number}>; + percent: string; +} +interface PollItem { + id: string; + type: PollKind; + access: PollAccess; // remove it as poll are not private or public but the response will be public or private + status: PollStatus; + question: string; + answers: Array<{ + uid: number; + response: string; + timestamp: number; + }> | null; + options: Array | null; + multiple_response: boolean; + share: boolean; + duration: boolean; + expiresAt: number; + createdBy: number; +} + +type Poll = Record; + +interface PollFormErrors { + question?: { + message: string; + }; + options?: { + message: string; + }; +} + +enum PollActionKind { + ADD_POLL_ITEM = 'ADD_POLL_ITEM', + UPDATE_POLL_ITEM = 'UPDATE_POLL_ITEM', + UPDATE_POLL_ITEM_RESPONSES = 'UPDATE_POLL_ITEM_RESPONSES', + DELETE_POLL_ITEM = 'DELETE_POLL_ITEM', + EXPORT_POLL_ITEM = 'EXPORT_POLL_ITEM', + FINISH_POLL_ITEM = 'FINISH_POLL_ITEM', +} + +type PollAction = + | { + type: PollActionKind.ADD_POLL_ITEM; + payload: {item: PollItem}; + } + | { + type: PollActionKind.UPDATE_POLL_ITEM; + payload: {item: PollItem}; + } + | { + type: PollActionKind.UPDATE_POLL_ITEM_RESPONSES; + payload: { + id: string; + type: PollKind; + responses: string | string[]; + uid: number; + timestamp: number; + }; + } + | { + type: PollActionKind.FINISH_POLL_ITEM; + payload: {pollId: string}; + } + | { + type: PollActionKind.EXPORT_POLL_ITEM; + payload: {pollId: string}; + } + | { + type: PollActionKind.DELETE_POLL_ITEM; + payload: {pollId: string}; + }; + +function pollReducer(state: Poll, action: PollAction): Poll { + switch (action.type) { + case PollActionKind.ADD_POLL_ITEM: { + const pollId = action.payload.item.id; + return { + ...state, + [pollId]: {...action.payload.item}, + }; + } + case PollActionKind.UPDATE_POLL_ITEM: { + const pollId = action.payload.item.id; + return { + ...state, + [pollId]: {...action.payload.item}, + }; + } + case PollActionKind.UPDATE_POLL_ITEM_RESPONSES: + { + const {id: pollId, uid, responses, type, timestamp} = action.payload; + const poll = state[pollId]; + if (type === PollKind.OPEN_ENDED && typeof responses === 'string') { + return { + ...state, + [pollId]: { + ...poll, + answers: poll.answers + ? [ + ...poll.answers, + { + uid, + response: responses, + timestamp, + }, + ] + : [{uid, response: responses, timestamp}], + }, + }; + } + if (type === PollKind.MCQ && Array.isArray(responses)) { + const newCopyOptions = poll.options?.map(item => ({...item})) || []; + const withVotesOptions = addVote( + responses, + newCopyOptions, + uid, + timestamp, + ); + const withPercentOptions = calculatePercentage(withVotesOptions); + return { + ...state, + [pollId]: { + ...poll, + options: withPercentOptions, + }, + }; + } + } + break; + case PollActionKind.FINISH_POLL_ITEM: + { + const pollId = action.payload.pollId; + if (pollId) { + return { + ...state, + [pollId]: {...state[pollId], status: PollStatus.FINISHED}, + }; + } + } + break; + case PollActionKind.EXPORT_POLL_ITEM: + { + const pollId = action.payload.pollId; + if (pollId) { + let csv = arrayToCsv(state[pollId].options); + downloadCsv(csv, 'polls.csv'); + } + } + break; + 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, + }; + } + } + break; + default: { + return state; + } + } +} + +interface PollContextValue { + polls: Poll; + currentModal: PollModalState; + dispatch: Dispatch; + startPollForm: () => void; + savePoll: (item: PollItem) => void; + sendPoll: (item: PollItem) => void; + onPollReceived: (item: PollItem, launchId: string) => void; + sendResponseToPoll: (item: PollItem, responses: string | string[]) => void; + onPollResponseReceived: ( + id: string, + type: PollKind, + responses: string | string[], + sender: number, + ts: number, + ) => void; + launchPollId: string; + viewResultPollId: string; + sendPollResults: (item: PollItem) => void; + onPollResultsReceived: (item: PollItem) => void; + 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 [currentModal, setCurrentModal] = useState(null); + const [launchPollId, setLaunchPollId] = useState(null); + const [viewResultPollId, setViewResultPollId] = useState(null); + + const localUid = useLocalUid(); + const {hostUids} = useLiveStreamDataContext(); + + const {sendPollEvt, sendResponseToPollEvt, sendPollResultsEvt} = + usePollEvents(); + const isHost = () => { + if (hostUids.includes(localUid)) { + return true; + } + return false; + }; + + const startPollForm = () => { + setCurrentModal(PollModalState.DRAFT_POLL); + }; + + const savePoll = (item: PollItem) => { + addPollItem(item); + setCurrentModal(null); + }; + + const sendPoll = (item: PollItem) => { + if (item.status === PollStatus.ACTIVE) { + item.expiresAt = getPollExpiresAtTime(POLL_DURATION); + sendPollEvt(item); + setCurrentModal(null); + } else { + console.error('Poll: Cannot send poll as the status is not active'); + } + }; + + const onPollReceived = (item: PollItem, launchId: string) => { + addPollItem(item); + if (!isHost()) { + setLaunchPollId(launchId); + setCurrentModal(PollModalState.RESPOND_TO_POLL); + } + }; + + const sendResponseToPoll = (item: PollItem, responses: string | string[]) => { + if ( + (item.type === PollKind.OPEN_ENDED && typeof responses === 'string') || + (item.type === PollKind.MCQ && Array.isArray(responses)) + ) { + dispatch({ + type: PollActionKind.UPDATE_POLL_ITEM_RESPONSES, + payload: { + id: item.id, + type: item.type, + responses, + uid: localUid, + timestamp: Date.now(), + }, + }); + sendResponseToPollEvt(item, responses); + } else { + throw new Error( + 'sendResponseToPoll received incorrect type response. Unable to send poll response', + ); + } + }; + + const onPollResponseReceived = ( + id: string, + type: PollKind, + responses: string | string[], + sender: number, + ts: number, + ) => { + dispatch({ + type: PollActionKind.UPDATE_POLL_ITEM_RESPONSES, + payload: { + id, + type, + responses, + uid: sender, + timestamp: ts, + }, + }); + }; + + const sendPollResults = (item: PollItem) => { + sendPollResultsEvt(item); + }; + + const onPollResultsReceived = (item: PollItem) => { + updatePollItem(item); + }; + + const addPollItem = (item: PollItem) => { + dispatch({ + type: PollActionKind.ADD_POLL_ITEM, + payload: { + item: {...item}, + }, + }); + }; + + const updatePollItem = (item: PollItem) => { + dispatch({ + type: PollActionKind.UPDATE_POLL_ITEM, + payload: { + item: {...item}, + }, + }); + }; + + const handlePollTaskRequest = ( + task: PollTaskRequestTypes, + pollId: string, + ) => { + switch (task) { + case PollTaskRequestTypes.PUBLISH: + sendPollResults({...polls[pollId]}); + break; + case PollTaskRequestTypes.SHARE: + // No user case so far + break; + case PollTaskRequestTypes.VIEW_DETAILS: + setViewResultPollId(pollId); + setCurrentModal(PollModalState.VIEW_POLL_RESULTS); + break; + case PollTaskRequestTypes.DELETE: + dispatch({ + type: PollActionKind.DELETE_POLL_ITEM, + payload: { + pollId, + }, + }); + break; + case PollTaskRequestTypes.FINISH: + dispatch({ + type: PollActionKind.FINISH_POLL_ITEM, + payload: { + pollId, + }, + }); + break; + case PollTaskRequestTypes.EXPORT: + dispatch({ + type: PollActionKind.EXPORT_POLL_ITEM, + payload: { + pollId, + }, + }); + break; + default: + break; + } + }; + + const closeCurrentModal = () => { + if (currentModal === PollModalState.RESPOND_TO_POLL) { + setLaunchPollId(null); + } + if (currentModal === PollModalState.VIEW_POLL_RESULTS) { + setViewResultPollId(null); + } + setCurrentModal(null); + }; + + const value = { + polls, + dispatch, + startPollForm, + savePoll, + sendPoll, + onPollReceived, + onPollResponseReceived, + currentModal, + launchPollId, + viewResultPollId, + sendResponseToPoll, + sendPollResults, + onPollResultsReceived, + handlePollTaskRequest, + 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, + PollAccess, + PollModalState, +}; + +export type {PollItem, PollFormErrors, PollItemOptionItem}; diff --git a/polling/context/poll-events.tsx b/polling/context/poll-events.tsx new file mode 100644 index 0000000..95eddaa --- /dev/null +++ b/polling/context/poll-events.tsx @@ -0,0 +1,142 @@ +import React, {createContext, useContext, useEffect} from 'react'; +import {PollItem, usePoll} from './poll-context'; +import {customEvents as events, PersistanceLevel} from 'customization-api'; + +enum PollEventNames { + polls = 'POLLS', + pollResponse = 'POLL_RESPONSE', + pollResults = 'POLL_RESULTS', +} +enum PollEventActions { + sendPoll = 'SEND_POLL', + sendResponseToPoll = 'SEND_RESONSE_TO_POLL', + sendPollResults = 'SEND_POLL_RESULTS', +} + +type sendResponseToPollEvtFunction = ( + poll: PollItem, + responses: string | string[], +) => void; +interface PollEventsContextValue { + sendPollEvt: (poll: PollItem) => void; + sendResponseToPollEvt: sendResponseToPollEvtFunction; + sendPollResultsEvt: (poll: PollItem) => void; +} + +const PollEventsContext = createContext(null); +PollEventsContext.displayName = 'PollEventsContext'; + +// Event Dispatcher +function PollEventsProvider({children}: {children?: React.ReactNode}) { + const sendPollEvt = (poll: PollItem) => { + events.send( + PollEventNames.polls, + JSON.stringify({ + action: PollEventActions.sendPoll, + item: {...poll}, + activePollId: poll.id, + }), + PersistanceLevel.Channel, + ); + }; + + const sendResponseToPollEvt: sendResponseToPollEvtFunction = ( + item, + responses, + ) => { + events.send( + PollEventNames.pollResponse, + JSON.stringify({ + type: item.type, + id: item.id, + responses, + }), + PersistanceLevel.None, + ); + }; + + const sendPollResultsEvt = (item: PollItem) => { + events.send( + PollEventNames.polls, + JSON.stringify({ + action: PollEventActions.sendPollResults, + item: {...item}, + activePollId: '', + }), + PersistanceLevel.Channel, + ); + }; + + const value = { + sendPollEvt, + sendResponseToPollEvt, + sendPollResultsEvt, + }; + + 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 { + savePoll, + onPollReceived, + onPollResponseReceived, + onPollResultsReceived, + } = usePoll(); + + useEffect(() => { + events.on(PollEventNames.polls, args => { + // const {payload, sender, ts} = args; + const {payload} = args; + const data = JSON.parse(payload); + const {action, item, activePollId} = data; + console.log('supriya poll received', data); + switch (action) { + case PollEventActions.sendPoll: + onPollReceived(item, activePollId); + break; + case PollEventActions.sendPollResults: + onPollResultsReceived(item); + break; + default: + break; + } + }); + events.on(PollEventNames.pollResponse, args => { + const {payload, sender, ts} = args; + const data = JSON.parse(payload); + console.log('supriya poll response received', data); + const {type, id, responses} = data; + onPollResponseReceived(id, type, responses, sender, ts); + }); + + return () => { + events.off(PollEventNames.polls); + events.off(PollEventNames.pollResponse); + }; + }, [onPollReceived, onPollResponseReceived, savePoll, onPollResultsReceived]); + + return ( + + {children} + + ); +} + +export {usePollEvents, PollEventsProvider, PollEventsSubscriber}; diff --git a/polling/helpers.ts b/polling/helpers.ts new file mode 100644 index 0000000..3300a1d --- /dev/null +++ b/polling/helpers.ts @@ -0,0 +1,101 @@ +import {PollAccess, PollItemOptionItem} from './context/poll-context'; + +function addVote( + responses: string[], + options: PollItemOptionItem[], + 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); + if (exists) { + // Creating a new object explicitly + const newOption: PollItemOptionItem = { + ...option, + ...option, + votes: [ + ...option.votes, + { + uid, + access: PollAccess.PUBLIC, + 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(data: PollItemOptionItem[]): string { + const headers = ['text', 'value', 'votes', 'percent']; // Define the headers + const rows = data.map(item => { + const voteIds = item.votes.map(vote => vote.uid).join(', '); // Combine vote uids into a single string + return `${item.text},${voteIds}`; + }); + + return [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(string: string): string { + return string.charAt(0).toUpperCase() + string.slice(1).toLowerCase(); +} + +function iVoted(options: PollItemOptionItem[], myUid: number): boolean { + return options.some(optionItem => + optionItem.votes.some(item => item.uid === myUid), + ); +} + +export { + iVoted, + downloadCsv, + arrayToCsv, + addVote, + calculatePercentage, + capitalizeFirstLetter, +}; diff --git a/polling/hook/useCountdownTimer.tsx b/polling/hook/useCountdownTimer.tsx new file mode 100644 index 0000000..238f3ad --- /dev/null +++ b/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/polling/ui/BaseModal.tsx b/polling/ui/BaseModal.tsx new file mode 100644 index 0000000..591f054 --- /dev/null +++ b/polling/ui/BaseModal.tsx @@ -0,0 +1,160 @@ +import {Modal, View, StyleSheet, Text} from 'react-native'; +import React, {ReactNode} from 'react'; +import { + ThemeConfig, + hexadecimalTransparency, + IconButton, + isMobileUA, +} from 'customization-api'; + +interface TitleProps { + title?: string; + children?: ReactNode | ReactNode[]; +} + +function BaseModalTitle({title, children}: TitleProps) { + return ( + + {title && ( + + {title} + + )} + {children} + + ); +} + +interface ContentProps { + children: ReactNode; +} + +function BaseModalContent({children}: ContentProps) { + return {children}; +} + +interface ActionProps { + children: ReactNode; +} +function BaseModalActions({children}: ActionProps) { + return {children}; +} + +type BaseModalProps = { + visible?: boolean; + children: ReactNode; + width?: number; +}; + +const BaseModal = ({ + children, + visible = false, + width = 650, +}: BaseModalProps) => { + return ( + + + + {children} + + + + ); +}; + +type BaseModalCloseIconProps = { + onClose: () => void; +}; + +const BaseModalCloseIcon = ({onClose}: BaseModalCloseIconProps) => { + return ( + + + + ); +}; +export { + BaseModal, + BaseModalTitle, + BaseModalContent, + BaseModalActions, + BaseModalCloseIcon, +}; + +const style = StyleSheet.create({ + baseModalBackDrop: { + flex: 1, + position: 'relative', + justifyContent: 'center', + alignItems: 'center', + padding: 20, + backgroundColor: + $config.HARD_CODED_BLACK_COLOR + hexadecimalTransparency['60%'], + }, + baseModal: { + 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, + maxWidth: '90%', + maxHeight: 800, + overflow: 'scroll', + }, + scrollView: { + flex: 1, + }, + header: { + display: 'flex', + paddingHorizontal: 32, + paddingVertical: 20, + alignItems: 'center', + gap: 20, + minHeight: 72, + 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: 32, + gap: 20, + display: 'flex', + flexDirection: 'column', + // minWidth: 620, + }, + actions: { + height: 72, + paddingHorizontal: 32, + paddingVertical: 12, + display: 'flex', + gap: 16, + backgroundColor: $config.CARD_LAYER_2_COLOR, + }, +}); diff --git a/polling/ui/BaseMoreButton.tsx b/polling/ui/BaseMoreButton.tsx new file mode 100644 index 0000000..39a6bed --- /dev/null +++ b/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/polling/ui/BaseRadioButton.tsx b/polling/ui/BaseRadioButton.tsx new file mode 100644 index 0000000..42abcb1 --- /dev/null +++ b/polling/ui/BaseRadioButton.tsx @@ -0,0 +1,74 @@ +import { + TouchableOpacity, + View, + StyleSheet, + Text, + StyleProp, + TextStyle, +} from 'react-native'; +import React from 'react'; +import {hexadecimalTransparency, ThemeConfig} from 'customization-api'; + +interface Props { + option: { + label: string; + value: string; + }; + checked: boolean; + onChange: (option: string) => void; + labelStyle?: StyleProp; + disabled?: boolean; +} +export default function BaseRadioButton(props: Props) { + const {option, checked, onChange, disabled, labelStyle = {}} = props; + return ( + + !disabled && onChange(option.value)}> + + {checked && } + + {option.label} + + + ); +} + +const style = StyleSheet.create({ + optionsContainer: { + flexDirection: 'row', + alignItems: 'center', + marginBottom: 10, + }, + disabledContainer: { + opacity: 0.5, + }, + radioCircle: { + height: 22, + width: 22, + borderRadius: 11, + borderWidth: 2, + borderColor: $config.PRIMARY_ACTION_BRAND_COLOR, + alignItems: 'center', + justifyContent: 'center', + }, + disabledCircle: { + borderColor: $config.FONT_COLOR + hexadecimalTransparency['50%'], + }, + radioFilled: { + height: 12, + width: 12, + borderRadius: 6, + backgroundColor: $config.PRIMARY_ACTION_BRAND_COLOR, + }, + optionText: { + color: $config.FONT_COLOR, + fontSize: ThemeConfig.FontSize.normal, + fontFamily: ThemeConfig.FontFamily.sansPro, + fontWeight: '400', + lineHeight: 24, + marginLeft: 10, + }, +}); From 2f8917d50079a43f56e90cc4458cfc2f4bcbbb4c Mon Sep 17 00:00:00 2001 From: Supriya Adep Date: Wed, 11 Sep 2024 16:11:55 +0530 Subject: [PATCH 2/4] add changes to polling --- polling/components/Poll.tsx | 6 +- polling/components/PollCard.tsx | 11 +- polling/components/PollCardMoreActions.tsx | 27 +- polling/components/PollList.tsx | 2 +- polling/components/PollSidebar.tsx | 14 +- polling/components/PollTimer.tsx | 9 +- polling/components/form/DraftPollFormView.tsx | 4 +- .../form/SelectNewPollTypeFormView.tsx | 12 +- .../components/form/poll-response-forms.tsx | 84 ++-- .../components/modals/PollFormWizardModal.tsx | 18 +- .../modals/PollResponseFormModal.tsx | 3 +- polling/components/modals/PollResultModal.tsx | 2 +- polling/context/poll-context.tsx | 363 +++++++++++++----- polling/context/poll-events.tsx | 75 ++-- polling/helpers.ts | 39 +- 15 files changed, 438 insertions(+), 231 deletions(-) diff --git a/polling/components/Poll.tsx b/polling/components/Poll.tsx index ca7fec0..fe90b7f 100644 --- a/polling/components/Poll.tsx +++ b/polling/components/Poll.tsx @@ -4,6 +4,8 @@ import PollFormWizardModal from './modals/PollFormWizardModal'; import {PollEventsProvider, PollEventsSubscriber} from '../context/poll-events'; import PollResponseFormModal from './modals/PollResponseFormModal'; import PollResultModal from './modals/PollResultModal'; +import {log} from '../helpers'; +// TODO:SUP // const DraftPollModal = React.lazy(() => import('./DraftPollModal')); // const RespondToPollModal = React.lazy(() => import('./RespondToPollModal')); // const SharePollResultModal = React.lazy(() => import('./SharePollResultModal')); @@ -21,7 +23,7 @@ function Poll({children}: {children?: React.ReactNode}) { function PollModals() { const {currentModal, launchPollId, viewResultPollId, polls} = usePoll(); - console.log('supriya polls data chnaged: ', polls); + log('polls data changed: ', polls); return ( <> {currentModal === PollModalState.DRAFT_POLL && } @@ -31,7 +33,7 @@ function PollModals() { {currentModal === PollModalState.VIEW_POLL_RESULTS && viewResultPollId && } - // Loading...}> + // TODO:SUP Loading...}> // {activePollModal === PollAction.DraftPoll && } // {activePollModal === PollAction.RespondToPoll && } // {activePollModal === PollAction.SharePollResult && } diff --git a/polling/components/PollCard.tsx b/polling/components/PollCard.tsx index 3f8f1ac..81ce1c1 100644 --- a/polling/components/PollCard.tsx +++ b/polling/components/PollCard.tsx @@ -4,14 +4,15 @@ import { PollItem, PollItemOptionItem, PollStatus, + PollTaskRequestTypes, usePoll, } from '../context/poll-context'; import {ThemeConfig, TertiaryButton, useLocalUid} from 'customization-api'; import {PollOptionList, PollOptionListItemResult} from './poll-option-item-ui'; import {BaseMoreButton} from '../ui/BaseMoreButton'; -import {PollCardMoreActions, PollTaskRequestTypes} from './PollCardMoreActions'; -import {capitalizeFirstLetter, iVoted} from '../helpers'; -import {PollRenderResponseForm} from './form/poll-response-forms'; +import {PollCardMoreActions} from './PollCardMoreActions'; +import {capitalizeFirstLetter, hasUserVoted} from '../helpers'; +import {PollRenderResponseFormBody} from './form/poll-response-forms'; function PollCard({pollItem, isHost}: {pollItem: PollItem; isHost: boolean}) { const {sendResponseToPoll, handlePollTaskRequest} = usePoll(); @@ -24,7 +25,7 @@ function PollCard({pollItem, isHost}: {pollItem: PollItem; isHost: boolean}) { const resultView = isHost || pollItem.status === PollStatus.FINISHED || - iVoted(pollItem.options, localUid); + hasUserVoted(pollItem.options, localUid); return ( @@ -76,7 +77,7 @@ function PollCard({pollItem, isHost}: {pollItem: PollItem; isHost: boolean}) { ) : pollItem.status === PollStatus.ACTIVE ? ( - { sendResponseToPoll(pollItem, responses); diff --git a/polling/components/PollCardMoreActions.tsx b/polling/components/PollCardMoreActions.tsx index 571be7f..adc22ec 100644 --- a/polling/components/PollCardMoreActions.tsx +++ b/polling/components/PollCardMoreActions.tsx @@ -6,16 +6,7 @@ import { calculatePosition, ThemeConfig, } from 'customization-api'; -import {PollStatus} from '../context/poll-context'; - -export enum PollTaskRequestTypes { - PUBLISH = 'PUBLISH', - EXPORT = 'EXPORT', - FINISH = 'FINISH', - VIEW_DETAILS = 'VIEW_DETAILS', - DELETE = 'DELETE', - SHARE = 'SHARE', -} +import {PollStatus, PollTaskRequestTypes} from '../context/poll-context'; interface PollCardMoreActionsMenuProps { status: PollStatus; @@ -37,6 +28,21 @@ const PollCardMoreActions = (props: PollCardMoreActionsMenuProps) => { const [isPosCalculated, setIsPosCalculated] = React.useState(false); const {width: globalWidth, height: globalHeight} = useWindowDimensions(); + actionMenuitems.push({ + icon: 'send', + iconColor: $config.SECONDARY_ACTION_COLOR, + textColor: $config.FONT_COLOR, + title: 'Send Poll', + titleStyle: { + fontSize: ThemeConfig.FontSize.small, + }, + disabled: status !== PollStatus.LATER, + onPress: () => { + onCardActionSelect(PollTaskRequestTypes.SEND); + setActionMenuVisible(false); + }, + }); + actionMenuitems.push({ icon: 'share', iconColor: $config.SECONDARY_ACTION_COLOR, @@ -120,6 +126,7 @@ const PollCardMoreActions = (props: PollCardMoreActionsMenuProps) => { }, ); } + // eslint-disable-next-line react-hooks/exhaustive-deps }, [actionMenuVisible]); return ( diff --git a/polling/components/PollList.tsx b/polling/components/PollList.tsx index 25f178b..47a1039 100644 --- a/polling/components/PollList.tsx +++ b/polling/components/PollList.tsx @@ -17,7 +17,7 @@ export default function PollList() { {polls && Object.keys(polls).length > 0 ? ( Object.keys(polls).map((key: string) => ( - + )) ) : ( <> diff --git a/polling/components/PollSidebar.tsx b/polling/components/PollSidebar.tsx index 8441719..939bc5f 100644 --- a/polling/components/PollSidebar.tsx +++ b/polling/components/PollSidebar.tsx @@ -1,12 +1,12 @@ /* ******************************************** Copyright © 2021 Agora Lab, Inc., all rights reserved. - AppBuilder and all associated components, source code, APIs, services, and documentation - (the “Materials”) are owned by Agora Lab, Inc. and its licensors. The Materials may not be - accessed, used, modified, or distributed for any purpose without a license from Agora Lab, Inc. - Use without a license or in violation of any license terms and conditions (including use for - any purpose competitive to Agora Lab, Inc.’s business) is strictly prohibited. For more - information visit https://appbuilder.agora.io. + AppBuilder and all associated components, source code, APIs, services, and documentation + (the “Materials”) are owned by Agora Lab, Inc. and its licensors. The Materials may not be + accessed, used, modified, or distributed for any purpose without a license from Agora Lab, Inc. + Use without a license or in violation of any license terms and conditions (including use for + any purpose competitive to Agora Lab, Inc.’s business) is strictly prohibited. For more + information visit https://appbuilder.agora.io. ********************************************* */ import React from 'react'; @@ -21,7 +21,7 @@ const PollSidebar = () => { return ( {/* Header */} - {isHost() ? ( + {isHost ? ( <> diff --git a/polling/components/PollTimer.tsx b/polling/components/PollTimer.tsx index efd60ac..2d1d69b 100644 --- a/polling/components/PollTimer.tsx +++ b/polling/components/PollTimer.tsx @@ -1,4 +1,4 @@ -import React, {useEffect} from 'react'; +import React, {useEffect, useState} from 'react'; import {Text, View, StyleSheet} from 'react-native'; import {useCountdown} from '../hook/useCountdownTimer'; import {ThemeConfig} from 'customization-api'; @@ -12,8 +12,9 @@ const padZero = (value: number) => { return value.toString().padStart(2, '0'); }; -export default function PollTimer({expiresAt, setFreezeForm}: Props) { +export default function PollTimer({expiresAt}: Props) { const [days, hours, minutes, seconds] = useCountdown(expiresAt); + const [freeze, setFreeze] = useState(false); const getTime = () => { if (days) { @@ -32,9 +33,9 @@ export default function PollTimer({expiresAt, setFreezeForm}: Props) { useEffect(() => { if (days + hours + minutes + seconds === 0) { - setFreezeForm(true); + setFreeze(true); } - }, [days, hours, minutes, seconds, setFreezeForm]); + }, [days, hours, minutes, seconds, freeze]); return ( diff --git a/polling/components/form/DraftPollFormView.tsx b/polling/components/form/DraftPollFormView.tsx index 3565060..85351bc 100644 --- a/polling/components/form/DraftPollFormView.tsx +++ b/polling/components/form/DraftPollFormView.tsx @@ -228,7 +228,7 @@ export default function DraftPollFormView({ {/* Sections templete */} - + {/* {form.type === PollKind.MCQ ? ( )} - + */} {/* void; }): JSX.Element { - const [isFormFreezed, setFreezeForm] = useState(false); + return ( + <> + + + + ); +} - const renderSwitch = () => { - switch (pollItem.type) { - case 'OPEN_ENDED': - return ( - - ); - case 'MCQ': - case 'YES_NO': - return ( - - ); - default: - return Unknown type; - } - }; +function PollRenderResponseFormHeader({ + pollItem, +}: { + pollItem: PollItem; +}): JSX.Element { return ( <> - {pollItem.duration ? ( - - ) : null} + {pollItem.duration ? : null} {pollItem.question} - {renderSwitch()} ); } +function PollRenderResponseFormBody({ + pollItem, + onFormComplete, +}: { + pollItem: PollItem; + onFormComplete: (responses: string | string[]) => void; +}): JSX.Element { + // Directly use switch case logic inside the render + switch (pollItem.type) { + case PollKind.OPEN_ENDED: + return ( + + ); + case PollKind.MCQ: + case PollKind.YES_NO: + return ( + + ); + default: + console.error('Unknown poll type:', pollItem.type); + return Unknown poll type; + } +} + function PollResponseQuestionForm({ pollItem, isFormFreezed, @@ -151,7 +168,7 @@ function PollResponseMCQForm({ if (pollItem.multiple_response) { onComplete(selectedOptions); } else { - onComplete(selectedOption); + onComplete([selectedOption]); } }; @@ -202,6 +219,7 @@ export { PollResponseMCQForm, PollResponseFormComplete, PollRenderResponseForm, + PollRenderResponseFormBody, }; export const style = StyleSheet.create({ diff --git a/polling/components/modals/PollFormWizardModal.tsx b/polling/components/modals/PollFormWizardModal.tsx index 5cbcead..88b3467 100644 --- a/polling/components/modals/PollFormWizardModal.tsx +++ b/polling/components/modals/PollFormWizardModal.tsx @@ -11,12 +11,12 @@ import { } from '../../context/poll-context'; import {usePoll} from '../../context/poll-context'; import {initPollForm} from '../form/form-config'; -import {useLocalUid, filterObject} from 'customization-api'; +import {useLocalUid} from 'customization-api'; type FormWizardStep = 'SELECT' | 'DRAFT' | 'PREVIEW'; export default function PollFormWizardModal() { - const {polls, savePoll, sendPoll, closeCurrentModal} = usePoll(); + const {savePoll, sendPoll, closeCurrentModal} = usePoll(); const [step, setStep] = useState('SELECT'); const [type, setType] = useState(null); const [form, setForm] = useState(null); @@ -33,18 +33,6 @@ export default function PollFormWizardModal() { }, [type]); const onSave = (launch?: boolean) => { - if (launch) { - // check if there is an already launched poll - const isAnyPollActive = Object.keys( - filterObject(polls, ([_, v]) => v.status === PollStatus.ACTIVE), - ); - if (isAnyPollActive.length > 0) { - console.error( - 'Cannot publish poll now as there is already one poll active', - ); - return; - } - } const payload = { ...form, status: launch ? PollStatus.ACTIVE : PollStatus.LATER, @@ -52,7 +40,7 @@ export default function PollFormWizardModal() { }; savePoll(payload); if (launch) { - sendPoll(payload); + sendPoll(payload.id); } }; diff --git a/polling/components/modals/PollResponseFormModal.tsx b/polling/components/modals/PollResponseFormModal.tsx index 832c770..d399ec9 100644 --- a/polling/components/modals/PollResponseFormModal.tsx +++ b/polling/components/modals/PollResponseFormModal.tsx @@ -9,9 +9,8 @@ import { PollResponseFormComplete, PollRenderResponseForm, } from '../form/poll-response-forms'; -import {usePoll} from '../../context/poll-context'; +import {usePoll, PollTaskRequestTypes} from '../../context/poll-context'; import PollAvatarHeader from '../PollAvatarHeader'; -import {PollTaskRequestTypes} from '../PollCardMoreActions'; export default function PollResponseFormModal() { const { diff --git a/polling/components/modals/PollResultModal.tsx b/polling/components/modals/PollResultModal.tsx index 716d72b..558055c 100644 --- a/polling/components/modals/PollResultModal.tsx +++ b/polling/components/modals/PollResultModal.tsx @@ -31,7 +31,7 @@ export default function PollResultModal() { ))} diff --git a/polling/context/poll-context.tsx b/polling/context/poll-context.tsx index a27a9a8..aa8ed3d 100644 --- a/polling/context/poll-context.tsx +++ b/polling/context/poll-context.tsx @@ -1,16 +1,18 @@ -import React, {createContext, useReducer, Dispatch, useState} from 'react'; +import React, {createContext, useReducer, useEffect, useState} from 'react'; import {usePollEvents} from './poll-events'; -import {useLocalUid, useLiveStreamDataContext} from 'customization-api'; +import {useLocalUid, useRoomInfo, filterObject, Toast} from 'customization-api'; import { getPollExpiresAtTime, POLL_DURATION, } from '../components/form/form-config'; -import {PollTaskRequestTypes} from '../components/PollCardMoreActions'; import { addVote, arrayToCsv, calculatePercentage, downloadCsv, + hasUserVoted, + log, + mergePolls, } from '../helpers'; enum PollAccess { @@ -35,6 +37,16 @@ enum PollModalState { VIEW_POLL_RESULTS = 'VIEW_POLL_RESULTS', } +enum PollTaskRequestTypes { + SEND = 'SEND', + PUBLISH = 'PUBLISH', + EXPORT = 'EXPORT', + FINISH = 'FINISH', + VIEW_DETAILS = 'VIEW_DETAILS', + DELETE = 'DELETE', + SHARE = 'SHARE', +} + interface PollItemOptionItem { text: string; value: string; @@ -72,33 +84,53 @@ interface PollFormErrors { } enum PollActionKind { - ADD_POLL_ITEM = 'ADD_POLL_ITEM', + SAVE_POLL_ITEM = 'SAVE_POLL_ITEM', + SEND_POLL_ITEM = 'SEND_POLL_ITEM', UPDATE_POLL_ITEM = 'UPDATE_POLL_ITEM', - UPDATE_POLL_ITEM_RESPONSES = 'UPDATE_POLL_ITEM_RESPONSES', + 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', } type PollAction = | { - type: PollActionKind.ADD_POLL_ITEM; + type: PollActionKind.SAVE_POLL_ITEM; payload: {item: PollItem}; } + | { + type: PollActionKind.SEND_POLL_ITEM; + payload: {pollId: string}; + } | { type: PollActionKind.UPDATE_POLL_ITEM; - payload: {item: PollItem}; + payload: {pollId: string; partialItem: Partial}; } | { - type: PollActionKind.UPDATE_POLL_ITEM_RESPONSES; + type: PollActionKind.SUBMIT_POLL_ITEM_RESPONSES; payload: { id: string; - type: PollKind; responses: string | string[]; uid: number; timestamp: number; }; } + | { + type: PollActionKind.RECEIVE_POLL_ITEM_RESPONSES; + payload: { + id: string; + responses: string | string[]; + uid: number; + timestamp: number; + }; + } + | { + type: PollActionKind.PUBLISH_POLL_ITEM; + payload: {pollId: string}; + } | { type: PollActionKind.FINISH_POLL_ITEM; payload: {pollId: string}; @@ -110,29 +142,46 @@ type PollAction = | { type: PollActionKind.DELETE_POLL_ITEM; payload: {pollId: string}; + } + | { + type: PollActionKind.RESET; }; function pollReducer(state: Poll, action: PollAction): Poll { switch (action.type) { - case PollActionKind.ADD_POLL_ITEM: { + case PollActionKind.SAVE_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.UPDATE_POLL_ITEM: { - const pollId = action.payload.item.id; + const pollId = action.payload.pollId; return { ...state, - [pollId]: {...action.payload.item}, + [pollId]: {...state[pollId], ...action.payload.partialItem}, }; } - case PollActionKind.UPDATE_POLL_ITEM_RESPONSES: + case PollActionKind.SUBMIT_POLL_ITEM_RESPONSES: { - const {id: pollId, uid, responses, type, timestamp} = action.payload; + const {id: pollId, uid, responses, timestamp} = action.payload; const poll = state[pollId]; - if (type === PollKind.OPEN_ENDED && typeof responses === 'string') { + if ( + poll.type === PollKind.OPEN_ENDED && + typeof responses === 'string' + ) { return { ...state, [pollId]: { @@ -150,7 +199,7 @@ function pollReducer(state: Poll, action: PollAction): Poll { }, }; } - if (type === PollKind.MCQ && Array.isArray(responses)) { + if (poll.type === PollKind.MCQ && Array.isArray(responses)) { const newCopyOptions = poll.options?.map(item => ({...item})) || []; const withVotesOptions = addVote( responses, @@ -169,6 +218,53 @@ function pollReducer(state: Poll, action: PollAction): Poll { } } break; + case PollActionKind.RECEIVE_POLL_ITEM_RESPONSES: + { + const {id: pollId, uid, 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, + { + uid, + response: responses, + timestamp, + }, + ] + : [{uid, response: responses, timestamp}], + }, + }; + } + if (poll.type === PollKind.MCQ && Array.isArray(responses)) { + const newCopyOptions = poll.options?.map(item => ({...item})) || []; + const withVotesOptions = addVote( + responses, + newCopyOptions, + uid, + timestamp, + ); + const withPercentOptions = calculatePercentage(withVotesOptions); + return { + ...state, + [pollId]: { + ...poll, + options: withPercentOptions, + }, + }; + } + } + break; + case PollActionKind.PUBLISH_POLL_ITEM: + // No action need just return the state + return state; case PollActionKind.FINISH_POLL_ITEM: { const pollId = action.payload.pollId; @@ -201,6 +297,9 @@ function pollReducer(state: Poll, action: PollAction): Poll { } } break; + case PollActionKind.RESET: { + return {}; + } default: { return state; } @@ -210,25 +309,26 @@ function pollReducer(state: Poll, action: PollAction): Poll { interface PollContextValue { polls: Poll; currentModal: PollModalState; - dispatch: Dispatch; startPollForm: () => void; savePoll: (item: PollItem) => void; - sendPoll: (item: PollItem) => void; - onPollReceived: (item: PollItem, launchId: string) => void; + sendPoll: (pollId: string) => void; + onPollReceived: ( + polls: Poll, + pollId: string, + task: PollTaskRequestTypes, + ) => void; sendResponseToPoll: (item: PollItem, responses: string | string[]) => void; onPollResponseReceived: ( - id: string, - type: PollKind, + pollId: string, responses: string | string[], - sender: number, - ts: number, + uid: number, + timestamp: number, ) => void; launchPollId: string; viewResultPollId: string; - sendPollResults: (item: PollItem) => void; - onPollResultsReceived: (item: PollItem) => void; + sendPollResults: (pollId: string) => void; closeCurrentModal: () => void; - isHost: () => boolean; + isHost: boolean; handlePollTaskRequest: (task: PollTaskRequestTypes, pollId: string) => void; } @@ -240,43 +340,134 @@ function PollProvider({children}: {children: React.ReactNode}) { const [currentModal, setCurrentModal] = useState(null); const [launchPollId, setLaunchPollId] = useState(null); const [viewResultPollId, setViewResultPollId] = useState(null); + const [lastAction, setLastAction] = useState(null); + const { + data: {isHost}, + } = useRoomInfo(); + + const enhancedDispatch = (action: PollAction) => { + dispatch(action); + setLastAction(action); + }; const localUid = useLocalUid(); - const {hostUids} = useLiveStreamDataContext(); - const {sendPollEvt, sendResponseToPollEvt, sendPollResultsEvt} = - usePollEvents(); - const isHost = () => { - if (hostUids.includes(localUid)) { - return true; + const {sendPollEvt, sendResponseToPollEvt} = usePollEvents(); + + useEffect(() => { + if (lastAction) { + switch (lastAction.type) { + case PollActionKind.SAVE_POLL_ITEM: + if (lastAction.payload.item.status === PollStatus.LATER) { + setCurrentModal(null); + } + break; + case PollActionKind.SEND_POLL_ITEM: + { + const {pollId} = lastAction.payload; + sendPollEvt(polls, pollId, PollTaskRequestTypes.SEND); + setCurrentModal(null); + } + break; + case PollActionKind.SUBMIT_POLL_ITEM_RESPONSES: + const {id, responses, uid, timestamp} = lastAction.payload; + sendResponseToPollEvt(id, responses, uid, timestamp); + break; + case PollActionKind.PUBLISH_POLL_ITEM: + { + const {pollId} = lastAction.payload; + sendPollEvt(polls, pollId, PollTaskRequestTypes.PUBLISH); + } + break; + case PollActionKind.FINISH_POLL_ITEM: + { + const {pollId} = lastAction.payload; + sendPollEvt(polls, pollId, PollTaskRequestTypes.FINISH); + } + break; + case PollActionKind.DELETE_POLL_ITEM: + { + const {pollId} = lastAction.payload; + sendPollEvt(polls, pollId, PollTaskRequestTypes.DELETE); + } + break; + default: + break; + } } - return false; - }; + }, [lastAction, sendPollEvt, polls, sendResponseToPollEvt]); const startPollForm = () => { setCurrentModal(PollModalState.DRAFT_POLL); }; const savePoll = (item: PollItem) => { - addPollItem(item); - setCurrentModal(null); + enhancedDispatch({ + type: PollActionKind.SAVE_POLL_ITEM, + payload: { + item: {...item}, + }, + }); }; - const sendPoll = (item: PollItem) => { - if (item.status === PollStatus.ACTIVE) { - item.expiresAt = getPollExpiresAtTime(POLL_DURATION); - sendPollEvt(item); - setCurrentModal(null); - } else { - console.error('Poll: Cannot send poll as the status is not active'); + const sendPoll = (pollId: string) => { + // check if there is an already launched poll + const isAnyPollActive = Object.keys( + filterObject(polls, ([_, v]) => v.status === PollStatus.ACTIVE), + ); + if (isAnyPollActive.length > 0) { + Toast.show({ + leadingIconName: 'alert', + type: 'error', + text1: 'Cannot publish poll now as there is already one poll active', + text2: '', + visibilityTime: 1000 * 3, + }); + return; } + enhancedDispatch({ + type: PollActionKind.SEND_POLL_ITEM, + payload: { + pollId, + }, + }); }; - const onPollReceived = (item: PollItem, launchId: string) => { - addPollItem(item); - if (!isHost()) { - setLaunchPollId(launchId); - setCurrentModal(PollModalState.RESPOND_TO_POLL); + const onPollReceived = ( + newPoll: Poll, + pollId: string, + task: PollTaskRequestTypes, + ) => { + log('onPollReceived task', task); + const mergedPolls = mergePolls(newPoll, polls); + if (Object.keys(mergedPolls).length === 0) { + enhancedDispatch({ + type: PollActionKind.RESET, + }); + return; + } + if (isHost) { + log('i am host'); + Object.entries(mergedPolls).forEach(([_, pollItem]) => { + savePoll(pollItem); + }); + } else { + log('i am attendee'); + Object.entries(mergedPolls).forEach(([_, pollItem]) => { + if (pollItem.status === PollStatus.LATER) { + return; + } + savePoll(pollItem); + if (pollItem.status === PollStatus.ACTIVE) { + // If status is active but voted + if (hasUserVoted(pollItem.options, localUid)) { + return; + } + // if status is active but not voted + setLaunchPollId(pollId); + setCurrentModal(PollModalState.RESPOND_TO_POLL); + } + }); } }; @@ -285,17 +476,15 @@ function PollProvider({children}: {children: React.ReactNode}) { (item.type === PollKind.OPEN_ENDED && typeof responses === 'string') || (item.type === PollKind.MCQ && Array.isArray(responses)) ) { - dispatch({ - type: PollActionKind.UPDATE_POLL_ITEM_RESPONSES, + enhancedDispatch({ + type: PollActionKind.SUBMIT_POLL_ITEM_RESPONSES, payload: { id: item.id, - type: item.type, responses, uid: localUid, timestamp: Date.now(), }, }); - sendResponseToPollEvt(item, responses); } else { throw new Error( 'sendResponseToPoll received incorrect type response. Unable to send poll response', @@ -304,48 +493,24 @@ function PollProvider({children}: {children: React.ReactNode}) { }; const onPollResponseReceived = ( - id: string, - type: PollKind, + pollId: string, responses: string | string[], - sender: number, - ts: number, + uid: number, + timestamp: number, ) => { - dispatch({ - type: PollActionKind.UPDATE_POLL_ITEM_RESPONSES, + enhancedDispatch({ + type: PollActionKind.RECEIVE_POLL_ITEM_RESPONSES, payload: { - id, - type, + id: pollId, responses, - uid: sender, - timestamp: ts, + uid, + timestamp, }, }); }; - const sendPollResults = (item: PollItem) => { - sendPollResultsEvt(item); - }; - - const onPollResultsReceived = (item: PollItem) => { - updatePollItem(item); - }; - - const addPollItem = (item: PollItem) => { - dispatch({ - type: PollActionKind.ADD_POLL_ITEM, - payload: { - item: {...item}, - }, - }); - }; - - const updatePollItem = (item: PollItem) => { - dispatch({ - type: PollActionKind.UPDATE_POLL_ITEM, - payload: { - item: {...item}, - }, - }); + const sendPollResults = (pollId: string) => { + sendPollEvt(polls, pollId, PollTaskRequestTypes.SHARE); }; const handlePollTaskRequest = ( @@ -353,9 +518,10 @@ function PollProvider({children}: {children: React.ReactNode}) { pollId: string, ) => { switch (task) { - case PollTaskRequestTypes.PUBLISH: - sendPollResults({...polls[pollId]}); + case PollTaskRequestTypes.SEND: + sendPoll(pollId); break; + case PollTaskRequestTypes.SHARE: // No user case so far break; @@ -363,8 +529,16 @@ function PollProvider({children}: {children: React.ReactNode}) { setViewResultPollId(pollId); setCurrentModal(PollModalState.VIEW_POLL_RESULTS); break; + case PollTaskRequestTypes.PUBLISH: + enhancedDispatch({ + type: PollActionKind.PUBLISH_POLL_ITEM, + payload: { + pollId, + }, + }); + break; case PollTaskRequestTypes.DELETE: - dispatch({ + enhancedDispatch({ type: PollActionKind.DELETE_POLL_ITEM, payload: { pollId, @@ -372,7 +546,7 @@ function PollProvider({children}: {children: React.ReactNode}) { }); break; case PollTaskRequestTypes.FINISH: - dispatch({ + enhancedDispatch({ type: PollActionKind.FINISH_POLL_ITEM, payload: { pollId, @@ -380,7 +554,7 @@ function PollProvider({children}: {children: React.ReactNode}) { }); break; case PollTaskRequestTypes.EXPORT: - dispatch({ + enhancedDispatch({ type: PollActionKind.EXPORT_POLL_ITEM, payload: { pollId, @@ -404,10 +578,9 @@ function PollProvider({children}: {children: React.ReactNode}) { const value = { polls, - dispatch, startPollForm, - savePoll, sendPoll, + savePoll, onPollReceived, onPollResponseReceived, currentModal, @@ -415,7 +588,6 @@ function PollProvider({children}: {children: React.ReactNode}) { viewResultPollId, sendResponseToPoll, sendPollResults, - onPollResultsReceived, handlePollTaskRequest, closeCurrentModal, isHost, @@ -440,6 +612,7 @@ export { PollStatus, PollAccess, PollModalState, + PollTaskRequestTypes, }; -export type {PollItem, PollFormErrors, PollItemOptionItem}; +export type {Poll, PollItem, PollFormErrors, PollItemOptionItem}; diff --git a/polling/context/poll-events.tsx b/polling/context/poll-events.tsx index 95eddaa..f56ae5d 100644 --- a/polling/context/poll-events.tsx +++ b/polling/context/poll-events.tsx @@ -1,11 +1,11 @@ import React, {createContext, useContext, useEffect} from 'react'; -import {PollItem, usePoll} from './poll-context'; +import {Poll, PollTaskRequestTypes, usePoll} from './poll-context'; import {customEvents as events, PersistanceLevel} from 'customization-api'; +import {log} from '../helpers'; enum PollEventNames { polls = 'POLLS', pollResponse = 'POLL_RESPONSE', - pollResults = 'POLL_RESULTS', } enum PollEventActions { sendPoll = 'SEND_POLL', @@ -14,13 +14,19 @@ enum PollEventActions { } type sendResponseToPollEvtFunction = ( - poll: PollItem, + id: string, responses: string | string[], + uid: number, + timestamp: number, ) => void; + interface PollEventsContextValue { - sendPollEvt: (poll: PollItem) => void; + sendPollEvt: ( + polls: Poll, + pollId: string, + task?: PollTaskRequestTypes, + ) => void; sendResponseToPollEvt: sendResponseToPollEvtFunction; - sendPollResultsEvt: (poll: PollItem) => void; } const PollEventsContext = createContext(null); @@ -28,49 +34,44 @@ PollEventsContext.displayName = 'PollEventsContext'; // Event Dispatcher function PollEventsProvider({children}: {children?: React.ReactNode}) { - const sendPollEvt = (poll: PollItem) => { + const sendPollEvt = ( + polls: Poll, + pollId: string, + task: PollTaskRequestTypes, + ) => { events.send( PollEventNames.polls, JSON.stringify({ + state: {...polls}, action: PollEventActions.sendPoll, - item: {...poll}, - activePollId: poll.id, + pollId: pollId, + task, }), PersistanceLevel.Channel, ); }; const sendResponseToPollEvt: sendResponseToPollEvtFunction = ( - item, + id, responses, + uid, + timestamp, ) => { events.send( PollEventNames.pollResponse, JSON.stringify({ - type: item.type, - id: item.id, + id, responses, + uid, + timestamp, }), PersistanceLevel.None, ); }; - const sendPollResultsEvt = (item: PollItem) => { - events.send( - PollEventNames.polls, - JSON.stringify({ - action: PollEventActions.sendPollResults, - item: {...item}, - activePollId: '', - }), - PersistanceLevel.Channel, - ); - }; - const value = { sendPollEvt, sendResponseToPollEvt, - sendPollResultsEvt, }; return ( @@ -93,44 +94,36 @@ const PollEventsSubscriberContext = createContext(null); PollEventsSubscriberContext.displayName = 'PollEventsContext'; function PollEventsSubscriber({children}: {children?: React.ReactNode}) { - const { - savePoll, - onPollReceived, - onPollResponseReceived, - onPollResultsReceived, - } = usePoll(); + const {onPollReceived, onPollResponseReceived} = usePoll(); useEffect(() => { events.on(PollEventNames.polls, args => { // const {payload, sender, ts} = args; const {payload} = args; const data = JSON.parse(payload); - const {action, item, activePollId} = data; - console.log('supriya poll received', data); + const {action, state, pollId, task} = data; + log('poll channel state received', data); switch (action) { case PollEventActions.sendPoll: - onPollReceived(item, activePollId); - break; - case PollEventActions.sendPollResults: - onPollResultsReceived(item); + onPollReceived(state, pollId, task); break; default: break; } }); events.on(PollEventNames.pollResponse, args => { - const {payload, sender, ts} = args; + const {payload} = args; const data = JSON.parse(payload); - console.log('supriya poll response received', data); - const {type, id, responses} = data; - onPollResponseReceived(id, type, responses, sender, ts); + log('poll response received', data); + const {id, responses, uid, timestamp} = data; + onPollResponseReceived(id, responses, uid, timestamp); }); return () => { events.off(PollEventNames.polls); events.off(PollEventNames.pollResponse); }; - }, [onPollReceived, onPollResponseReceived, savePoll, onPollResultsReceived]); + }, [onPollReceived, onPollResponseReceived]); return ( diff --git a/polling/helpers.ts b/polling/helpers.ts index 3300a1d..376db84 100644 --- a/polling/helpers.ts +++ b/polling/helpers.ts @@ -1,4 +1,8 @@ -import {PollAccess, PollItemOptionItem} from './context/poll-context'; +import {Poll, PollAccess, PollItemOptionItem} from './context/poll-context'; + +function log(...args: any[]) { + console.log('[CustomPolling::] ', ...args); +} function addVote( responses: string[], @@ -9,7 +13,8 @@ function addVote( return options.map((option: PollItemOptionItem) => { // Count how many times the value appears in the strings array const exists = responses.includes(option.value); - if (exists) { + const isVoted = option.votes.find(item => item.uid === uid); + if (exists && !isVoted) { // Creating a new object explicitly const newOption: PollItemOptionItem = { ...option, @@ -85,14 +90,34 @@ function capitalizeFirstLetter(string: string): string { return string.charAt(0).toUpperCase() + string.slice(1).toLowerCase(); } -function iVoted(options: PollItemOptionItem[], myUid: number): boolean { - return options.some(optionItem => - optionItem.votes.some(item => item.uid === myUid), - ); +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)); +} + +function mergePolls(newPoll: Poll, oldPoll: Poll) { + // Merge and discard absent properties + + // 1. Start with a copy of the current polls state + const mergedPolls: Poll = {...oldPoll}; + // 2. Add or update polls from newPolls + Object.keys(newPoll).forEach(pollId => { + mergedPolls[pollId] = newPoll[pollId]; // Add or update each poll from newPolls + }); + // 3. Remove polls that are not in newPolls + Object.keys(mergedPolls).forEach(pollId => { + if (!(pollId in newPoll)) { + delete mergedPolls[pollId]; // Delete polls that are no longer present in newPolls + } + }); + + return mergedPolls; } export { - iVoted, + log, + mergePolls, + hasUserVoted, downloadCsv, arrayToCsv, addVote, From b96212ae3788df315dca1a55432b5e6b53f142c5 Mon Sep 17 00:00:00 2001 From: Supriya Adep Date: Thu, 24 Oct 2024 12:20:09 +0530 Subject: [PATCH 3/4] polling changes --- bottombar.tsx | 4 +- polling-ui.tsx | 65 +- polling/components/Poll.tsx | 88 ++- polling/components/PollAvatarHeader.tsx | 6 +- polling/components/PollCard.tsx | 345 ++++++--- polling/components/PollCardMoreActions.tsx | 106 +-- polling/components/PollList.tsx | 114 ++- polling/components/PollSidebar.tsx | 125 ++-- polling/components/PollTimer.tsx | 2 +- polling/components/form/DraftPollFormView.tsx | 471 +++++++----- .../components/form/PreviewPollFormView.tsx | 195 +++-- .../form/SelectNewPollTypeFormView.tsx | 140 ++-- polling/components/form/form-config.ts | 46 +- .../components/form/poll-response-forms.tsx | 505 +++++++------ .../components/modals/PollEndConfirmModal.tsx | 106 +++ .../components/modals/PollFormWizardModal.tsx | 162 +++-- .../components/modals/PollItemNotFound.tsx | 28 + .../modals/PollResponseFormModal.tsx | 170 ++++- polling/components/modals/PollResultModal.tsx | 364 ++++++++-- polling/components/poll-option-item-ui.tsx | 160 +++-- polling/context/poll-context.tsx | 671 ++++++++++++------ polling/context/poll-events.tsx | 223 ++++-- polling/helpers.ts | 129 +++- polling/hook/useButtonState.tsx | 68 ++ polling/hook/usePollForm.tsx | 162 +++++ polling/hook/usePollPermissions.tsx | 73 ++ polling/poll-icons.ts | 31 + polling/ui/BaseAccordian.tsx | 157 ++++ polling/ui/BaseButtonWithToggle.tsx | 104 +++ polling/ui/BaseModal.tsx | 105 ++- polling/ui/BaseRadioButton.tsx | 88 ++- 31 files changed, 3635 insertions(+), 1378 deletions(-) create mode 100644 polling/components/modals/PollEndConfirmModal.tsx create mode 100644 polling/components/modals/PollItemNotFound.tsx create mode 100644 polling/hook/useButtonState.tsx create mode 100644 polling/hook/usePollForm.tsx create mode 100644 polling/hook/usePollPermissions.tsx create mode 100644 polling/poll-icons.ts create mode 100644 polling/ui/BaseAccordian.tsx create mode 100644 polling/ui/BaseButtonWithToggle.tsx diff --git a/bottombar.tsx b/bottombar.tsx index fe3eb81..423d7be 100644 --- a/bottombar.tsx +++ b/bottombar.tsx @@ -4,7 +4,7 @@ import { useSidePanel, } from "customization-api"; import React from "react"; -import { CustomMoreItem, POLL_SIDEBAR_NAME } from "./polling-ui"; +import { PollButtonWithSidePanel, POLL_SIDEBAR_NAME } from "./polling-ui"; const Bottombar = () => { const { setSidePanel } = useSidePanel(); @@ -72,7 +72,7 @@ const Bottombar = () => { hide: (w) => { return w > 767 ? true : false; }, - component: CustomMoreItem, + component: PollButtonWithSidePanel, onPress: () => { setSidePanel(POLL_SIDEBAR_NAME); }, diff --git a/polling-ui.tsx b/polling-ui.tsx index 2673b4c..50ddd2f 100644 --- a/polling-ui.tsx +++ b/polling-ui.tsx @@ -6,19 +6,54 @@ import { ImageIcon, ThemeConfig, $config, + useActionSheet, + IconButton, + IconButtonProps, } from "customization-api"; import { View, Text, StyleSheet } from "react-native"; +import pollIcons from "./polling/poll-icons"; const POLL_SIDEBAR_NAME = "side-panel-poll"; -const CustomMoreItem = () => { +const PollButtonWithSidePanel = () => { + const { isOnActionSheet } = useActionSheet(); + const { setSidePanel } = useSidePanel(); + + // On smaller screens + if (isOnActionSheet) { + const iconButtonProps: IconButtonProps = { + onPress: () => { + setSidePanel(POLL_SIDEBAR_NAME); + }, + iconProps: { + icon: pollIcons["bar-chart"], + tintColor: $config.SECONDARY_ACTION_COLOR, + }, + btnTextProps: { + text: "Polls", + textColor: $config.FONT_COLOR, + numberOfLines: 1, + textStyle: { + marginTop: 8, + }, + }, + isOnActionSheet: isOnActionSheet, + }; + + return ( + + + + ); + } + // On bigger screens return ( @@ -27,29 +62,7 @@ const CustomMoreItem = () => { ); }; -// const CustomBottomToolbar = () => { -// const {setSidePanel} = useSidePanel(); - -// return ( -// { -// setSidePanel(POLL_SIDEBAR_NAME); -// }, -// }, -// }, -// }, -// }} -// /> -// ); -// }; - -export { CustomMoreItem, POLL_SIDEBAR_NAME }; +export { PollButtonWithSidePanel, POLL_SIDEBAR_NAME }; const style = StyleSheet.create({ toolbarItem: { diff --git a/polling/components/Poll.tsx b/polling/components/Poll.tsx index fe90b7f..b5133ee 100644 --- a/polling/components/Poll.tsx +++ b/polling/components/Poll.tsx @@ -1,43 +1,83 @@ import React from 'react'; -import {PollModalState, PollProvider, usePoll} from '../context/poll-context'; +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'; -// TODO:SUP -// const DraftPollModal = React.lazy(() => import('./DraftPollModal')); -// const RespondToPollModal = React.lazy(() => import('./RespondToPollModal')); -// const SharePollResultModal = React.lazy(() => import('./SharePollResultModal')); function Poll({children}: {children?: React.ReactNode}) { return ( - {children} - + + {children} + + ); } function PollModals() { - const {currentModal, launchPollId, viewResultPollId, polls} = usePoll(); - log('polls data changed: ', polls); - return ( - <> - {currentModal === PollModalState.DRAFT_POLL && } - {currentModal === PollModalState.RESPOND_TO_POLL && launchPollId && ( - - )} - {currentModal === PollModalState.VIEW_POLL_RESULTS && - viewResultPollId && } - - // TODO:SUP Loading...}> - // {activePollModal === PollAction.DraftPoll && } - // {activePollModal === PollAction.RespondToPoll && } - // {activePollModal === PollAction.SharePollResult && } - // - ); + 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 = () => { + 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 <>; + } + }; + + return <>{renderModal()}; } + export default Poll; diff --git a/polling/components/PollAvatarHeader.tsx b/polling/components/PollAvatarHeader.tsx index 3f036f8..d584f05 100644 --- a/polling/components/PollAvatarHeader.tsx +++ b/polling/components/PollAvatarHeader.tsx @@ -8,6 +8,7 @@ import { useString, videoRoomUserFallbackText, UidType, + $config, } from 'customization-api'; interface Props { @@ -17,8 +18,9 @@ interface Props { function PollAvatarHeader({pollItem}: Props) { const remoteUserDefaultLabel = useString(videoRoomUserFallbackText)(); const {defaultContent} = useContent(); - const getPollCreaterName = (uid: UidType) => { - return defaultContent[uid]?.name || remoteUserDefaultLabel; + + const getPollCreaterName = ({uid, name}: {uid: UidType; name: string}) => { + return defaultContent[uid]?.name || name || remoteUserDefaultLabel; }; return ( diff --git a/polling/components/PollCard.tsx b/polling/components/PollCard.tsx index 81ce1c1..51fb2f2 100644 --- a/polling/components/PollCard.tsx +++ b/polling/components/PollCard.tsx @@ -1,115 +1,221 @@ import React from 'react'; -import {Text, View, StyleSheet} from 'react-native'; +import {Text, View, StyleSheet, TouchableOpacity} from 'react-native'; import { PollItem, - PollItemOptionItem, PollStatus, PollTaskRequestTypes, usePoll, } from '../context/poll-context'; -import {ThemeConfig, TertiaryButton, useLocalUid} from 'customization-api'; -import {PollOptionList, PollOptionListItemResult} from './poll-option-item-ui'; +import { + ThemeConfig, + TertiaryButton, + useLocalUid, + $config, + LinkButton, + ImageIcon, +} from 'customization-api'; import {BaseMoreButton} from '../ui/BaseMoreButton'; import {PollCardMoreActions} from './PollCardMoreActions'; -import {capitalizeFirstLetter, hasUserVoted} from '../helpers'; -import {PollRenderResponseFormBody} from './form/poll-response-forms'; - -function PollCard({pollItem, isHost}: {pollItem: PollItem; isHost: boolean}) { - const {sendResponseToPoll, handlePollTaskRequest} = usePoll(); - const localUid = useLocalUid(); +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 resultView = - isHost || - pollItem.status === PollStatus.FINISHED || - hasUserVoted(pollItem.options, localUid); + const {editPollForm, handlePollTaskRequest} = usePoll(); + const {canEdit} = usePollPermissions({pollItem}); return ( - - - - - {capitalizeFirstLetter(pollItem.status)} - - - {isHost ? ( - <> - - { - handlePollTaskRequest(action, pollItem.id); - }} - /> - - ) : ( - <> - )} - - - - - - {pollItem.question} - - - - {resultView ? ( - - {pollItem.options.map( - (item: PollItemOptionItem, index: number) => ( - - ), - )} - - ) : pollItem.status === PollStatus.ACTIVE ? ( - - { - sendResponseToPoll(pollItem, responses); - }} + + + + {getPollTypeDesc(pollItem.type, pollItem.multiple_response)} + + {pollItem.status === PollStatus.LATER && ( + <> + + Draft + + )} + + {canEdit && ( + + {pollItem.status === PollStatus.LATER && ( + { + editPollForm(pollItem.id); + }}> + + + Edit - ) : ( - Form not published yet. Incorrect state - )} - + + )} + + { + handlePollTaskRequest(action, pollItem.id); + setActionMenuVisible(false); + }} + /> - - - {resultView ? ( - - handlePollTaskRequest( - PollTaskRequestTypes.VIEW_DETAILS, - pollItem.id, - ) - } + )} + + ); +}; + +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', @@ -118,22 +224,24 @@ const style = StyleSheet.create({ marginVertical: 12, }, pollCard: { - padding: 12, display: 'flex', flexDirection: 'column', - gap: 12, - alignSelf: 'stretch', - backgroundColor: $config.CARD_LAYER_3_COLOR, - borderRadius: 15, + 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: '#04D000', + color: $config.FONT_COLOR + ThemeConfig.EmphasisPlus.low, fontSize: ThemeConfig.FontSize.tiny, fontFamily: ThemeConfig.FontFamily.sansPro, fontWeight: '600', @@ -147,20 +255,55 @@ const style = StyleSheet.create({ alignItems: 'flex-start', }, pollCardContentQuestionText: { - color: $config.FONT_COLOR, + 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: { + height: 36, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + }, + linkText: { + textAlign: 'center', + color: $config.PRIMARY_ACTION_BRAND_COLOR, fontSize: ThemeConfig.FontSize.small, fontFamily: ThemeConfig.FontFamily.sansPro, fontWeight: '600', lineHeight: 16, }, - pollCardFooter: {}, - pollCardFooterActions: { - alignSelf: 'flex-start', + space: { + marginHorizontal: 8, }, - pollResponseFormView: { + row: { display: 'flex', - gap: 20, + 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', }, }); - -export {PollCard}; diff --git a/polling/components/PollCardMoreActions.tsx b/polling/components/PollCardMoreActions.tsx index adc22ec..ec244eb 100644 --- a/polling/components/PollCardMoreActions.tsx +++ b/polling/components/PollCardMoreActions.tsx @@ -5,6 +5,7 @@ import { ActionMenuItem, calculatePosition, ThemeConfig, + $config, } from 'customization-api'; import {PollStatus, PollTaskRequestTypes} from '../context/poll-context'; @@ -23,71 +24,73 @@ const PollCardMoreActions = (props: PollCardMoreActionsMenuProps) => { onCardActionSelect, status, } = props; - const actionMenuitems: ActionMenuItem[] = []; + const actionMenuItems: ActionMenuItem[] = []; const [modalPosition, setModalPosition] = React.useState({}); const [isPosCalculated, setIsPosCalculated] = React.useState(false); const {width: globalWidth, height: globalHeight} = useWindowDimensions(); - actionMenuitems.push({ - icon: 'send', - iconColor: $config.SECONDARY_ACTION_COLOR, - textColor: $config.FONT_COLOR, - title: 'Send Poll', - titleStyle: { - fontSize: ThemeConfig.FontSize.small, - }, - disabled: status !== PollStatus.LATER, - onPress: () => { - onCardActionSelect(PollTaskRequestTypes.SEND); - setActionMenuVisible(false); - }, - }); + 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); + }, + }); - actionMenuitems.push({ - icon: 'share', - iconColor: $config.SECONDARY_ACTION_COLOR, - textColor: $config.FONT_COLOR, - title: 'Publish Result', - titleStyle: { - fontSize: ThemeConfig.FontSize.small, - }, - disabled: status === PollStatus.LATER, - onPress: () => { - onCardActionSelect(PollTaskRequestTypes.PUBLISH); - 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); + // }, + // }); - actionMenuitems.push({ - icon: 'download', - iconColor: $config.SECONDARY_ACTION_COLOR, - textColor: $config.FONT_COLOR, - title: 'Export Resuts', - titleStyle: { - fontSize: ThemeConfig.FontSize.small, - }, - onPress: () => { - onCardActionSelect(PollTaskRequestTypes.EXPORT); - setActionMenuVisible(false); - }, - }); + status !== PollStatus.LATER && + actionMenuItems.push({ + icon: 'download', + iconColor: $config.SECONDARY_ACTION_COLOR, + textColor: $config.FONT_COLOR, + title: 'Export Resuts', + titleStyle: { + fontSize: ThemeConfig.FontSize.small, + }, + onPress: () => { + onCardActionSelect(PollTaskRequestTypes.EXPORT); + setActionMenuVisible(false); + }, + }); - actionMenuitems.push({ + actionMenuItems.push({ icon: 'close', iconColor: $config.SECONDARY_ACTION_COLOR, textColor: $config.FONT_COLOR, - title: 'Finish Poll', + title: 'End Poll', titleStyle: { fontSize: ThemeConfig.FontSize.small, }, - disabled: status === PollStatus.LATER || status === PollStatus.FINISHED, + disabled: status !== PollStatus.ACTIVE, onPress: () => { - onCardActionSelect(PollTaskRequestTypes.FINISH); + onCardActionSelect(PollTaskRequestTypes.FINISH_CONFIRMATION); setActionMenuVisible(false); }, }); - actionMenuitems.push({ + actionMenuItems.push({ icon: 'delete', iconColor: $config.SEMANTIC_ERROR, textColor: $config.SEMANTIC_ERROR, @@ -96,13 +99,13 @@ const PollCardMoreActions = (props: PollCardMoreActionsMenuProps) => { fontSize: ThemeConfig.FontSize.small, }, onPress: () => { - onCardActionSelect(PollTaskRequestTypes.DELETE); + onCardActionSelect(PollTaskRequestTypes.DELETE_CONFIRMATION); setActionMenuVisible(false); }, }); React.useEffect(() => { - if (actionMenuVisible) { + if (actionMenuVisible && moreBtnRef.current) { //getting btnRef x,y moreBtnRef?.current?.measure( ( @@ -126,8 +129,7 @@ const PollCardMoreActions = (props: PollCardMoreActionsMenuProps) => { }, ); } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [actionMenuVisible]); + }, [actionMenuVisible, globalWidth, globalHeight, moreBtnRef]); return ( <> @@ -136,7 +138,7 @@ const PollCardMoreActions = (props: PollCardMoreActionsMenuProps) => { actionMenuVisible={actionMenuVisible && isPosCalculated} setActionMenuVisible={setActionMenuVisible} modalPosition={modalPosition} - items={actionMenuitems} + items={actionMenuItems} /> ); diff --git a/polling/components/PollList.tsx b/polling/components/PollList.tsx index 47a1039..5016f96 100644 --- a/polling/components/PollList.tsx +++ b/polling/components/PollList.tsx @@ -1,38 +1,94 @@ -import React from 'react'; -import {View, Text, StyleSheet} from 'react-native'; +import React, {useState, useEffect} from 'react'; +import {View} from 'react-native'; import {PollCard} from './PollCard'; -import {usePoll} from '../context/poll-context'; -import {ThemeConfig} from 'customization-api'; +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, isHost} = usePoll(); + 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 ( - - - Past Polls ({Object.keys(polls).length}) - - - - {polls && Object.keys(polls).length > 0 ? ( - Object.keys(polls).map((key: string) => ( - - )) - ) : ( - <> - )} - + {renderPollList(activePolls, 'Active', 'active-accordion')} + {renderPollList(draftPolls, 'Saved as Draft', 'draft-accordion')} + {renderPollList(finishedPolls, 'Completed', 'finished-accordion')} ); } - -const style = StyleSheet.create({ - titleText: { - color: $config.FONT_COLOR, - fontSize: ThemeConfig.FontSize.tiny, - fontFamily: ThemeConfig.FontFamily.sansPro, - fontWeight: '600', - lineHeight: 12, - }, -}); diff --git a/polling/components/PollSidebar.tsx b/polling/components/PollSidebar.tsx index 939bc5f..a55beed 100644 --- a/polling/components/PollSidebar.tsx +++ b/polling/components/PollSidebar.tsx @@ -1,74 +1,107 @@ -/* -******************************************** - Copyright © 2021 Agora Lab, Inc., all rights reserved. - AppBuilder and all associated components, source code, APIs, services, and documentation - (the “Materials”) are owned by Agora Lab, Inc. and its licensors. The Materials may not be - accessed, used, modified, or distributed for any purpose without a license from Agora Lab, Inc. - Use without a license or in violation of any license terms and conditions (including use for - any purpose competitive to Agora Lab, Inc.’s business) is strictly prohibited. For more - information visit https://appbuilder.agora.io. -********************************************* -*/ import React from 'react'; -import {Text, View, StyleSheet} from 'react-native'; -import {PrimaryButton, ThemeConfig} from 'customization-api'; +import {View, StyleSheet, ScrollView, Text} from 'react-native'; +import { + PrimaryButton, + ThemeConfig, + $config, + ImageIcon, +} from 'customization-api'; import {usePoll} from '../context/poll-context'; import PollList from './PollList'; +import pollIcons from '../poll-icons'; +import {isWebOnly} from '../helpers'; +import {usePollPermissions} from '../hook/usePollPermissions'; const PollSidebar = () => { - const {startPollForm, isHost} = usePoll(); + const {startPollForm, isHost, polls} = usePoll(); + const {canCreate} = usePollPermissions({}); return ( - {/* Header */} - {isHost ? ( - <> - - - - Create a new poll and boost interaction with your audience - members now! - - - startPollForm()} - text="Create Poll" + {Object.keys(polls).length === 0 ? ( + + + {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: { - backgroundColor: $config.CARD_LAYER_1_COLOR, + display: 'flex', + flex: 1, + }, + pollFooter: { + padding: 12, + backgroundColor: $config.CARD_LAYER_3_COLOR, }, - headerSection: { + emptyCard: { + maxWidth: 220, display: 'flex', - flexDirection: 'column', alignItems: 'center', justifyContent: 'center', + gap: 12, }, - headerCard: { + emptyCardIcon: { + width: 72, + height: 72, + borderRadius: 12, display: 'flex', - flexDirection: 'column', + alignItems: 'center', justifyContent: 'center', - alignItems: 'flex-start', - gap: 16, - padding: 20, backgroundColor: $config.CARD_LAYER_3_COLOR, - borderRadius: 15, }, + 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, @@ -84,18 +117,12 @@ const style = StyleSheet.create({ paddingHorizontal: 8, }, btnText: { - color: $config.FONT_COLOR, + color: $config.PRIMARY_ACTION_TEXT_COLOR, fontSize: ThemeConfig.FontSize.small, fontFamily: ThemeConfig.FontFamily.sansPro, fontWeight: '600', textTransform: 'capitalize', }, - separator: { - marginVertical: 24, - height: 1, - display: 'flex', - backgroundColor: $config.CARD_LAYER_3_COLOR, - }, }); export default PollSidebar; diff --git a/polling/components/PollTimer.tsx b/polling/components/PollTimer.tsx index 2d1d69b..1475954 100644 --- a/polling/components/PollTimer.tsx +++ b/polling/components/PollTimer.tsx @@ -1,7 +1,7 @@ import React, {useEffect, useState} from 'react'; import {Text, View, StyleSheet} from 'react-native'; import {useCountdown} from '../hook/useCountdownTimer'; -import {ThemeConfig} from 'customization-api'; +import {ThemeConfig, $config} from 'customization-api'; interface Props { expiresAt: number; diff --git a/polling/components/form/DraftPollFormView.tsx b/polling/components/form/DraftPollFormView.tsx index 85351bc..a388d8b 100644 --- a/polling/components/form/DraftPollFormView.tsx +++ b/polling/components/form/DraftPollFormView.tsx @@ -1,4 +1,10 @@ -import {Text, View, StyleSheet, TextInput} from 'react-native'; +import { + Text, + View, + StyleSheet, + TextInput, + TouchableOpacity, +} from 'react-native'; import React from 'react'; import { BaseModalTitle, @@ -7,14 +13,17 @@ import { BaseModalCloseIcon, } from '../../ui/BaseModal'; import { - LinkButton, - Checkbox, IconButton, PrimaryButton, ThemeConfig, + $config, + TertiaryButton, + ImageIcon, + PlatformWrapper, } from 'customization-api'; import {PollFormErrors, PollItem, PollKind} from '../../context/poll-context'; import {nanoid} from 'nanoid'; +import BaseButtonWithToggle from '../../ui/BaseButtonWithToggle'; function FormTitle({title}: {title: string}) { return ( @@ -25,18 +34,57 @@ function FormTitle({title}: {title: string}) { } interface Props { form: PollItem; - setForm: React.Dispatch>; + setForm: React.Dispatch>; onPreview: () => void; + onSave: (launch?: boolean) => void; errors: Partial; onClose: () => void; } +// Define the form action types and reducer for state management +const formReducer = ( + state: PollItem, + action: {type: string; payload?: any}, +) => { + switch (action.type) { + case 'UPDATE_FIELD': + return {...state, [action.payload.field]: action.payload.value}; + case 'UPDATE_OPTION': + return { + ...state, + options: state.options?.map((option, index) => + index === action.payload.index + ? {...option, ...action.payload.option} + : option, + ), + }; + case 'ADD_OPTION': + return { + ...state, + options: [ + ...(state.options || []), + {text: '', value: '', votes: [], percent: '0'}, + ], + }; + case 'DELETE_OPTION': + return { + ...state, + options: + state.options?.filter((_, index) => index !== action.payload.index) || + [], + }; + default: + return state; + } +}; + export default function DraftPollFormView({ form, setForm, onPreview, errors, onClose, + onSave, }: Props) { const handleInputChange = (field: string, value: string | boolean) => { setForm({ @@ -45,7 +93,25 @@ export default function DraftPollFormView({ }); }; - const handleCheckboxChange = (field: keyof typeof form, value: boolean) => { + 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, @@ -61,7 +127,7 @@ export default function DraftPollFormView({ setForm({ ...form, options: [ - ...form.options, + ...(form.options || []), { text: '', value: '', @@ -72,11 +138,11 @@ export default function DraftPollFormView({ }); } if (action === 'update') { - setForm({ - ...form, - options: form.options.map((option, i) => { + setForm(prevForm => ({ + ...prevForm, + options: prevForm.options?.map((option, i) => { if (i === index) { - const text = value.trim(); + const text = value; const lowerText = text .replace(/\s+/g, '-') .toLowerCase() @@ -86,54 +152,45 @@ export default function DraftPollFormView({ ...option, text: text, value: lowerText, - votes: [], }; } return option; }), - }); + })); } if (action === 'delete') { setForm({ ...form, - options: form.options.filter((option, i) => i !== index), + options: form.options?.filter((option, i) => i !== index) || [], }); } }; - const getTitle = (type: PollKind) => { - if (type === PollKind.MCQ) { - return 'Multiple Choice'; - } - if (type === PollKind.OPEN_ENDED) { - return 'Open Ended Poll'; - } - if (type === PollKind.YES_NO) { - return 'Yes/No'; - } - }; - return ( <> - + - {/* Question section */} - + + {/* Question section */} { handleInputChange('question', text); }} - placeholder="Enter poll question here..." + placeholder="Enter your question here..." placeholderTextColor={ $config.FONT_COLOR + ThemeConfig.EmphasisPlus.low } @@ -143,142 +200,168 @@ export default function DraftPollFormView({ )} - {/* Options section */} - {form.type === PollKind.MCQ || form.type === PollKind.YES_NO ? ( + {/* MCQ section */} + {form.type === PollKind.MCQ ? ( - + + + + + { + handleCheckboxChange('multiple_response', value); + }} + /> + + + - {form.type === PollKind.MCQ ? ( - <> - {form.options.map((option, index) => ( - - {index + 1} - { - updateFormOption('update', text, index); + {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); }} - placeholder="Add text here..." - placeholderTextColor={ - $config.FONT_COLOR + ThemeConfig.EmphasisPlus.low - } /> - {index > 1 ? ( - - { - updateFormOption('delete', option.text, index); - }} - /> - - ) : ( - <> - )} - ))} - - + )} + + ))} + {form.options?.length < 5 ? ( + + {(isHovered: boolean) => ( + { - updateFormOption('add', null, null); - }} - /> - - {errors?.options && ( - - {errors.options.message} - + updateFormOption('add', '', -1); + }}> + + + Add option + + )} - + ) : ( <> )} - {form.type === PollKind.YES_NO ? ( - <> - - Yes - - - No - - - ) : ( - <> + {errors?.options && ( + {errors.options.message} )} ) : ( <> )} - {/* Sections templete */} - - - {/* - {form.type === PollKind.MCQ ? ( - { - handleCheckboxChange( - 'multiple_response', - !form.multiple_response, - ); - }} - /> - ) : ( - <> - )} - */} - {/* - { - handleCheckboxChange('share', !form.share); - }} - /> - */} - {/* - { - handleCheckboxChange('duration', !form.duration); - }} - /> - */} + {/* Yes / No section */} + {form.type === PollKind.YES_NO ? ( + + + + + + + + Yes + + + + + + No + + - + ) : ( + <> + )} - { - onPreview(); - }} - text="Preview" - /> + + { + try { + onSave(false); + } catch (error) { + console.error('Error saving form:', error); + } + }} + /> + + + { + try { + onPreview(); + } catch (error) { + console.error('Error previewing form:', error); + } + }} + /> + @@ -286,19 +369,22 @@ export default function DraftPollFormView({ } export const style = StyleSheet.create({ - createPollBox: { + pForm: { display: 'flex', flexDirection: 'column', - gap: 20, + gap: 24, }, pFormSection: { - gap: 12, + gap: 8, }, - pFormAddOptionLinkSection: { - marginTop: -8, - paddingVertical: 8, - paddingHorizontal: 16, - alignItems: 'flex-start', + 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, @@ -307,9 +393,14 @@ export const style = StyleSheet.create({ lineHeight: 16, fontWeight: '600', }, + pFormTitleRow: { + display: 'flex', + flexDirection: 'row', + alignItems: 'center', + }, pFormTextarea: { color: $config.FONT_COLOR + ThemeConfig.EmphasisPlus.high, - fontSize: ThemeConfig.FontSize.small, + fontSize: ThemeConfig.FontSize.normal, fontFamily: ThemeConfig.FontFamily.sansPro, lineHeight: 16, fontWeight: '400', @@ -317,15 +408,15 @@ export const style = StyleSheet.create({ borderWidth: 1, borderColor: $config.INPUT_FIELD_BORDER_COLOR, backgroundColor: $config.INPUT_FIELD_BACKGROUND_COLOR, - height: 110, + height: 60, outlineStyle: 'none', padding: 20, }, pFormOptionText: { color: $config.FONT_COLOR + ThemeConfig.EmphasisPlus.high, - fontSize: ThemeConfig.FontSize.small, + fontSize: ThemeConfig.FontSize.normal, fontFamily: ThemeConfig.FontFamily.sansPro, - lineHeight: 16, + lineHeight: 24, fontWeight: '400', }, pFormOptionPrefix: { @@ -333,49 +424,68 @@ export const style = StyleSheet.create({ paddingRight: 4, }, pFormOptionLink: { - fontWeight: '400', - lineHeight: 24, + color: $config.PRIMARY_ACTION_BRAND_COLOR, + height: 48, + paddingVertical: 12, }, pFormOptions: { - paddingVertical: 8, gap: 8, }, pFormInput: { flex: 1, color: $config.FONT_COLOR + ThemeConfig.EmphasisPlus.high, - fontSize: ThemeConfig.FontSize.small, + fontSize: ThemeConfig.FontSize.normal, fontFamily: ThemeConfig.FontFamily.sansPro, - lineHeight: 16, + lineHeight: 24, fontWeight: '400', outlineStyle: 'none', + borderColor: $config.INPUT_FIELD_BORDER_COLOR, backgroundColor: $config.INPUT_FIELD_BACKGROUND_COLOR, - borderRadius: 9, + 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: 16, + paddingHorizontal: 12, flexDirection: 'row', justifyContent: 'flex-start', alignItems: 'center', - alignSelf: 'stretch', - gap: 8, + gap: 4, backgroundColor: $config.INPUT_FIELD_BACKGROUND_COLOR, - borderRadius: 9, + 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: { - paddingHorizontal: 16, - paddingVertical: 8, - }, + pFormCheckboxContainer: {}, previewActions: { flex: 1, display: 'flex', flexDirection: 'row', alignItems: 'center', justifyContent: 'flex-end', + gap: 16, }, btnContainer: { minWidth: 150, @@ -383,17 +493,28 @@ export const style = StyleSheet.create({ borderRadius: 4, }, btnText: { - color: $config.FONT_COLOR, + 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, - paddingLeft: 5, - paddingTop: 5, + lineHeight: 12, + fontWeight: '400', + paddingTop: 8, + paddingLeft: 8, + }, + pushRight: { + marginLeft: 'auto', }, }); diff --git a/polling/components/form/PreviewPollFormView.tsx b/polling/components/form/PreviewPollFormView.tsx index cc049a9..0aa7b30 100644 --- a/polling/components/form/PreviewPollFormView.tsx +++ b/polling/components/form/PreviewPollFormView.tsx @@ -1,4 +1,4 @@ -import {Text, StyleSheet, View} from 'react-native'; +import {Text, StyleSheet, View, TouchableOpacity} from 'react-native'; import React from 'react'; import { BaseModalTitle, @@ -6,10 +6,14 @@ import { BaseModalActions, BaseModalCloseIcon, } from '../../ui/BaseModal'; -import {PollItem} from '../../context/poll-context'; -import {POLL_DURATION} from './form-config'; -import BaseRadioButton from '../../ui/BaseRadioButton'; -import {TertiaryButton, Checkbox, ThemeConfig} from 'customization-api'; +import {PollItem, PollKind} from '../../context/poll-context'; +import { + PrimaryButton, + TertiaryButton, + ThemeConfig, + $config, + ImageIcon, +} from 'customization-api'; interface Props { form: PollItem; @@ -26,73 +30,84 @@ export default function PreviewPollFormView({ }: Props) { return ( <> - + - + - {form.duration && ( - {POLL_DURATION} - )} - {form.question} - {form?.options ? ( - - {form.multiple_response - ? form.options.map((option, index) => ( - - {}} - /> - - )) - : form.options.map((option, index) => ( + + + + 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} ))} + + ) : ( + <> + )} - ) : ( - <> - )} + - - { - onEdit(); - }} - text="Edit" - /> - - + { - onSave(false); + try { + onSave(false); + } catch (error) { + console.error('Error saving form:', error); + } }} /> - - + { - onSave(true); + try { + onSave(true); + } catch (error) { + console.error('Error launching form:', error); + } }} /> @@ -104,14 +119,48 @@ export default function PreviewPollFormView({ export const style = StyleSheet.create({ previewContainer: { - width: 550, + // 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, }, - previewTimer: { - color: $config.SEMANTIC_WARNING, + editText: { + color: $config.PRIMARY_ACTION_BRAND_COLOR, + fontSize: ThemeConfig.FontSize.normal, fontFamily: ThemeConfig.FontFamily.sansPro, - fontSize: 16, lineHeight: 20, - paddingBottom: 12, + 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, @@ -119,35 +168,45 @@ export const style = StyleSheet.create({ fontFamily: ThemeConfig.FontFamily.sansPro, lineHeight: 24, fontWeight: '600', - paddingBottom: 20, + fontStyle: 'italic', }, previewOptionSection: { - backgroundColor: $config.INPUT_FIELD_BACKGROUND_COLOR, - borderRadius: 9, - paddingVertical: 8, display: 'flex', flexDirection: 'column', - gap: 4, + gap: 8, }, previewOptionCard: { display: 'flex', paddingHorizontal: 16, - paddingVertical: 8, + paddingVertical: 10, + borderRadius: 6, + backgroundColor: $config.CARD_LAYER_4_COLOR, }, previewOptionText: { - color: $config.FONT_COLOR, - fontSize: ThemeConfig.FontSize.normal, + color: $config.SECONDARY_ACTION_COLOR, + fontSize: ThemeConfig.FontSize.small, fontFamily: ThemeConfig.FontFamily.sansPro, fontWeight: '400', - lineHeight: 24, + lineHeight: 20, + fontStyle: 'italic', }, previewActions: { flex: 1, display: 'flex', flexDirection: 'row', + justifyContent: 'flex-end', gap: 16, }, btnContainer: { - flex: 1, + minWidth: 150, + height: 36, + borderRadius: 4, + }, + btnText: { + color: $config.PRIMARY_ACTION_TEXT_COLOR, + fontSize: ThemeConfig.FontSize.small, + fontFamily: ThemeConfig.FontFamily.sansPro, + fontWeight: '600', + textTransform: 'capitalize', }, }); diff --git a/polling/components/form/SelectNewPollTypeFormView.tsx b/polling/components/form/SelectNewPollTypeFormView.tsx index 523846e..b00714d 100644 --- a/polling/components/form/SelectNewPollTypeFormView.tsx +++ b/polling/components/form/SelectNewPollTypeFormView.tsx @@ -6,7 +6,14 @@ import { BaseModalCloseIcon, } from '../../ui/BaseModal'; import {PollKind} from '../../context/poll-context'; -import {ThemeConfig} from 'customization-api'; +import { + ThemeConfig, + $config, + ImageIcon, + hexadecimalTransparency, + PlatformWrapper, +} from 'customization-api'; +import {getPollTypeIcon} from '../../helpers'; interface newPollType { key: PollKind; @@ -16,23 +23,26 @@ interface newPollType { } 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', - description: 'Quick stand-alone question with different options', + 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: null, - // title: 'Open Ended', - // description: 'Question with a descriptive, open text response', - // }, - // { - // key: PollKind.YES_NO, - // image: null, - // title: 'Yes / No', - // description: 'A simple question with a binary Yes or No response', + // image: 'question', + // title: 'Open Ended Question', + // description: + // 'A question that invites users to provide a detailed, free-form response, encouraging more in-depth feedback.', // }, ]; @@ -45,28 +55,57 @@ export default function SelectNewPollTypeFormView({ }) { return ( <> - + - {newPollTypeConfig.map((item: newPollType) => ( - { - setType(item.key); - }}> - - - - {item.title} - {item.description} - - - - ))} + + + 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} + + + + ); + }} + + ))} + @@ -76,38 +115,59 @@ export default function SelectNewPollTypeFormView({ export const style = StyleSheet.create({ section: { display: 'flex', - flexDirection: 'row', + flexDirection: 'column', gap: 20, - justifyContent: 'space-around', }, - card: { + sectionHeader: { + color: $config.FONT_COLOR, + fontSize: ThemeConfig.FontSize.normal, + fontFamily: ThemeConfig.FontFamily.sansPro, + lineHeight: 20, + fontWeight: '400', + }, + pollTypeList: { + display: 'flex', flexDirection: 'column', gap: 12, - width: 140, + }, + 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: { - height: 90, - flexDirection: 'column', + width: 100, + height: 60, + display: 'flex', justifyContent: 'center', alignItems: 'center', - gap: 8, borderRadius: 8, - borderWidth: 1, - borderColor: $config.CARD_LAYER_3_COLOR, + 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: '400', + fontWeight: '700', }, cardContentDesc: { color: $config.FONT_COLOR + ThemeConfig.EmphasisPlus.low, diff --git a/polling/components/form/form-config.ts b/polling/components/form/form-config.ts index 23dfe2c..b91d0c2 100644 --- a/polling/components/form/form-config.ts +++ b/polling/components/form/form-config.ts @@ -1,10 +1,5 @@ import {nanoid} from 'nanoid'; -import { - PollKind, - PollItem, - PollAccess, - PollStatus, -} from '../../context/poll-context'; +import {PollKind, PollItem, PollStatus} from '../../context/poll-context'; const POLL_DURATION = 600; // takes seconds @@ -14,28 +9,32 @@ const getPollExpiresAtTime = (interval: number): number => { return expiresAT; }; -const initPollForm = (kind: PollKind): PollItem => { +const initPollForm = ( + kind: PollKind, + user: {uid: number; name: string}, +): PollItem => { if (kind === PollKind.OPEN_ENDED) { return { id: nanoid(4), type: PollKind.OPEN_ENDED, - access: PollAccess.PUBLIC, status: PollStatus.LATER, question: '', answers: null, options: null, multiple_response: false, - share: false, + share_attendee: true, + share_host: true, + anonymous: false, duration: false, - expiresAt: null, - createdBy: -1, + expiresAt: 0, + createdAt: Date.now(), + createdBy: {...user}, }; } if (kind === PollKind.MCQ) { return { id: nanoid(4), type: PollKind.MCQ, - access: PollAccess.PUBLIC, status: PollStatus.LATER, question: '', answers: null, @@ -59,18 +58,20 @@ const initPollForm = (kind: PollKind): PollItem => { percent: '0', }, ], - multiple_response: true, - share: false, + multiple_response: false, + share_attendee: true, + share_host: true, + anonymous: false, duration: false, - expiresAt: null, - createdBy: -1, + expiresAt: 0, + createdAt: Date.now(), + createdBy: {...user}, }; } if (kind === PollKind.YES_NO) { return { id: nanoid(4), type: PollKind.YES_NO, - access: PollAccess.PUBLIC, status: PollStatus.LATER, question: '', answers: null, @@ -89,12 +90,17 @@ const initPollForm = (kind: PollKind): PollItem => { }, ], multiple_response: false, - share: false, + share_attendee: true, + share_host: true, + anonymous: false, duration: false, - expiresAt: null, - createdBy: -1, + 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}`); }; const getAttributeLengthInKb = (attribute: string): string => { diff --git a/polling/components/form/poll-response-forms.tsx b/polling/components/form/poll-response-forms.tsx index 9031347..8934f89 100644 --- a/polling/components/form/poll-response-forms.tsx +++ b/polling/components/form/poll-response-forms.tsx @@ -1,14 +1,28 @@ -import {Text, View, StyleSheet, TextInput} from 'react-native'; +import { + Text, + View, + StyleSheet, + TextInput, + TouchableWithoutFeedback, +} from 'react-native'; import React, {useState} from 'react'; -import {PollItem, PollKind} from '../../context/poll-context'; -import PollTimer from '../PollTimer'; +import {PollKind, PollStatus} from '../../context/poll-context'; import { ImageIcon, Checkbox, PrimaryButton, ThemeConfig, + $config, + useLocalUid, + PlatformWrapper, } from 'customization-api'; import BaseRadioButton from '../../ui/BaseRadioButton'; +import { + PollOptionList, + PollOptionInputListItem, + PollItemFill, +} from '../poll-option-item-ui'; +import {PollFormButton, PollFormInput} from '../../hook/usePollForm'; function PollResponseFormComplete() { return ( @@ -22,93 +36,47 @@ function PollResponseFormComplete() { /> - Thank you for your response + Thank you for your response ); } -interface PollResponseFormProps { - pollItem: PollItem; - isFormFreezed: boolean; - onComplete: (responses: string | string[]) => void; -} - -function PollRenderResponseForm({ - pollItem, - onFormComplete, -}: { - pollItem: PollItem; - onFormComplete: (responses: string | string[]) => void; -}): JSX.Element { - return ( - <> - - - - ); -} - -function PollRenderResponseFormHeader({ - pollItem, -}: { - pollItem: PollItem; -}): JSX.Element { - return ( - <> - {pollItem.duration ? : null} - {pollItem.question} - - ); -} - -function PollRenderResponseFormBody({ - pollItem, - onFormComplete, -}: { - pollItem: PollItem; - onFormComplete: (responses: string | string[]) => void; -}): JSX.Element { +function PollRenderResponseFormBody( + props: PollFormInput & { + submitted: boolean; + submitting: boolean; + }, +): JSX.Element { // Directly use switch case logic inside the render - switch (pollItem.type) { - case PollKind.OPEN_ENDED: - return ( - - ); + switch (props.pollItem.type) { + // case PollKind.OPEN_ENDED: + // return ( + // + // ); case PollKind.MCQ: case PollKind.YES_NO: - return ( - - ); + return ; default: - console.error('Unknown poll type:', pollItem.type); + console.error('Unknown poll type:', props.pollItem.type); return Unknown poll type; } } -function PollResponseQuestionForm({ - pollItem, - isFormFreezed, - onComplete, -}: PollResponseFormProps) { +function PollResponseQuestionForm() { const [answer, setAnswer] = useState(''); return ( - + - - { - if (!answer || answer.trim() === '') { - return; - } - onComplete(answer); - }} - text="Submit" - /> - ); } function PollResponseMCQForm({ pollItem, - isFormFreezed, - onComplete, -}: PollResponseFormProps) { - const [selectedOption, setSelectedOption] = useState(null); - const [selectedOptions, setSelectedOptions] = useState([]); - - const handleCheckboxToggle = (value: string) => { - setSelectedOptions(prevSelectedOptions => { - if (prevSelectedOptions.includes(value)) { - return prevSelectedOptions.filter(option => option !== value); - } else { - return [...prevSelectedOptions, value]; - } - }); - }; - - const handleRadioSelect = (option: string) => { - setSelectedOption(option); - }; + 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) => ( + + <> + + + + + )} + + + + ); + })} + + + ); +} - const handleSubmit = () => { - if (selectedOptions.length === 0 && !selectedOption) { - return; - } - if (pollItem.multiple_response) { - onComplete(selectedOptions); - } else { - onComplete([selectedOption]); +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 ( - - - {pollItem.multiple_response - ? pollItem.options.map((option, index) => ( - - handleCheckboxToggle(option.value)} - /> - - )) - : pollItem.options.map((option, index) => ( - - - - ))} - - - - - + { + if (buttonStatus === 'submitted') { + return; + } else { + onSubmit(); + } + }} + text={buttonText} + /> ); } @@ -218,51 +274,18 @@ export { PollResponseQuestionForm, PollResponseMCQForm, PollResponseFormComplete, - PollRenderResponseForm, PollRenderResponseFormBody, + PollFormSubmitButton, }; export const style = StyleSheet.create({ - titleCard: { - display: 'flex', - flexDirection: 'row', - gap: 12, - }, - title: { + optionsForm: { 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, + gap: 20, + width: '100%', }, - 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, - }, - heading4: { + thankyouText: { color: $config.FONT_COLOR + ThemeConfig.EmphasisPlus.high, fontSize: ThemeConfig.FontSize.medium, fontFamily: ThemeConfig.FontFamily.sansPro, @@ -294,55 +317,33 @@ export const style = StyleSheet.create({ height: 36, borderRadius: 4, }, + submittedBtn: { + backgroundColor: $config.SEMANTIC_SUCCESS, + cursor: 'default', + }, btnText: { - color: $config.FONT_COLOR, + color: $config.PRIMARY_ACTION_TEXT_COLOR, fontSize: ThemeConfig.FontSize.small, fontFamily: ThemeConfig.FontFamily.sansPro, fontWeight: '600', textTransform: 'capitalize', }, - optionsSection: { - backgroundColor: $config.INPUT_FIELD_BACKGROUND_COLOR, - borderRadius: 9, - marginBottom: 32, - display: 'flex', - flexDirection: 'column', - gap: 4, - paddingVertical: 8, - }, - optionCard: { + optionListItem: { display: 'flex', flexDirection: 'row', - paddingHorizontal: 16, - paddingVertical: 8, + padding: 12, alignItems: 'center', + borderWidth: 1, + borderColor: $config.CARD_LAYER_3_COLOR, + backgroundColor: $config.CARD_LAYER_3_COLOR, }, - optionCardText: { + optionText: { color: $config.FONT_COLOR, fontSize: ThemeConfig.FontSize.normal, fontFamily: ThemeConfig.FontFamily.sansPro, fontWeight: '400', lineHeight: 24, }, - // pFormOptionText: { - // color: $config.FONT_COLOR + ThemeConfig.EmphasisPlus.high, - // fontSize: ThemeConfig.FontSize.small, - // fontFamily: ThemeConfig.FontFamily.sansPro, - // lineHeight: 16, - // fontWeight: '400', - // }, - // pFormOptionPrefix: { - // color: $config.FONT_COLOR + ThemeConfig.EmphasisPlus.low, - // paddingRight: 4, - // }, - // pFormOptionLink: { - // fontWeight: '400', - // lineHeight: 24, - // }, - // pFormOptions: { - // paddingVertical: 8, - // gap: 8, - // }, pFormInput: { flex: 1, color: $config.FONT_COLOR + ThemeConfig.EmphasisPlus.high, @@ -365,41 +366,25 @@ export const style = StyleSheet.create({ mediumHeight: { height: 272, }, - // pFormOptionCard: { - // display: 'flex', - // paddingHorizontal: 16, - // flexDirection: 'row', - // justifyContent: 'flex-start', - // alignItems: 'center', - // alignSelf: 'stretch', - // gap: 8, - // backgroundColor: $config.INPUT_FIELD_BACKGROUND_COLOR, - // borderRadius: 9, - // }, - // verticalPadding: { - // paddingVertical: 12, - // }, - // pFormCheckboxContainer: { - // paddingHorizontal: 16, - // paddingVertical: 8, - // }, - // previewActions: { - // flex: 1, - // display: 'flex', - // flexDirection: 'row', - // alignItems: 'center', - // justifyContent: 'flex-end', - // }, - // btnContainer: { - // minWidth: 150, - // height: 36, - // borderRadius: 4, - // }, - // btnText: { - // color: $config.FONT_COLOR, - // fontSize: ThemeConfig.FontSize.small, - // fontFamily: ThemeConfig.FontFamily.sansPro, - // fontWeight: '600', - // textTransform: 'capitalize', - // }, + checkboxContainer: { + display: 'flex', + flexDirection: 'row', + alignItems: 'center', + padding: 12, + width: '100%', + }, + checkBox: { + borderColor: $config.FONT_COLOR, + }, + checkboxVoted: { + borderColor: $config.PRIMARY_ACTION_BRAND_COLOR, + backgroundColor: $config.PRIMARY_ACTION_BRAND_COLOR, + }, + checkboxSubmittedAndVoted: { + borderColor: $config.FONT_COLOR, + backgroundColor: $config.FONT_COLOR, + }, + checkboxSubmittedAndNotVoted: { + borderColor: $config.FONT_COLOR, + }, }); diff --git a/polling/components/modals/PollEndConfirmModal.tsx b/polling/components/modals/PollEndConfirmModal.tsx new file mode 100644 index 0000000..c3c2914 --- /dev/null +++ b/polling/components/modals/PollEndConfirmModal.tsx @@ -0,0 +1,106 @@ +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, +} from 'customization-api'; +import {PollTaskRequestTypes, usePoll} from '../../context/poll-context'; + +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, + height: 36, + borderRadius: 4, + }, + 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/polling/components/modals/PollFormWizardModal.tsx b/polling/components/modals/PollFormWizardModal.tsx index 88b3467..6691524 100644 --- a/polling/components/modals/PollFormWizardModal.tsx +++ b/polling/components/modals/PollFormWizardModal.tsx @@ -1,4 +1,4 @@ -import React, {useEffect, useState} from 'react'; +import React, {useEffect, useState, useRef} from 'react'; import {BaseModal} from '../../ui/BaseModal'; import SelectNewPollTypeFormView from '../form/SelectNewPollTypeFormView'; import DraftPollFormView from '../form/DraftPollFormView'; @@ -11,36 +11,84 @@ import { } from '../../context/poll-context'; import {usePoll} from '../../context/poll-context'; import {initPollForm} from '../form/form-config'; -import {useLocalUid} from 'customization-api'; +import {useLocalUid, useContent} from 'customization-api'; +import {log} from '../../helpers'; type FormWizardStep = 'SELECT' | 'DRAFT' | 'PREVIEW'; -export default function PollFormWizardModal() { +interface PollFormWizardModalProps { + formObject?: PollItem; // Optional prop to initialize form in edit mode + formStep?: FormWizardStep; +} + +export default function PollFormWizardModal({ + formObject, + formStep, +}: PollFormWizardModalProps) { const {savePoll, sendPoll, closeCurrentModal} = usePoll(); - const [step, setStep] = useState('SELECT'); - const [type, setType] = useState(null); - const [form, setForm] = useState(null); - const [formErrors, setFormErrors] = useState(null); + const [savedPollId, setSavedPollId] = useState(null); + + const [step, setStep] = useState( + formObject ? 'DRAFT' : 'SELECT', + ); + 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 (!type) { - return; + if (savedPollId) { + sendPoll(savedPollId); } - setForm(initPollForm(type)); - setStep('DRAFT'); - }, [type]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [savedPollId]); + + useEffect(() => { + try { + if (formObject) { + // If formObject is passed, skip the SELECT step and initialize the form + setForm(formObject); + if (formStep) { + setStep(formStep); + } else { + setStep('DRAFT'); + } + } else if (type !== PollKind.NONE) { + // Initialize the form for a new poll based on the selected type + const user = { + uid: localUidRef.current, + name: defaultContentRef?.current[localUidRef.current]?.name || 'user', + }; + setForm(initPollForm(type, user)); + setStep('DRAFT'); + } + } catch (error) { + log('Error while initializing form: ', error); + } + }, [type, formObject, formStep]); const onSave = (launch?: boolean) => { - const payload = { - ...form, - status: launch ? PollStatus.ACTIVE : PollStatus.LATER, - createdBy: localUid, - }; - savePoll(payload); - if (launch) { - sendPoll(payload.id); + 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); } }; @@ -55,33 +103,44 @@ export default function PollFormWizardModal() { }; const validateForm = () => { - setFormErrors(null); - if (form.question.trim() === '') { - setFormErrors({ - ...formErrors, - question: {message: 'Cannot be blank'}, - }); + // 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.find(item => item.text.trim() === '')) + form.options.some(item => item.text.trim() === '')) ) { - setFormErrors({ - ...formErrors, - options: {message: 'Cannot be empty'}, - }); - return false; + errors = { + ...errors, + options: {message: 'Option can’t be empty.'}, + }; } - return true; + // 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(null); + setFormErrors({}); setForm(null); - setType(null); + setType(PollKind.NONE); closeCurrentModal(); }; @@ -93,22 +152,27 @@ export default function PollFormWizardModal() { ); case 'DRAFT': return ( - + form && ( + + ) ); case 'PREVIEW': return ( - + form && ( + + ) ); default: return <>; @@ -116,7 +180,7 @@ export default function PollFormWizardModal() { } return ( - + {renderSwitch()} ); diff --git a/polling/components/modals/PollItemNotFound.tsx b/polling/components/modals/PollItemNotFound.tsx new file mode 100644 index 0000000..774a00e --- /dev/null +++ b/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/polling/components/modals/PollResponseFormModal.tsx b/polling/components/modals/PollResponseFormModal.tsx index d399ec9..f0d7847 100644 --- a/polling/components/modals/PollResponseFormModal.tsx +++ b/polling/components/modals/PollResponseFormModal.tsx @@ -1,56 +1,186 @@ import React, {useState} from 'react'; +import {Text, View, StyleSheet} from 'react-native'; import { BaseModal, + BaseModalActions, BaseModalCloseIcon, BaseModalContent, BaseModalTitle, } from '../../ui/BaseModal'; import { PollResponseFormComplete, - PollRenderResponseForm, + PollRenderResponseFormBody, + PollFormSubmitButton, } from '../form/poll-response-forms'; -import {usePoll, PollTaskRequestTypes} from '../../context/poll-context'; -import PollAvatarHeader from '../PollAvatarHeader'; +import { + PollStatus, + PollTaskRequestTypes, + usePoll, +} from '../../context/poll-context'; +import {getPollTypeDesc} from '../../helpers'; +import { + ThemeConfig, + $config, + TertiaryButton, + useSidePanel, +} from 'customization-api'; +import {usePollForm} from '../../hook/usePollForm'; +import {POLL_SIDEBAR_NAME} from '../../../custom-ui'; -export default function PollResponseFormModal() { - const { - polls, - launchPollId, - sendResponseToPoll, - handlePollTaskRequest, - closeCurrentModal, - } = usePoll(); +export default function PollResponseFormModal({pollId}: {pollId: string}) { + const {polls, sendResponseToPoll, closeCurrentModal, handlePollTaskRequest} = + usePoll(); + const {setSidePanel} = useSidePanel(); const [hasResponded, setHasResponded] = useState(false); - const pollItem = polls[launchPollId]; + const pollItem = polls[pollId]; - const onFormComplete = (responses: string | string[]) => { + const onFormSubmit = (responses: string | string[]) => { sendResponseToPoll(pollItem, responses); - if (pollItem.share) { + }; + + 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 ? ( ) : ( <> - + + 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: 12, + }, + btnContainer: { + minWidth: 150, + height: 36, + borderRadius: 4, + }, + 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/polling/components/modals/PollResultModal.tsx b/polling/components/modals/PollResultModal.tsx index 558055c..863ce0a 100644 --- a/polling/components/modals/PollResultModal.tsx +++ b/polling/components/modals/PollResultModal.tsx @@ -5,94 +5,370 @@ import { BaseModalTitle, BaseModalContent, BaseModalCloseIcon, + BaseModalActions, } from '../../ui/BaseModal'; -import {ThemeConfig} from 'customization-api'; -import PollAvatarHeader from '../PollAvatarHeader'; -import {PollItemOptionItem, usePoll} from '../../context/poll-context'; -import {PollOptionList, PollOptionListItemResult} from '../poll-option-item-ui'; +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'; -export default function PollResultModal() { - const {polls, viewResultPollId, isHost, closeCurrentModal} = usePoll(); +export default function PollResultModal({pollId}: {pollId: string}) { + const {polls, closeCurrentModal, handlePollTaskRequest} = usePoll(); + const localUid = useLocalUid(); + const {defaultContent} = useContent(); - const pollItem = polls[viewResultPollId]; + const pollItem = polls[pollId]; + const {canViewWhoVoted} = usePollPermissions({pollItem}); return ( - - - + + - - - {pollItem.question} - - - {pollItem.options.map((item: PollItemOptionItem) => ( - - ))} - + + + + + + {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 + + )} + + + + + + { + handlePollTaskRequest(PollTaskRequestTypes.EXPORT, pollItem.id); + }} + /> + + ); } export const style = StyleSheet.create({ - shareBox: { - width: 550, + 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', - gap: 20, + alignItems: 'flex-start', + gap: 4, }, - titleCard: { + percentText: { display: 'flex', flexDirection: 'row', + alignItems: 'center', gap: 12, }, - title: { + rowCenter: { + display: 'flex', + flexDirection: 'row', + alignItems: 'center', + }, + resultSummaryContainer: { + paddingVertical: 16, + paddingHorizontal: 20, + gap: 16, + }, + summaryCard: { display: 'flex', flexDirection: 'column', - gap: 2, + 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: 36, - height: 36, - borderRadius: 18, + width: 24, + height: 24, + borderRadius: 12, backgroundColor: $config.VIDEO_AUDIO_TILE_AVATAR_COLOR, }, titleAvatarContainerText: { - fontSize: ThemeConfig.FontSize.small, - lineHeight: 16, + fontSize: ThemeConfig.FontSize.tiny, + lineHeight: 12, fontWeight: '600', - color: $config.VIDEO_AUDIO_TILE_COLOR, + color: $config.BACKGROUND_COLOR, }, - titleText: { - color: $config.FONT_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, - fontWeight: '700', + 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', }, - titleSubtext: { + 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, }, - questionText: { + username: { color: $config.FONT_COLOR + ThemeConfig.EmphasisPlus.high, - fontSize: ThemeConfig.FontSize.medium, - fontFamily: ThemeConfig.FontFamily.sansPro, - lineHeight: 24, - fontWeight: '600', + fontSize: ThemeConfig.FontSize.small, + fontWeight: '400', + lineHeight: 20, + }, + alignRight: { + textAlign: 'right', + }, + btnContainer: { + minWidth: 150, + height: 36, + borderRadius: 4, + }, + 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/polling/components/poll-option-item-ui.tsx b/polling/components/poll-option-item-ui.tsx index 1c11677..89db63c 100644 --- a/polling/components/poll-option-item-ui.tsx +++ b/polling/components/poll-option-item-ui.tsx @@ -1,104 +1,126 @@ import React from 'react'; import {Text, View, StyleSheet, DimensionValue} from 'react-native'; -import {PollItemOptionItem} from '../context/poll-context'; -import {ThemeConfig, useLocalUid} from 'customization-api'; - -interface PollOptionListItem { - optionItem: PollItemOptionItem; - showYourVote?: boolean; -} +import {ThemeConfig, $config, hexadecimalTransparency} from 'customization-api'; function PollOptionList({children}: {children: React.ReactNode}) { return {children}; } -function PollOptionListItemResult({ - optionItem, - showYourVote, -}: PollOptionListItem) { - const localUid = useLocalUid(); +interface Props { + submitting: boolean; + submittedMyVote: boolean; + percent: string; +} +function PollItemFill({submitting, submittedMyVote, percent}: Props) { return ( - - - {optionItem.text} - {showYourVote && - optionItem.votes.some(item => item.uid === localUid) && ( - Your Response - )} - - {optionItem.percent}% ({optionItem.votes.length}) - - - - - - + <> + + + {`${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: { - backgroundColor: $config.INPUT_FIELD_BACKGROUND_COLOR, - borderRadius: 9, - paddingTop: 8, - paddingHorizontal: 12, - paddingBottom: 32, display: 'flex', flexDirection: 'column', - gap: 4, + gap: 8, + width: '100%', }, optionListItem: { - display: 'flex', - flexDirection: 'column', - gap: 4, - }, - optionListItemHeader: { display: 'flex', flexDirection: 'row', - paddingHorizontal: 16, - paddingVertical: 8, + 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, }, - optionListItemFooter: {}, optionText: { color: $config.FONT_COLOR, fontSize: ThemeConfig.FontSize.normal, fontFamily: ThemeConfig.FontFamily.sansPro, - fontWeight: '400', + fontWeight: '700', lineHeight: 24, }, - yourResponseText: { - color: $config.SEMANTIC_SUCCESS, - fontSize: ThemeConfig.FontSize.tiny, - fontFamily: ThemeConfig.FontFamily.sansPro, - fontWeight: '600', - lineHeight: 12, - paddingLeft: 16, + myVote: { + color: $config.PRIMARY_ACTION_BRAND_COLOR, }, pushRight: { marginLeft: 'auto', }, - progressBar: { - height: 4, - borderRadius: 8, - backgroundColor: $config.CARD_LAYER_3_COLOR, - width: '100%', - }, - progressBarFill: { - borderRadius: 8, - backgroundColor: $config.PRIMARY_ACTION_BRAND_COLOR, - }, }); -export {PollOptionList, PollOptionListItemResult}; +export {PollOptionList, PollOptionInputListItem, PollItemFill}; diff --git a/polling/context/poll-context.tsx b/polling/context/poll-context.tsx index aa8ed3d..83606fa 100644 --- a/polling/context/poll-context.tsx +++ b/polling/context/poll-context.tsx @@ -1,6 +1,21 @@ -import React, {createContext, useReducer, useEffect, useState} from 'react'; +import React, { + createContext, + useReducer, + useEffect, + useState, + useMemo, + useRef, + useCallback, +} from 'react'; import {usePollEvents} from './poll-events'; -import {useLocalUid, useRoomInfo, filterObject, Toast} from 'customization-api'; +import { + useLocalUid, + useRoomInfo, + useSidePanel, + SidePanelType, + useContent, + isWeb, +} from 'customization-api'; import { getPollExpiresAtTime, POLL_DURATION, @@ -9,15 +24,12 @@ import { addVote, arrayToCsv, calculatePercentage, + debounce, downloadCsv, - hasUserVoted, log, mergePolls, } from '../helpers'; - -enum PollAccess { - PUBLIC = 'PUBLIC', -} +import {POLL_SIDEBAR_NAME} from '../../custom-ui'; enum PollStatus { ACTIVE = 'ACTIVE', @@ -29,34 +41,47 @@ enum PollKind { OPEN_ENDED = 'OPEN_ENDED', MCQ = 'MCQ', YES_NO = 'YES_NO', + NONE = 'NONE', } -enum PollModalState { +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', - FINISH = 'FINISH', 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; access: PollAccess; timestamp: number}>; + votes: Array<{uid: number; name: string; timestamp: number}>; percent: string; } interface PollItem { id: string; type: PollKind; - access: PollAccess; // remove it as poll are not private or public but the response will be public or private status: PollStatus; question: string; answers: Array<{ @@ -66,10 +91,13 @@ interface PollItem { }> | null; options: Array | null; multiple_response: boolean; - share: boolean; + share_attendee: boolean; + share_host: boolean; + anonymous: boolean; duration: boolean; expiresAt: number; - createdBy: number; + createdBy: {uid: number; name: string}; + createdAt: number; } type Poll = Record; @@ -85,8 +113,8 @@ interface PollFormErrors { enum PollActionKind { SAVE_POLL_ITEM = 'SAVE_POLL_ITEM', + ADD_POLL_ITEM = 'ADD_POLL_ITEM', SEND_POLL_ITEM = 'SEND_POLL_ITEM', - UPDATE_POLL_ITEM = 'UPDATE_POLL_ITEM', SUBMIT_POLL_ITEM_RESPONSES = 'SUBMIT_POLL_ITEM_RESPONSES', RECEIVE_POLL_ITEM_RESPONSES = 'RECEIVE_POLL_ITEM_RESPONSES', PUBLISH_POLL_ITEM = 'PUBLISH_POLL_ITEM', @@ -94,9 +122,16 @@ enum PollActionKind { 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}; @@ -105,16 +140,12 @@ type PollAction = type: PollActionKind.SEND_POLL_ITEM; payload: {pollId: string}; } - | { - type: PollActionKind.UPDATE_POLL_ITEM; - payload: {pollId: string; partialItem: Partial}; - } | { type: PollActionKind.SUBMIT_POLL_ITEM_RESPONSES; payload: { id: string; responses: string | string[]; - uid: number; + user: {name: string; uid: number}; timestamp: number; }; } @@ -123,7 +154,7 @@ type PollAction = payload: { id: string; responses: string | string[]; - uid: number; + user: {name: string; uid: number}; timestamp: number; }; } @@ -145,6 +176,14 @@ type PollAction = } | { type: PollActionKind.RESET; + payload: null; + } + | { + type: PollActionKind.SYNC_COMPLETE; + payload: { + latestTask: PollTaskRequestTypes; + latestPollId: string; + }; }; function pollReducer(state: Poll, action: PollAction): Poll { @@ -156,6 +195,13 @@ function pollReducer(state: Poll, action: PollAction): Poll { [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 { @@ -167,101 +213,92 @@ function pollReducer(state: Poll, action: PollAction): Poll { }, }; } - case PollActionKind.UPDATE_POLL_ITEM: { - const pollId = action.payload.pollId; - return { - ...state, - [pollId]: {...state[pollId], ...action.payload.partialItem}, - }; + 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.SUBMIT_POLL_ITEM_RESPONSES: - { - const {id: pollId, uid, 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, - { - uid, - response: responses, - timestamp, - }, - ] - : [{uid, response: responses, timestamp}], - }, - }; - } - if (poll.type === PollKind.MCQ && Array.isArray(responses)) { - const newCopyOptions = poll.options?.map(item => ({...item})) || []; - const withVotesOptions = addVote( - responses, - newCopyOptions, - uid, - timestamp, - ); - const withPercentOptions = calculatePercentage(withVotesOptions); - return { - ...state, - [pollId]: { - ...poll, - options: withPercentOptions, - }, - }; - } + 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}], + }, + }; } - break; - case PollActionKind.RECEIVE_POLL_ITEM_RESPONSES: - { - const {id: pollId, uid, 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, - { - uid, - response: responses, - timestamp, - }, - ] - : [{uid, response: responses, timestamp}], - }, - }; - } - if (poll.type === PollKind.MCQ && Array.isArray(responses)) { - const newCopyOptions = poll.options?.map(item => ({...item})) || []; - const withVotesOptions = addVote( - responses, - newCopyOptions, - uid, - timestamp, - ); - const withPercentOptions = calculatePercentage(withVotesOptions); - return { - ...state, - [pollId]: { - ...poll, - options: withPercentOptions, - }, - }; - } + 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, + }, + }; } - break; + return state; + } case PollActionKind.PUBLISH_POLL_ITEM: // No action need just return the state return state; @@ -275,16 +312,17 @@ function pollReducer(state: Poll, action: PollAction): Poll { }; } } - break; + return state; case PollActionKind.EXPORT_POLL_ITEM: { const pollId = action.payload.pollId; - if (pollId) { - let csv = arrayToCsv(state[pollId].options); + 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'); } } - break; + return state; case PollActionKind.DELETE_POLL_ITEM: { const pollId = action.payload.pollId; @@ -296,7 +334,7 @@ function pollReducer(state: Poll, action: PollAction): Poll { }; } } - break; + return state; case PollActionKind.RESET: { return {}; } @@ -308,25 +346,28 @@ function pollReducer(state: Poll, action: PollAction): Poll { interface PollContextValue { polls: Poll; - currentModal: PollModalState; 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[], - uid: number, + user: { + uid: number; + name: string; + }, timestamp: number, ) => void; - launchPollId: string; - viewResultPollId: string; sendPollResults: (pollId: string) => void; + modalState: PollModalState; closeCurrentModal: () => void; isHost: boolean; handlePollTaskRequest: (task: PollTaskRequestTypes, pollId: string) => void; @@ -337,99 +378,243 @@ PollContext.displayName = 'PollContext'; function PollProvider({children}: {children: React.ReactNode}) { const [polls, dispatch] = useReducer(pollReducer, {}); - const [currentModal, setCurrentModal] = useState(null); - const [launchPollId, setLaunchPollId] = useState(null); - const [viewResultPollId, setViewResultPollId] = useState(null); + 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 (isWeb()) { + window.addEventListener('beforeunload', handleBeforeUnload); + } + + return () => { + if (isWeb()) { + window.removeEventListener('beforeunload', handleBeforeUnload); + } else { + deleteMyPolls(); + } + }; + }, [localUid]); const enhancedDispatch = (action: PollAction) => { + log(`Dispatching action: ${action.type} with payload:`, action.payload); dispatch(action); setLastAction(action); }; - const localUid = useLocalUid(); - - const {sendPollEvt, sendResponseToPollEvt} = usePollEvents(); + const closeCurrentModal = useCallback(() => { + log('Closing current modal.'); + setModalState({ + modalType: PollModalType.NONE, + id: null, + }); + }, []); useEffect(() => { - if (lastAction) { + 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) { - setCurrentModal(null); + 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; - sendPollEvt(polls, pollId, PollTaskRequestTypes.SEND); - setCurrentModal(null); + 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: - const {id, responses, uid, timestamp} = lastAction.payload; - sendResponseToPollEvt(id, responses, uid, timestamp); + 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; - sendPollEvt(polls, pollId, PollTaskRequestTypes.PUBLISH); + syncPollEvt(pollsRef.current, pollId, PollTaskRequestTypes.PUBLISH); } break; case PollActionKind.FINISH_POLL_ITEM: + log('Handling FINISH_POLL_ITEM'); { const {pollId} = lastAction.payload; - sendPollEvt(polls, pollId, PollTaskRequestTypes.FINISH); + syncPollEvt(pollsRef.current, pollId, PollTaskRequestTypes.FINISH); + closeCurrentModal(); } break; case PollActionKind.DELETE_POLL_ITEM: + log('Handling DELETE_POLL_ITEM'); { const {pollId} = lastAction.payload; - sendPollEvt(polls, pollId, PollTaskRequestTypes.DELETE); + 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, sendPollEvt, polls, sendResponseToPollEvt]); + }, [ + lastAction, + localUid, + setSidePanel, + syncPollEvt, + sendResponseToPollEvt, + callDebouncedSyncPoll, + closeCurrentModal, + ]); const startPollForm = () => { - setCurrentModal(PollModalState.DRAFT_POLL); + 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}, - }, + 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) => { - // check if there is an already launched poll - const isAnyPollActive = Object.keys( - filterObject(polls, ([_, v]) => v.status === PollStatus.ACTIVE), - ); - if (isAnyPollActive.length > 0) { - Toast.show({ - leadingIconName: 'alert', - type: 'error', - text1: 'Cannot publish poll now as there is already one poll active', - text2: '', - visibilityTime: 1000 * 3, - }); + 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, - }, + payload: {pollId}, }); }; @@ -437,51 +622,84 @@ function PollProvider({children}: {children: React.ReactNode}) { newPoll: Poll, pollId: string, task: PollTaskRequestTypes, + initialLoad: boolean, ) => { - log('onPollReceived task', task); - const mergedPolls = mergePolls(newPoll, polls); + 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) { - enhancedDispatch({ - type: PollActionKind.RESET, - }); + log('No polls left after merge. Resetting state.'); + enhancedDispatch({type: PollActionKind.RESET, payload: null}); return; } - if (isHost) { - log('i am host'); - Object.entries(mergedPolls).forEach(([_, pollItem]) => { - savePoll(pollItem); + + 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) + .filter(pollItem => pollItem.status !== PollStatus.LATER) + .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 { - log('i am attendee'); - Object.entries(mergedPolls).forEach(([_, pollItem]) => { - if (pollItem.status === PollStatus.LATER) { - return; + 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'); } - savePoll(pollItem); - if (pollItem.status === PollStatus.ACTIVE) { - // If status is active but voted - if (hasUserVoted(pollItem.options, localUid)) { - return; - } - // if status is active but not voted - setLaunchPollId(pollId); - setCurrentModal(PollModalState.RESPOND_TO_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.MCQ && Array.isArray(responses)) + (item.type !== PollKind.OPEN_ENDED && Array.isArray(responses)) ) { enhancedDispatch({ type: PollActionKind.SUBMIT_POLL_ITEM_RESPONSES, payload: { id: item.id, responses, - uid: localUid, + user: { + uid: localUid, + name: defaultContent[localUid]?.name || 'user', + }, timestamp: Date.now(), }, }); @@ -495,100 +713,118 @@ function PollProvider({children}: {children: React.ReactNode}) { const onPollResponseReceived = ( pollId: string, responses: string | string[], - uid: number, + 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, - uid, + user, timestamp, }, }); }; const sendPollResults = (pollId: string) => { - sendPollEvt(polls, pollId, PollTaskRequestTypes.SHARE); + 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: - sendPoll(pollId); + if (polls[pollId].status === PollStatus.LATER) { + setModalState({ + modalType: PollModalType.PREVIEW_POLL, + id: pollId, + }); + } else { + sendPoll(pollId); + } break; - case PollTaskRequestTypes.SHARE: - // No user case so far break; case PollTaskRequestTypes.VIEW_DETAILS: - setViewResultPollId(pollId); - setCurrentModal(PollModalState.VIEW_POLL_RESULTS); + setModalState({ + modalType: PollModalType.VIEW_POLL_RESULTS, + id: pollId, + }); break; case PollTaskRequestTypes.PUBLISH: enhancedDispatch({ type: PollActionKind.PUBLISH_POLL_ITEM, - payload: { - pollId, - }, + 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, - }, + 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, - }, + payload: {pollId}, }); break; case PollTaskRequestTypes.EXPORT: enhancedDispatch({ type: PollActionKind.EXPORT_POLL_ITEM, - payload: { - pollId, - }, + payload: {pollId}, }); break; default: + log(`Unhandled task type: ${task}`); break; } }; - const closeCurrentModal = () => { - if (currentModal === PollModalState.RESPOND_TO_POLL) { - setLaunchPollId(null); - } - if (currentModal === PollModalState.VIEW_POLL_RESULTS) { - setViewResultPollId(null); - } - setCurrentModal(null); - }; - const value = { polls, startPollForm, + editPollForm, sendPoll, savePoll, onPollReceived, onPollResponseReceived, - currentModal, - launchPollId, - viewResultPollId, sendResponseToPoll, sendPollResults, handlePollTaskRequest, + modalState, closeCurrentModal, isHost, }; @@ -610,8 +846,7 @@ export { PollActionKind, PollKind, PollStatus, - PollAccess, - PollModalState, + PollModalType, PollTaskRequestTypes, }; diff --git a/polling/context/poll-events.tsx b/polling/context/poll-events.tsx index f56ae5d..0895927 100644 --- a/polling/context/poll-events.tsx +++ b/polling/context/poll-events.tsx @@ -1,5 +1,13 @@ -import React, {createContext, useContext, useEffect} from 'react'; -import {Poll, PollTaskRequestTypes, usePoll} from './poll-context'; +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 {log} from '../helpers'; @@ -7,24 +15,19 @@ enum PollEventNames { polls = 'POLLS', pollResponse = 'POLL_RESPONSE', } -enum PollEventActions { - sendPoll = 'SEND_POLL', - sendResponseToPoll = 'SEND_RESONSE_TO_POLL', - sendPollResults = 'SEND_POLL_RESULTS', -} type sendResponseToPollEvtFunction = ( - id: string, + item: PollItem, responses: string | string[], - uid: number, + user: {name: string; uid: number}, timestamp: number, ) => void; interface PollEventsContextValue { - sendPollEvt: ( + syncPollEvt: ( polls: Poll, pollId: string, - task?: PollTaskRequestTypes, + task: PollTaskRequestTypes, ) => void; sendResponseToPollEvt: sendResponseToPollEvtFunction; } @@ -34,45 +37,72 @@ PollEventsContext.displayName = 'PollEventsContext'; // Event Dispatcher function PollEventsProvider({children}: {children?: React.ReactNode}) { - const sendPollEvt = ( - polls: Poll, - pollId: string, - task: PollTaskRequestTypes, - ) => { - events.send( - PollEventNames.polls, - JSON.stringify({ - state: {...polls}, - action: PollEventActions.sendPoll, - pollId: pollId, - task, - }), - PersistanceLevel.Channel, - ); - }; - - const sendResponseToPollEvt: sendResponseToPollEvtFunction = ( - id, - responses, - uid, - timestamp, - ) => { - events.send( - PollEventNames.pollResponse, - JSON.stringify({ - id, - responses, - uid, - timestamp, - }), - PersistanceLevel.None, - ); - }; - - const value = { - sendPollEvt, - sendResponseToPollEvt, - }; + // 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.'); + } + events.send( + PollEventNames.polls, + JSON.stringify({ + state: {...polls}, + pollId: pollId, + task, + }), + PersistanceLevel.Channel, + ); + log('Poll sync successful', {pollId, task}); + } 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 ( @@ -95,35 +125,94 @@ 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 => { - // const {payload, sender, ts} = args; - const {payload} = args; - const data = JSON.parse(payload); - const {action, state, pollId, task} = data; - log('poll channel state received', data); - switch (action) { - case PollEventActions.sendPoll: - onPollReceived(state, pollId, task); - break; - default: - break; + 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 => { - const {payload} = args; - const data = JSON.parse(payload); - log('poll response received', data); - const {id, responses, uid, timestamp} = data; - onPollResponseReceived(id, responses, uid, timestamp); + 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); }; - }, [onPollReceived, onPollResponseReceived]); + }, [initialized, initialLoadComplete]); return ( diff --git a/polling/helpers.ts b/polling/helpers.ts index 376db84..2fb8c6f 100644 --- a/polling/helpers.ts +++ b/polling/helpers.ts @@ -1,19 +1,21 @@ -import {Poll, PollAccess, PollItemOptionItem} from './context/poll-context'; +import {isMobileUA, isWeb} from 'customization-api'; +import {Poll, PollItemOptionItem, PollKind} from './context/poll-context'; +import pollIcons from './poll-icons'; function log(...args: any[]) { - console.log('[CustomPolling::] ', ...args); + console.log('[Custom-Polling::] supriya ', ...args); } function addVote( responses: string[], options: PollItemOptionItem[], - uid: number, + 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 === uid); + const isVoted = option.votes.find(item => item.uid === user.uid); if (exists && !isVoted) { // Creating a new object explicitly const newOption: PollItemOptionItem = { @@ -22,8 +24,7 @@ function addVote( votes: [ ...option.votes, { - uid, - access: PollAccess.PUBLIC, + ...user, timestamp, }, ], @@ -61,14 +62,20 @@ function calculatePercentage( }) as PollItemOptionItem[]; } -function arrayToCsv(data: PollItemOptionItem[]): string { - const headers = ['text', 'value', 'votes', 'percent']; // Define the headers +function arrayToCsv(question: string, data: PollItemOptionItem[]): string { + const headers = ['Option', 'Votes', 'Percent']; // Define the headers const rows = data.map(item => { - const voteIds = item.votes.map(vote => vote.uid).join(', '); // Combine vote uids into a single string - return `${item.text},${voteIds}`; - }); + 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 [headers.join(','), ...rows].join('\n'); + 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 { @@ -86,8 +93,8 @@ function downloadCsv(data: string, filename: string = 'data.csv'): void { document.body.removeChild(link); } -function capitalizeFirstLetter(string: string): string { - return string.charAt(0).toUpperCase() + string.slice(1).toLowerCase(); +function capitalizeFirstLetter(sentence: string): string { + return sentence.charAt(0).toUpperCase() + sentence.slice(1).toLowerCase(); } function hasUserVoted(options: PollItemOptionItem[], uid: number): boolean { @@ -95,25 +102,103 @@ function hasUserVoted(options: PollItemOptionItem[], uid: number): boolean { return options.some(option => option.votes.some(vote => vote.uid === uid)); } -function mergePolls(newPoll: Poll, oldPoll: Poll) { +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. Add or update polls from newPolls + + // 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 }); - // 3. Remove polls that are not in newPolls - Object.keys(mergedPolls).forEach(pollId => { + + // 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 } }); - return mergedPolls; + // 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(); + export { log, mergePolls, @@ -123,4 +208,10 @@ export { addVote, calculatePercentage, capitalizeFirstLetter, + getPollTypeDesc, + formatTimestampToTime, + calculateTotalVotes, + debounce, + getPollTypeIcon, + isWebOnly, }; diff --git a/polling/hook/useButtonState.tsx b/polling/hook/useButtonState.tsx new file mode 100644 index 0000000..8ca5914 --- /dev/null +++ b/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/polling/hook/usePollForm.tsx b/polling/hook/usePollForm.tsx new file mode 100644 index 0000000..b794fa3 --- /dev/null +++ b/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/polling/hook/usePollPermissions.tsx b/polling/hook/usePollPermissions.tsx new file mode 100644 index 0000000..fbca9e2 --- /dev/null +++ b/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/polling/poll-icons.ts b/polling/poll-icons.ts new file mode 100644 index 0000000..ba5422a --- /dev/null +++ b/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/svg+xml;base64,PHN2ZyB3aWR0aD0iMjQiIGhlaWdodD0iMjQiIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPGcgaWQ9InBvbGwtaWNvbnMiPgo8bWFzayBpZD0ibWFzazBfOTYzN182NjY0OCIgc3R5bGU9Im1hc2stdHlwZTphbHBoYSIgbWFza1VuaXRzPSJ1c2VyU3BhY2VPblVzZSIgeD0iMCIgeT0iMCIgd2lkdGg9IjI0IiBoZWlnaHQ9IjI0Ij4KPHJlY3QgaWQ9IkJvdW5kaW5nIGJveCIgd2lkdGg9IjI0IiBoZWlnaHQ9IjI0IiBmaWxsPSIjRkY0MTREIi8+CjwvbWFzaz4KPGcgbWFzaz0idXJsKCNtYXNrMF85NjM3XzY2NjQ4KSI+CjxwYXRoIGlkPSJjaGVja2xpc3QiIGQ9Ik01LjUyNDggMTYuMTczOUw5LjA3NDggMTIuNjIzOUM5LjI3NDggMTIuNDIzOSA5LjUwODE0IDEyLjMyODEgOS43NzQ4IDEyLjMzNjRDMTAuMDQxNSAxMi4zNDQ4IDEwLjI3NDggMTIuNDQ4OSAxMC40NzQ4IDEyLjY0ODlDMTAuNjU4MSAxMi44NDg5IDEwLjc0OTggMTMuMDgyMyAxMC43NDk4IDEzLjM0ODlDMTAuNzQ5OCAxMy42MTU2IDEwLjY1ODEgMTMuODQ4OSAxMC40NzQ4IDE0LjA0ODlMNi4yNDk4IDE4LjI5ODlDNi4wNDk4IDE4LjQ5ODkgNS44MTY0NyAxOC41OTg5IDUuNTQ5OCAxOC41OTg5QzUuMjgzMTQgMTguNTk4OSA1LjA0OTggMTguNDk4OSA0Ljg0OTggMTguMjk4OUwyLjY5OTggMTYuMTQ4OUMyLjUxNjQ3IDE1Ljk2NTYgMi40MjQ4IDE1LjczMjMgMi40MjQ4IDE1LjQ0ODlDMi40MjQ4IDE1LjE2NTYgMi41MTY0NyAxNC45MzIzIDIuNjk5OCAxNC43NDg5QzIuODgzMTQgMTQuNTY1NiAzLjExNjQ3IDE0LjQ3MzkgMy4zOTk4IDE0LjQ3MzlDMy42ODMxNCAxNC40NzM5IDMuOTE2NDcgMTQuNTY1NiA0LjA5OTggMTQuNzQ4OUw1LjUyNDggMTYuMTczOVpNNS41MjQ4IDguMTczOTRMOS4wNzQ4IDQuNjIzOTRDOS4yNzQ4IDQuNDIzOTQgOS41MDgxNCA0LjMyODEgOS43NzQ4IDQuMzM2NDRDMTAuMDQxNSA0LjM0NDc3IDEwLjI3NDggNC40NDg5NCAxMC40NzQ4IDQuNjQ4OTRDMTAuNjU4MSA0Ljg0ODk0IDEwLjc0OTggNS4wODIyNyAxMC43NDk4IDUuMzQ4OTRDMTAuNzQ5OCA1LjYxNTYgMTAuNjU4MSA1Ljg0ODk0IDEwLjQ3NDggNi4wNDg5NEw2LjI0OTggMTAuMjk4OUM2LjA0OTggMTAuNDk4OSA1LjgxNjQ3IDEwLjU5ODkgNS41NDk4IDEwLjU5ODlDNS4yODMxNCAxMC41OTg5IDUuMDQ5OCAxMC40OTg5IDQuODQ5OCAxMC4yOTg5TDIuNjk5OCA4LjE0ODk0QzIuNTE2NDcgNy45NjU2IDIuNDI0OCA3LjczMjI3IDIuNDI0OCA3LjQ0ODk0QzIuNDI0OCA3LjE2NTYgMi41MTY0NyA2LjkzMjI3IDIuNjk5OCA2Ljc0ODk0QzIuODgzMTQgNi41NjU2IDMuMTE2NDcgNi40NzM5NCAzLjM5OTggNi40NzM5NEMzLjY4MzE0IDYuNDczOTQgMy45MTY0NyA2LjU2NTYgNC4wOTk4IDYuNzQ4OTRMNS41MjQ4IDguMTczOTRaTTEzLjk5OTggMTYuOTk4OUMxMy43MTY1IDE2Ljk5ODkgMTMuNDc5IDE2LjkwMzEgMTMuMjg3MyAxNi43MTE0QzEzLjA5NTYgMTYuNTE5OCAxMi45OTk4IDE2LjI4MjMgMTIuOTk5OCAxNS45OTg5QzEyLjk5OTggMTUuNzE1NiAxMy4wOTU2IDE1LjQ3ODEgMTMuMjg3MyAxNS4yODY0QzEzLjQ3OSAxNS4wOTQ4IDEzLjcxNjUgMTQuOTk4OSAxMy45OTk4IDE0Ljk5ODlIMjAuOTk5OEMyMS4yODMxIDE0Ljk5ODkgMjEuNTIwNiAxNS4wOTQ4IDIxLjcxMjMgMTUuMjg2NEMyMS45MDQgMTUuNDc4MSAyMS45OTk4IDE1LjcxNTYgMjEuOTk5OCAxNS45OTg5QzIxLjk5OTggMTYuMjgyMyAyMS45MDQgMTYuNTE5OCAyMS43MTIzIDE2LjcxMTRDMjEuNTIwNiAxNi45MDMxIDIxLjI4MzEgMTYuOTk4OSAyMC45OTk4IDE2Ljk5ODlIMTMuOTk5OFpNMTMuOTk5OCA4Ljk5ODk0QzEzLjcxNjUgOC45OTg5NCAxMy40NzkgOC45MDMxIDEzLjI4NzMgOC43MTE0NEMxMy4wOTU2IDguNTE5NzcgMTIuOTk5OCA4LjI4MjI3IDEyLjk5OTggNy45OTg5NEMxMi45OTk4IDcuNzE1NiAxMy4wOTU2IDcuNDc4MSAxMy4yODczIDcuMjg2NDRDMTMuNDc5IDcuMDk0NzcgMTMuNzE2NSA2Ljk5ODk0IDEzLjk5OTggNi45OTg5NEgyMC45OTk4QzIxLjI4MzEgNi45OTg5NCAyMS41MjA2IDcuMDk0NzcgMjEuNzEyMyA3LjI4NjQ0QzIxLjkwNCA3LjQ3ODEgMjEuOTk5OCA3LjcxNTYgMjEuOTk5OCA3Ljk5ODk0QzIxLjk5OTggOC4yODIyNyAyMS45MDQgOC41MTk3NyAyMS43MTIzIDguNzExNDRDMjEuNTIwNiA4LjkwMzEgMjEuMjgzMSA4Ljk5ODk0IDIwLjk5OTggOC45OTg5NEgxMy45OTk4WiIgZmlsbD0iI0JEQ0ZEQiIvPgo8L2c+CjwvZz4KPC9zdmc+Cg==', + 'like-dislike': + 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjQiIGhlaWdodD0iMjQiIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPGcgaWQ9InBvbGwtaWNvbnMiPgo8bWFzayBpZD0ibWFzazBfOTYzN181Nzk5NSIgc3R5bGU9Im1hc2stdHlwZTphbHBoYSIgbWFza1VuaXRzPSJ1c2VyU3BhY2VPblVzZSIgeD0iMCIgeT0iMCIgd2lkdGg9IjI0IiBoZWlnaHQ9IjI0Ij4KPHJlY3QgaWQ9IkJvdW5kaW5nIGJveCIgd2lkdGg9IjI0IiBoZWlnaHQ9IjI0IiBmaWxsPSIjRkY0MTREIi8+CjwvbWFzaz4KPGcgbWFzaz0idXJsKCNtYXNrMF85NjM3XzU3OTk1KSI+CjxwYXRoIGlkPSJ0aHVtYnNfdXBfZG93biIgZD0iTTAuNTg3NSAxMy40MTQxQzAuOTc5MTY3IDEzLjgwNTcgMS40NSAxNC4wMDE2IDIgMTQuMDAxNkg4LjI1QzguNTUgMTQuMDAxNiA4LjgyOTE3IDEzLjkyMjQgOS4wODc1IDEzLjc2NDFDOS4zNDU4MyAxMy42MDU3IDkuNTMzMzMgMTMuMzg0OSA5LjY1IDEzLjEwMTZMMTEuOSA3LjgwMTU2QzExLjkzMzMgNy43MTgyMyAxMS45NTgzIDcuNjMwNzMgMTEuOTc1IDcuNTM5MDZDMTEuOTkxNyA3LjQ0NzQgMTIgNy4zNTE1NiAxMiA3LjI1MTU2VjYuMDAxNTZDMTIgNS43MTgyMyAxMS45MDQyIDUuNDgwNzMgMTEuNzEyNSA1LjI4OTA2QzExLjUyMDggNS4wOTc0IDExLjI4MzMgNS4wMDE1NiAxMSA1LjAwMTU2SDUuOEw3LjEyNSAyLjEyNjU2QzcuMjA4MzMgMS42MDk5IDcuMDkxNjcgMS4xODQ5IDYuNzc1IDAuODUxNTYyQzYuNDU4MzMgMC41MTgyMjkgNi4wODMzMyAwLjM1MTU2MiA1LjY1IDAuMzUxNTYyQzUuNDY2NjcgMC4zNTE1NjIgNS4yODMzMyAwLjM4OTA2MiA1LjEgMC40NjQwNjJDNC45MTY2NyAwLjUzOTA2MiA0Ljc1IDAuNjUxNTYzIDQuNiAwLjgwMTU2M0wwLjQ1IDQuOTUxNTZDMC4zMTY2NjcgNS4wODQ5IDAuMjA4MzMzIDUuMjQzMjMgMC4xMjUgNS40MjY1NkMwLjA0MTY2NjcgNS42MDk5IDAgNS44MDE1NiAwIDYuMDAxNTZWMTIuMDAxNkMwIDEyLjU1MTYgMC4xOTU4MzMgMTMuMDIyNCAwLjU4NzUgMTMuNDE0MVoiIGZpbGw9IiNCRENGREIiLz4KPHBhdGggaWQ9InRodW1ic191cF9kb3duXzIiIGQ9Ik0xMyAxOUMxMi43MTY3IDE5IDEyLjQ3OTIgMTguOTA0MiAxMi4yODc1IDE4LjcxMjVDMTIuMDk1OCAxOC41MjA4IDEyIDE4LjI4MzMgMTIgMThWMTYuNzVDMTIgMTYuNjUgMTIuMDA4MyAxNi41NTQyIDEyLjAyNSAxNi40NjI1QzEyLjA0MTcgMTYuMzcwOCAxMi4wNjY3IDE2LjI4MzMgMTIuMSAxNi4yTDE0LjM1IDEwLjlDMTQuNDgzMyAxMC42MTY3IDE0LjY3NSAxMC4zOTU4IDE0LjkyNSAxMC4yMzc1QzE1LjE3NSAxMC4wNzkyIDE1LjQ1IDEwIDE1Ljc1IDEwSDIyQzIyLjU1IDEwIDIzLjAyMDggMTAuMTk1OCAyMy40MTI1IDEwLjU4NzVDMjMuODA0MiAxMC45NzkyIDI0IDExLjQ1IDI0IDEyVjE4QzI0IDE4LjIgMjMuOTYyNSAxOC4zODc1IDIzLjg4NzUgMTguNTYyNUMyMy44MTI1IDE4LjczNzUgMjMuNyAxOC45IDIzLjU1IDE5LjA1TDE5LjQgMjMuMkMxOS4yNSAyMy4zNSAxOS4wODMzIDIzLjQ2MjUgMTguOSAyMy41Mzc1QzE4LjcxNjcgMjMuNjEyNSAxOC41MzMzIDIzLjY1IDE4LjM1IDIzLjY1QzE3LjkxNjcgMjMuNjUgMTcuNTQxNyAyMy40ODMzIDE3LjIyNSAyMy4xNUMxNi45MDgzIDIyLjgxNjcgMTYuNzkxNyAyMi4zOTE3IDE2Ljg3NSAyMS44NzVMMTguMiAxOUgxM1oiIGZpbGw9IiNCRENGREIiLz4KPC9nPgo8L2c+Cjwvc3ZnPgo=', + question: + 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjQiIGhlaWdodD0iMjQiIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPGcgaWQ9InBvbGwtaWNvbnMiPgo8bWFzayBpZD0ibWFzazBfOTYzN182NjYzMCIgc3R5bGU9Im1hc2stdHlwZTphbHBoYSIgbWFza1VuaXRzPSJ1c2VyU3BhY2VPblVzZSIgeD0iMCIgeT0iMCIgd2lkdGg9IjI0IiBoZWlnaHQ9IjI0Ij4KPHJlY3QgaWQ9IkJvdW5kaW5nIGJveCIgd2lkdGg9IjI0IiBoZWlnaHQ9IjI0IiBmaWxsPSIjRkY0MTREIi8+CjwvbWFzaz4KPGcgbWFzaz0idXJsKCNtYXNrMF85NjM3XzY2NjMwKSI+CjxwYXRoIGlkPSJWZWN0b3IiIGQ9Ik02LjI1IDEzLjg3NUgxMy43NVYxMi4zNzVINi4yNVYxMy44NzVaTTYuMjUgMTAuODc1SDE3Ljc1VjkuMzc1SDYuMjVWMTAuODc1Wk02LjI1IDcuODc1SDE3Ljc1VjYuMzc1SDYuMjVWNy44NzVaTTQuMzA3NzUgMTcuNzVDMy44MTA1OCAxNy43NSAzLjM4NSAxNy41NzMgMy4wMzEgMTcuMjE5QzIuNjc3IDE2Ljg2NSAyLjUgMTYuNDM5NCAyLjUgMTUuOTQyM1Y0LjMwNzc1QzIuNSAzLjgxMDU4IDIuNjc3IDMuMzg1IDMuMDMxIDMuMDMxQzMuMzg1IDIuNjc3IDMuODEwNTggMi41IDQuMzA3NzUgMi41SDE5LjY5MjNDMjAuMTg5NCAyLjUgMjAuNjE1IDIuNjc3IDIwLjk2OSAzLjAzMUMyMS4zMjMgMy4zODUgMjEuNSAzLjgxMDU4IDIxLjUgNC4zMDc3NVYxNS45NDIzQzIxLjUgMTYuNDM5NCAyMS4zMjMgMTYuODY1IDIwLjk2OSAxNy4yMTlDMjAuNjE1IDE3LjU3MyAyMC4xODk0IDE3Ljc1IDE5LjY5MjMgMTcuNzVIMTQuNDgyN0wxMi43NDggMjAuMzU1N0MxMi42NTczIDIwLjQ5MjkgMTIuNTQ3NyAyMC41OTU4IDEyLjQxOTIgMjAuNjY0NUMxMi4yOTA5IDIwLjczMyAxMi4xNTEyIDIwLjc2NzMgMTIgMjAuNzY3M0MxMS44NDg4IDIwLjc2NzMgMTEuNzA5MSAyMC43MzMgMTEuNTgwOCAyMC42NjQ1QzExLjQ1MjMgMjAuNTk1OCAxMS4zNDI3IDIwLjQ5MjkgMTEuMjUyIDIwLjM1NTdMOS41MTcyNSAxNy43NUg0LjMwNzc1WiIgZmlsbD0iI0JEQ0ZEQiIvPgo8L2c+CjwvZz4KPC9zdmc+Cg==', + 'bar-chart': + 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMzIiIGhlaWdodD0iMzIiIHZpZXdCb3g9IjAgMCAzMiAzMiIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggaWQ9ImJhcl9jaGFydCIgZD0iTTI2LjQ2MTMgMzJDMjUuOTE1MiAzMiAyNS40NTcyIDMxLjgxNTEgMjUuMDg3NSAzMS40NDUzQzI0LjcxOCAzMS4wNzU5IDI0LjUzMzMgMzAuNjE4IDI0LjUzMzMgMzAuMDcxNVYyMS4zMzMzQzI0LjUzMzMgMjAuNzg3MiAyNC43MTggMjAuMzI5MiAyNS4wODc1IDE5Ljk1OTVDMjUuNDU3MiAxOS41OSAyNS45MTUyIDE5LjQwNTMgMjYuNDYxMyAxOS40MDUzSDMwLjA3MTVDMzAuNjE4IDE5LjQwNTMgMzEuMDc1OSAxOS41OSAzMS40NDUzIDE5Ljk1OTVDMzEuODE1MSAyMC4zMjkyIDMyIDIwLjc4NzIgMzIgMjEuMzMzM1YzMC4wNzE1QzMyIDMwLjYxOCAzMS44MTUxIDMxLjA3NTkgMzEuNDQ1MyAzMS40NDUzQzMxLjA3NTkgMzEuODE1MSAzMC42MTggMzIgMzAuMDcxNSAzMkgyNi40NjEzWk0xNC4xOTQ3IDMyQzEzLjY0ODUgMzIgMTMuMTkwNiAzMS44MTUxIDEyLjgyMDggMzEuNDQ1M0MxMi40NTE0IDMxLjA3NTkgMTIuMjY2NyAzMC42MTggMTIuMjY2NyAzMC4wNzE1VjEuOTI4NTNDMTIuMjY2NyAxLjM4MjA0IDEyLjQ1MTQgMC45MjQwOSAxMi44MjA4IDAuNTU0NjY4QzEzLjE5MDYgMC4xODQ4OSAxMy42NDg1IDAgMTQuMTk0NyAwSDE3LjgwNTNDMTguMzUxNSAwIDE4LjgwOTQgMC4xODQ4OSAxOS4xNzkyIDAuNTU0NjY4QzE5LjU0ODYgMC45MjQwOSAxOS43MzMzIDEuMzgyMDQgMTkuNzMzMyAxLjkyODUzVjMwLjA3MTVDMTkuNzMzMyAzMC42MTggMTkuNTQ4NiAzMS4wNzU5IDE5LjE3OTIgMzEuNDQ1M0MxOC44MDk0IDMxLjgxNTEgMTguMzUxNSAzMiAxNy44MDUzIDMySDE0LjE5NDdaTTEuOTI4NTMgMzJDMS4zODIwNCAzMiAwLjkyNDA4OSAzMS44MTUxIDAuNTU0NjY2IDMxLjQ0NTNDMC4xODQ4ODkgMzEuMDc1OSAwIDMwLjYxOCAwIDMwLjA3MTVWMTIuMzk5NUMwIDExLjg0MzQgMC4xODQ4ODkgMTEuMzgxMyAwLjU1NDY2NiAxMS4wMTMzQzAuOTI0MDg5IDEwLjY0NTcgMS4zODIwNCAxMC40NjE5IDEuOTI4NTMgMTAuNDYxOUg1LjUzODY3QzYuMDg0OCAxMC40NjE5IDYuNTQyNzYgMTAuNjQ2NiA2LjkxMjUzIDExLjAxNkM3LjI4MTk2IDExLjM4NTQgNy40NjY2NyAxMS44NDM0IDcuNDY2NjcgMTIuMzg5OVYzMC4wNjE5QzcuNDY2NjcgMzAuNjE4MyA3LjI4MTk2IDMxLjA4MDQgNi45MTI1MyAzMS40NDhDNi41NDI3NiAzMS44MTYgNi4wODQ4IDMyIDUuNTM4NjcgMzJIMS45Mjg1M1oiIGZpbGw9IiMxRDFEMUQiLz4KPC9zdmc+Cg==', + anonymous: + 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjAiIGhlaWdodD0iMjAiIHZpZXdCb3g9IjAgMCAyMCAyMCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPGcgaWQ9Ikljb24mIzYwO01lZGl1bSYjNjI7L0Fub255bW91cyI+CjxtYXNrIGlkPSJtYXNrMF85NjM3XzUzMzM5IiBzdHlsZT0ibWFzay10eXBlOmFscGhhIiBtYXNrVW5pdHM9InVzZXJTcGFjZU9uVXNlIiB4PSIwIiB5PSIwIiB3aWR0aD0iMjAiIGhlaWdodD0iMjAiPgo8cmVjdCBpZD0iQm91bmRpbmcgYm94IiB3aWR0aD0iMjAiIGhlaWdodD0iMjAiIGZpbGw9IiNEOUQ5RDkiLz4KPC9tYXNrPgo8ZyBtYXNrPSJ1cmwoI21hc2swXzk2MzdfNTMzMzkpIj4KPHBhdGggaWQ9InZpc2liaWxpdHlfbG9jayIgZD0iTTEzLjgzMTcgMTYuNTkyNkMxMy43MTI4IDE2LjU5MjYgMTMuNjEzMSAxNi41NTIzIDEzLjUzMjYgMTYuNDcxOUMxMy40NTIxIDE2LjM5MTQgMTMuNDExOSAxNi4yOTE2IDEzLjQxMTkgMTYuMTcyOFYxMy4yOTNDMTMuNDExOSAxMy4xNzU1IDEzLjQ1NzggMTMuMDczOSAxMy41NDk2IDEyLjk4ODRDMTMuNjQxNiAxMi45MDMgMTMuNzQ5IDEyLjg2NzIgMTMuODcxOSAxMi44ODExSDE0LjM3MTlWMTEuODgxMUMxNC4zNzE5IDExLjUwMTYgMTQuNTA3NiAxMS4xNzY5IDE0Ljc3ODggMTAuOTA2N0MxNS4wNTAxIDEwLjYzNjQgMTUuMzc2MiAxMC41MDEzIDE1Ljc1NzEgMTAuNTAxM0MxNi4xMzgxIDEwLjUwMTMgMTYuNDYxIDEwLjYzNjQgMTYuNzI1OSAxMC45MDY3QzE2Ljk5MDkgMTEuMTc2OSAxNy4xMjM0IDExLjUwMTYgMTcuMTIzNCAxMS44ODExVjEyLjg4MTFIMTcuNjIzNEMxNy43NDkxIDEyLjg4MTEgMTcuODU3MSAxMi45MTk2IDE3Ljk0NzYgMTIuOTk2N0MxOC4wMzgxIDEzLjA3MzkgMTguMDgzNCAxMy4xNzI3IDE4LjA4MzQgMTMuMjkzVjE2LjE3MjhDMTguMDgzNCAxNi4yOTE2IDE4LjA0MzEgMTYuMzkxNCAxNy45NjI2IDE2LjQ3MTlDMTcuODgyMSAxNi41NTIzIDE3Ljc4MjQgMTYuNTkyNiAxNy42NjM0IDE2LjU5MjZIMTMuODMxN1pNMTUuMDI1NyAxMi44ODExSDE2LjQ2OTZWMTEuODgxMUMxNi40Njk2IDExLjY2ODYgMTYuNDAzNSAxMS40OTQ1IDE2LjI3MTMgMTEuMzU4OEMxNi4xMzkxIDExLjIyMyAxNS45Njc0IDExLjE1NTEgMTUuNzU2MSAxMS4xNTUxQzE1LjU0NDkgMTEuMTU1MSAxNS4zNzAzIDExLjIyMyAxNS4yMzI0IDExLjM1ODhDMTUuMDk0NiAxMS40OTQ1IDE1LjAyNTcgMTEuNjY4NiAxNS4wMjU3IDExLjg4MTFWMTIuODgxMVpNMTAuMDAwMSAxMi4wMDEzQzkuNDQ0NTEgMTIuMDAxMyA4Ljk3MjI4IDExLjgwNjkgOC41ODM0IDExLjQxOEM4LjE5NDUxIDExLjAyOTEgOC4wMDAwNiAxMC41NTY5IDguMDAwMDYgMTAuMDAxM0M4LjAwMDA2IDkuNDQ1NzUgOC4xOTQ1MSA4Ljk3MzUyIDguNTgzNCA4LjU4NDY0QzguOTcyMjggOC4xOTU3NSA5LjQ0NDUxIDguMDAxMyAxMC4wMDAxIDguMDAxM0MxMC41NTU2IDguMDAxMyAxMS4wMjc4IDguMTk1NzUgMTEuNDE2NyA4LjU4NDY0QzExLjgwNTYgOC45NzM1MiAxMi4wMDAxIDkuNDQ1NzUgMTIuMDAwMSAxMC4wMDEzQzEyLjAwMDEgMTAuNTU2OSAxMS44MDU2IDExLjAyOTEgMTEuNDE2NyAxMS40MThDMTEuMDI3OCAxMS44MDY5IDEwLjU1NTYgMTIuMDAxMyAxMC4wMDAxIDEyLjAwMTNaTTEwLjAwMDEgMTUuNTg0NkM4LjI4NjA0IDE1LjU4NDYgNi42ODM5NSAxNS4xMzg5IDUuMTkzODEgMTQuMjQ3M0MzLjcwMzUzIDEzLjM1NTcgMi41NjMxMiAxMi4xNDU2IDEuNzcyNTYgMTAuNjE3MUMxLjcxODEyIDEwLjUxOTggMS42NzgzMyAxMC40MjA5IDEuNjUzMTkgMTAuMzIwNUMxLjYyODA1IDEwLjIyMDEgMS42MTU0OCAxMC4xMTM3IDEuNjE1NDggMTAuMDAxM0MxLjYxNTQ4IDkuODg4OTQgMS42MjgwNSA5Ljc4MjU1IDEuNjUzMTkgOS42ODIxNEMxLjY3ODMzIDkuNTgxNzIgMS43MTgxMiA5LjQ4MjgzIDEuNzcyNTYgOS4zODU0N0MyLjU2MzEyIDcuODU3IDMuNzAzNTMgNi42NDY5MyA1LjE5MzgxIDUuNzU1MjZDNi42ODM5NSA0Ljg2MzczIDguMjg2MDQgNC40MTc5NyAxMC4wMDAxIDQuNDE3OTdDMTAuODg3NiA0LjQxNzk3IDExLjczOTUgNC41MzcxMyAxMi41NTU5IDQuNzc1NDdDMTMuMzcyMyA1LjAxMzY2IDE0LjEzNDEgNS4zNTAxOSAxNC44NDEzIDUuNzg1MDVDMTUuMzk1NSA2LjEyMTcyIDE1LjkwNzcgNi41MDk1NyAxNi4zNzggNi45NDg1OUMxNi44NDgzIDcuMzg3NjIgMTcuMjcyNSA3Ljg3NzQxIDE3LjY1MDcgOC40MTc5N0MxNy43OTI4IDguNjI3NDEgMTcuOCA4Ljg0NjQ0IDE3LjY3MjQgOS4wNzUwNUMxNy41NDQ3IDkuMzAzNjYgMTcuMzUzMiA5LjQxNzk3IDE3LjA5NzggOS40MTc5N0gxNS43NTE3QzE1LjIzODcgOS40MTc5NyAxNC43NjE5IDkuNDg2MzcgMTQuMzIxMyA5LjYyMzE4QzEzLjg4MDYgOS43NTk4NCAxMy40ODQgOS45NjQ5OCAxMy4xMzE1IDEwLjIzODZDMTMuMTM2OCAxMC4xOTQ3IDEzLjE0MDggMTAuMTUzNyAxMy4xNDM0IDEwLjExNTdDMTMuMTQ2MiAxMC4wNzc2IDEzLjE0NzYgMTAuMDM5NSAxMy4xNDc2IDEwLjAwMTNDMTMuMTQ3NiA5LjEyNyAxMi44NDE0IDguMzgzODcgMTIuMjI5IDcuNzcxOTNDMTEuNjE2NyA3LjE1OTg0IDEwLjg3MyA2Ljg1MzggOS45OTgxOSA2Ljg1MzhDOS4xMjMzMyA2Ljg1MzggOC4zODAzNCA3LjE1OTk4IDcuNzY5MjMgNy43NzIzNEM3LjE1ODEyIDguMzg0NzEgNi44NTI1NiA5LjEyODMyIDYuODUyNTYgMTAuMDAzMkM2Ljg1MjU2IDEwLjg3OCA3LjE1ODYgMTEuNjIxIDcuNzcwNjkgMTIuMjMyMUM4LjM4MjYzIDEyLjg0MzIgOS4xMjU3NiAxMy4xNDg4IDEwLjAwMDEgMTMuMTQ4OEMxMC4zNjEyIDEzLjE0ODggMTAuNzEgMTMuMDkwNSAxMS4wNDY1IDEyLjk3NEMxMS4zODMgMTIuODU3NiAxMS42ODQ5IDEyLjY5NjkgMTEuOTUxOSAxMi40OTE3QzExLjk0MTIgMTIuNTIzNyAxMS45MzE5IDEyLjU3MTggMTEuOTI0IDEyLjYzNjFDMTEuOTE2IDEyLjcwMDMgMTEuOTExOSAxMi43NjU5IDExLjkxMTkgMTIuODMzVjE0Ljc5MjZDMTEuOTExOSAxNC45NzAzIDExLjg1NjkgMTUuMTI3MiAxMS43NDY3IDE1LjI2MzJDMTEuNjM2NiAxNS4zOTkzIDExLjQ5MzUgMTUuNDc4NyAxMS4zMTc0IDE1LjUwMTNDMTEuMTAyNSAxNS41MjkxIDEwLjg4NzYgMTUuNTQ5OSAxMC42NzI2IDE1LjU2MzhDMTAuNDU3NyAxNS41Nzc3IDEwLjIzMzUgMTUuNTg0NiAxMC4wMDAxIDE1LjU4NDZaIiBmaWxsPSJ3aGl0ZSIvPgo8L2c+CjwvZz4KPC9zdmc+Cg==', + 'stop-watch': + 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjAiIGhlaWdodD0iMjAiIHZpZXdCb3g9IjAgMCAyMCAyMCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPGcgaWQ9Ikljb24mIzYwO01lZGl1bSYjNjI7L1N0b3BfV2F0Y2giPgo8bWFzayBpZD0ibWFzazBfOTYzN181MzMwNiIgc3R5bGU9Im1hc2stdHlwZTphbHBoYSIgbWFza1VuaXRzPSJ1c2VyU3BhY2VPblVzZSIgeD0iMCIgeT0iMCIgd2lkdGg9IjIwIiBoZWlnaHQ9IjIwIj4KPHJlY3QgaWQ9IkJvdW5kaW5nIGJveCIgd2lkdGg9IjIwIiBoZWlnaHQ9IjIwIiBmaWxsPSIjRDlEOUQ5Ii8+CjwvbWFzaz4KPGcgbWFzaz0idXJsKCNtYXNrMF85NjM3XzUzMzA2KSI+CjxwYXRoIGlkPSJhbGFybV9vbiIgZD0iTTkuMTQ1NzkgMTEuNTk1M0w3Ljg4OTMzIDEwLjMzODlDNy43NTQwNiAxMC4yMDExIDcuNTk1NzkgMTAuMTMwOCA3LjQxNDU0IDEwLjEyOEM3LjIzMzI5IDEwLjEyNTQgNy4wNzEwNyAxMC4xOTM1IDYuOTI3ODcgMTAuMzMyNEM2Ljc5ODU3IDEwLjQ3MTQgNi43MzM5MiAxMC42MzE0IDYuNzMzOTIgMTAuODEyNEM2LjczMzkyIDEwLjk5MzQgNi43OTg1NyAxMS4xNTM0IDYuOTI3ODcgMTEuMjkyNEw4LjY4MSAxMy4wNTg0QzguODE2NTYgMTMuMTk1MSA4Ljk3NDYxIDEzLjI2MzQgOS4xNTUxNyAxMy4yNjM0QzkuMzM1ODYgMTMuMjYzNCA5LjQ5NDYxIDEzLjE5NTEgOS42MzE0MiAxMy4wNTg0TDEzLjEwNTggOS41ODQwNkMxMy4yNDM2IDkuNDQ4OTIgMTMuMzEzOCA5LjI4ODUxIDEzLjMxNjQgOS4xMDI4MUMxMy4zMTkxIDguOTE3MjYgMTMuMjQ5OSA4Ljc1Mjg4IDEzLjEwODkgOC42MDk2OUMxMi45Njc5IDguNDY2NDkgMTIuODA1NiA4LjM5NDkgMTIuNjIxOCA4LjM5NDlDMTIuNDM4MSA4LjM5NDkgMTIuMjc0NiA4LjQ2NjQ5IDEyLjEzMTQgOC42MDk2OUw5LjE0NTc5IDExLjU5NTNaTTkuOTk5OTYgMTcuNTgyNEM5LjA4NjQ5IDE3LjU4MjQgOC4yMzA1OCAxNy40MDk3IDcuNDMyMjUgMTcuMDY0M0M2LjYzMzkyIDE2LjcxODkgNS45Mzc4MSAxNi4yNDkxIDUuMzQzOTIgMTUuNjU1MUM0Ljc0OTg5IDE1LjA2MTIgNC4yODAxNyAxNC4zNjUxIDMuOTM0NzUgMTMuNTY2OEMzLjU4OTMzIDEyLjc2ODQgMy40MTY2MiAxMS45MTI1IDMuNDE2NjIgMTAuOTk5MUMzLjQxNjYyIDEwLjA4NTYgMy41ODkzMyA5LjIyOTY5IDMuOTM0NzUgOC40MzEzNUM0LjI4MDE3IDcuNjMzMDIgNC43NDk4OSA2LjkzNjkxIDUuMzQzOTIgNi4zNDMwMkM1LjkzNzgxIDUuNzQ4OTkgNi42MzM5MiA1LjI3OTI3IDcuNDMyMjUgNC45MzM4NUM4LjIzMDU4IDQuNTg4NDQgOS4wODY0OSA0LjQxNTczIDkuOTk5OTYgNC40MTU3M0MxMC45MTM0IDQuNDE1NzMgMTEuNzY5MyA0LjU4ODQ0IDEyLjU2NzcgNC45MzM4NUMxMy4zNjYgNS4yNzkyNyAxNC4wNjIxIDUuNzQ4OTkgMTQuNjU2IDYuMzQzMDJDMTUuMjUgNi45MzY5MSAxNS43MTk3IDcuNjMzMDIgMTYuMDY1MiA4LjQzMTM1QzE2LjQxMDYgOS4yMjk2OSAxNi41ODMzIDEwLjA4NTYgMTYuNTgzMyAxMC45OTkxQzE2LjU4MzMgMTEuOTEyNSAxNi40MTA2IDEyLjc2ODQgMTYuMDY1MiAxMy41NjY4QzE1LjcxOTcgMTQuMzY1MSAxNS4yNSAxNS4wNjEyIDE0LjY1NiAxNS42NTUxQzE0LjA2MjEgMTYuMjQ5MSAxMy4zNjYgMTYuNzE4OSAxMi41Njc3IDE3LjA2NDNDMTEuNzY5MyAxNy40MDk3IDEwLjkxMzQgMTcuNTgyNCA5Ljk5OTk2IDE3LjU4MjRaTTIuMjQyMDQgNi40NDEzNUMyLjEyMTIxIDYuMzIwNjYgMi4wNjA3OSA2LjE5MTYzIDIuMDYwNzkgNi4wNTQyN0MyLjA2MDc5IDUuOTE3MDUgMi4xMjEyMSA1Ljc4ODA5IDIuMjQyMDQgNS42Njc0TDQuNjg5MTIgMy4yMjAzMUM0LjgwNDU0IDMuMTA0OSA0LjkzMjE4IDMuMDQ1ODcgNS4wNzIwNCAzLjA0MzIzQzUuMjEyMDQgMy4wNDA0NSA1LjM0MjM5IDMuMDk5NDggNS40NjMwOCAzLjIyMDMxQzUuNTgzNzggMy4zNDEwMSA1LjY0NDEyIDMuNDY5OTcgNS42NDQxMiAzLjYwNzE5QzUuNjQ0MTIgMy43NDQ1NSA1LjU4Mzc4IDMuODczNTggNS40NjMwOCAzLjk5NDI3TDIuOTk1MTcgNi40NjIxOUMyLjg3OTc1IDYuNTc3NiAyLjc1NTU4IDYuNjMzMTYgMi42MjI2NyA2LjYyODg1QzIuNDg5NjEgNi42MjQ1NSAyLjM2Mjc0IDYuNTYyMDUgMi4yNDIwNCA2LjQ0MTM1Wk0xNy43Nzg3IDYuNDQxMzVDMTcuNjU4IDYuNTYyMDUgMTcuNTI5MSA2LjYyMjQgMTcuMzkxOCA2LjYyMjRDMTcuMjU0NSA2LjYyMjQgMTcuMTI1NCA2LjU2MjA1IDE3LjAwNDcgNi40NDEzNUwxNC41NTc3IDMuOTk0MjdDMTQuNDQyMyAzLjg3ODg1IDE0LjM4MzIgMy43NTEyMiAxNC4zODA2IDMuNjExMzVDMTQuMzc3OSAzLjQ3MTM1IDE0LjQzNyAzLjM0MTAxIDE0LjU1NzcgMy4yMjAzMUMxNC42Nzg0IDMuMDk5NDggMTQuODA3NCAzLjAzOTA2IDE0Ljk0NDcgMy4wMzkwNkMxNS4wODIgMy4wMzkwNiAxNS4yMTA5IDMuMDk5NDggMTUuMzMxNiAzLjIyMDMxTDE3Ljc3ODcgNS42ODgyM0MxNy44OTQxIDUuODAzNjUgMTcuOTUzMiA1LjkyNzgxIDE3Ljk1NTggNi4wNjA3M0MxNy45NTg2IDYuMTkzNzkgMTcuODk5NSA2LjMyMDY2IDE3Ljc3ODcgNi40NDEzNVoiIGZpbGw9IndoaXRlIi8+CjwvZz4KPC9nPgo8L3N2Zz4K', + group: + 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjAiIGhlaWdodD0iMjAiIHZpZXdCb3g9IjAgMCAyMCAyMCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPGcgaWQ9Ikljb24mIzYwO01lZGl1bSYjNjI7L0F0dGVuZGVlIj4KPG1hc2sgaWQ9Im1hc2swXzk2MzdfNTMzMTciIHN0eWxlPSJtYXNrLXR5cGU6YWxwaGEiIG1hc2tVbml0cz0idXNlclNwYWNlT25Vc2UiIHg9IjAiIHk9IjAiIHdpZHRoPSIyMCIgaGVpZ2h0PSIyMCI+CjxyZWN0IGlkPSJCb3VuZGluZyBib3giIHdpZHRoPSIyMCIgaGVpZ2h0PSIyMCIgZmlsbD0iI0Q5RDlEOSIvPgo8L21hc2s+CjxnIG1hc2s9InVybCgjbWFzazBfOTYzN181MzMxNykiPgo8cGF0aCBpZD0iZ3JvdXBzIiBkPSJNMi4wNTQ2NiAxNC4zMjM4QzEuODc4MTQgMTQuMzIzOCAxLjcyNzY1IDE0LjI2MTYgMS42MDMyMSAxNC4xMzczQzEuNDc4OSAxNC4wMTI5IDEuNDE2NzUgMTMuODYyNCAxLjQxNjc1IDEzLjY4NTlWMTMuNDMyOEMxLjQxNjc1IDEyLjkzMzkgMS42NzE1NCAxMi41MjcxIDIuMTgxMTIgMTIuMjEyM0MyLjY5MDg1IDExLjg5NzggMy4zNjkzMiAxMS43NDA1IDQuMjE2NTQgMTEuNzQwNUM0LjM4NDE4IDExLjc0MDUgNC41NDU5OCAxMS43NDg3IDQuNzAxOTYgMTEuNzY1M0M0Ljg1NzkzIDExLjc4MTggNS4wMTUwMSAxMS44MDM5IDUuMTczMjEgMTEuODMxN0M1LjAzODYyIDEyLjA0OTggNC45MzU1IDEyLjI4NTMgNC44NjM4MyAxMi41Mzg0QzQuNzkyMyAxMi43OTE2IDQuNzU2NTQgMTMuMDQyNyA0Ljc1NjU0IDEzLjI5MTdWMTQuMzIzOEgyLjA1NDY2Wk02LjU4OTg3IDE0LjMyMzhDNi40MDA3MSAxNC4zMjM4IDYuMjQxMjYgMTQuMjU4NSA2LjExMTU0IDE0LjEyOEM1Ljk4MTY4IDEzLjk5NzYgNS45MTY3NSAxMy44Mzk1IDUuOTE2NzUgMTMuNjUzOFYxMy4zMjM4QzUuOTE2NzUgMTIuOTg5MiA2LjAwNTQzIDEyLjY5MTYgNi4xODI3OSAxMi40MzExQzYuMzYwMTUgMTIuMTcwNCA2LjYzNDE4IDExLjk0MDcgNy4wMDQ4NyAxMS43NDIxQzcuMzc1NTcgMTEuNTQzNCA3LjgxMDcxIDExLjM5NzggOC4zMTAyOSAxMS4zMDU1QzguODA5NzMgMTEuMjEzIDkuMzc1MzYgMTEuMTY2NyAxMC4wMDcyIDExLjE2NjdDMTAuNjM4MSAxMS4xNjY3IDExLjE5OTkgMTEuMjEzIDExLjY5MjQgMTEuMzA1NUMxMi4xODQ5IDExLjM5NzggMTIuNjE2NSAxMS41NDM0IDEyLjk4NzIgMTEuNzQyMUMxMy4zNTggMTEuOTI2OSAxMy42MzM0IDEyLjE1NDMgMTMuODEzNCAxMi40MjQ0QzEzLjk5MzQgMTIuNjk0NiAxNC4wODM0IDEyLjk5NDQgMTQuMDgzNCAxMy4zMjM4VjEzLjY1MzhDMTQuMDgzNCAxMy44Mzk1IDE0LjAxODEgMTMuOTk3NiAxMy44ODc2IDE0LjEyOEMxMy43NTcyIDE0LjI1ODUgMTMuNTk5MSAxNC4zMjM4IDEzLjQxMzQgMTQuMzIzOEg2LjU4OTg3Wk0xNS4yNDM2IDE0LjMyMzhWMTMuMjkyOEMxNS4yNDM2IDEzLjAzNDYgMTUuMjA2NSAxMi43ODQ1IDE1LjEzMjQgMTIuNTQyNkMxNS4wNTgxIDEyLjMwMDYgMTQuOTUzNiAxMi4wNjM3IDE0LjgxOSAxMS44MzE3QzE0Ljk1NDcgMTEuODAzOSAxNS4wOTcxIDExLjc4MTggMTUuMjQ2MSAxMS43NjUzQzE1LjM5NTIgMTEuNzQ4NyAxNS41NzcgMTEuNzQwNSAxNS43OTE3IDExLjc0MDVDMTYuNjM5IDExLjc0MDUgMTcuMzE2MSAxMS44OTkxIDE3LjgyMyAxMi4yMTY1QzE4LjMyOTkgMTIuNTMzNyAxOC41ODM0IDEyLjkzOTEgMTguNTgzNCAxMy40MzI4VjEzLjY4NTlDMTguNTgzNCAxMy44NjI0IDE4LjUyMTMgMTQuMDEyOSAxOC4zOTcgMTQuMTM3M0MxOC4yNzI1IDE0LjI2MTYgMTguMTIyIDE0LjMyMzggMTcuOTQ1NSAxNC4zMjM4SDE1LjI0MzZaTTQuMjE0MjUgMTAuNzY0NEMzLjg2MzE0IDEwLjc2NDQgMy41NjU3OCAxMC42NDIxIDMuMzIyMTYgMTAuMzk3NkMzLjA3ODU1IDEwLjE1MyAyLjk1Njc1IDkuODU1NCAyLjk1Njc1IDkuNTA0ODRDMi45NTY3NSA5LjE2MzA0IDMuMDc5MTEgOC44NjkwMSAzLjMyMzgzIDguNjIyNzZDMy41Njg0MSA4LjM3NjUxIDMuODY1OTggOC4yNTMzOCA0LjIxNjU0IDguMjUzMzhDNC41NTgzNSA4LjI1MzM4IDQuODUzNjkgOC4zNzY1MSA1LjEwMjU4IDguNjIyNzZDNS4zNTE2MSA4Ljg2OTAxIDUuNDc2MTIgOS4xNjM4NyA1LjQ3NjEyIDkuNTA3MzRDNS40NzYxMiA5Ljg1MjkgNS4zNTMwNyAxMC4xNDg4IDUuMTA2OTYgMTAuMzk1MUM0Ljg2MDk4IDEwLjY0MTMgNC41NjM0MSAxMC43NjQ0IDQuMjE0MjUgMTAuNzY0NFpNMTUuNzkxNyAxMC43NjQ0QzE1LjQ0NDUgMTAuNzY0NCAxNS4xNDc4IDEwLjY0MTMgMTQuOTAxNSAxMC4zOTUxQzE0LjY1NTMgMTAuMTQ4OCAxNC41MzIyIDkuODUyOSAxNC41MzIyIDkuNTA3MzRDMTQuNTMyMiA5LjE2Mzg3IDE0LjY1NTMgOC44NjkwMSAxNC45MDE1IDguNjIyNzZDMTUuMTQ3OCA4LjM3NjUxIDE1LjQ0NSA4LjI1MzM4IDE1Ljc5MzIgOC4yNTMzOEMxNi4xMzk1IDguMjUzMzggMTYuNDM1NyA4LjM3NjUxIDE2LjY4MiA4LjYyMjc2QzE2LjkyODIgOC44NjkwMSAxNy4wNTEzIDkuMTYzMDQgMTcuMDUxMyA5LjUwNDg0QzE3LjA1MTMgOS44NTU0IDE2LjkyODYgMTAuMTUzIDE2LjY4MyAxMC4zOTc2QzE2LjQzNzQgMTAuNjQyMSAxNi4xNDA0IDEwLjc2NDQgMTUuNzkxNyAxMC43NjQ0Wk0xMC4wMDMgMTAuMTY2N0M5LjQ3MjE3IDEwLjE2NjcgOS4wMjAwOCA5Ljk4MDU0IDguNjQ2NzUgOS42MDgxOEM4LjI3MzQyIDkuMjM1ODIgOC4wODY3NSA4Ljc4MzczIDguMDg2NzUgOC4yNTE5M0M4LjA4Njc1IDcuNzIzMTggOC4yNzI4NiA3LjI3MjM0IDguNjQ1MDggNi44OTk0M0M5LjAxNzQ0IDYuNTI2MzcgOS40Njk2IDYuMzM5ODQgMTAuMDAxNSA2LjMzOTg0QzEwLjUzMDIgNi4zMzk4NCAxMC45ODEgNi41MjY0NCAxMS4zNTQgNi44OTk2NEMxMS43MjcgNy4yNzI2OSAxMS45MTM0IDcuNzIyOTcgMTEuOTEzNCA4LjI1MDQ3QzExLjkxMzQgOC43ODExNiAxMS43MjY5IDkuMjMzMjUgMTEuMzUzOCA5LjYwNjcyQzEwLjk4MDYgOS45ODAwNSAxMC41MzA0IDEwLjE2NjcgMTAuMDAzIDEwLjE2NjdaIiBmaWxsPSJ3aGl0ZSIvPgo8L2c+CjwvZz4KPC9zdmc+Cg==', + 'co-host': + 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjAiIGhlaWdodD0iMjAiIHZpZXdCb3g9IjAgMCAyMCAyMCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPGcgaWQ9Ikljb24mIzYwO01lZGl1bSYjNjI7L0NvLUhvc3QiPgo8bWFzayBpZD0ibWFzazBfOTYzN181MzMyOCIgc3R5bGU9Im1hc2stdHlwZTphbHBoYSIgbWFza1VuaXRzPSJ1c2VyU3BhY2VPblVzZSIgeD0iMCIgeT0iMCIgd2lkdGg9IjIwIiBoZWlnaHQ9IjIwIj4KPHJlY3QgaWQ9IkJvdW5kaW5nIGJveCIgd2lkdGg9IjIwIiBoZWlnaHQ9IjIwIiBmaWxsPSIjRDlEOUQ5Ii8+CjwvbWFzaz4KPGcgbWFzaz0idXJsKCNtYXNrMF85NjM3XzUzMzI4KSI+CjxwYXRoIGlkPSJncm91cCIgZD0iTTIuNjY1MjggMTMuOTg2N0MyLjY2NTI4IDEzLjY2ODQgMi43NDEzOSAxMy4zODkzIDIuODkzNjIgMTMuMTQ5NEMzLjA0NTg0IDEyLjkwOTYgMy4yNTg1NSAxMi43MDk4IDMuNTMxNzQgMTIuNTUwM0M0LjIxMjU3IDEyLjE0OSA0LjkyOTAzIDExLjgzMyA1LjY4MTEyIDExLjYwMjFDNi40MzMzNCAxMS4zNzE0IDcuMjg5MSAxMS4yNTYxIDguMjQ4NDEgMTEuMjU2MUM5LjIwNzg1IDExLjI1NjEgMTAuMDYzNiAxMS4zNzE0IDEwLjgxNTcgMTEuNjAyMUMxMS41Njc5IDExLjgzMyAxMi4yODQ1IDEyLjE0OSAxMi45NjUzIDEyLjU1MDNDMTMuMjM4NSAxMi43MDk4IDEzLjQ1MTIgMTIuOTA5NiAxMy42MDM0IDEzLjE0OTRDMTMuNzU1NiAxMy4zODkzIDEzLjgzMTcgMTMuNjY4NCAxMy44MzE3IDEzLjk4NjdWMTQuMzIzNEMxMy44MzE3IDE0LjYxNzEgMTMuNzI2IDE0Ljg3MzggMTMuNTE0NSAxNS4wOTM0QzEzLjMwMjkgMTUuMzEzIDEzLjA0MjIgMTUuNDIyOCAxMi43MzI0IDE1LjQyMjhIMy43NjQ0NUMzLjQ1NDczIDE1LjQyMjggMy4xOTQxIDE1LjMxNyAyLjk4MjU3IDE1LjEwNTVDMi43NzEwNSAxNC44OTM4IDIuNjY1MjggMTQuNjMzMSAyLjY2NTI4IDE0LjMyMzRWMTMuOTg2N1pNMTUuMDcyMiAxNS40MjI4QzE1LjE0OTEgMTUuMjQ1NCAxNS4yMTE2IDE1LjA2NjIgMTUuMjU5NyAxNC44ODUxQzE1LjMwNzcgMTQuNzAzOSAxNS4zMzE3IDE0LjUxNjcgMTUuMzMxNyAxNC4zMjM0VjE0LjA1MDlDMTUuMzMxNyAxMy41MzE3IDE1LjIzMjYgMTMuMDU1NSAxNS4wMzQyIDEyLjYyMjNDMTQuODM1OCAxMi4xODkzIDE0LjU3MzggMTEuODM3IDE0LjI0ODQgMTEuNTY1NUMxNC42NDA1IDExLjY3NjYgMTUuMDI4NiAxMS44MTI1IDE1LjQxMjggMTEuOTczMkMxNS43OTY4IDEyLjEzNCAxNi4xNzUzIDEyLjMyNjYgMTYuNTQ4MiAxMi41NTA5QzE2Ljc4NDMgMTIuNjgzNCAxNi45NzQ1IDEyLjg4NDcgMTcuMTE4NiAxMy4xNTQ4QzE3LjI2MjggMTMuNDI0OCAxNy4zMzQ5IDEzLjcyMzUgMTcuMzM0OSAxNC4wNTA5VjE0LjMyMzRDMTcuMzM0OSAxNC42MzMxIDE3LjIyOTEgMTQuODkzOCAxNy4wMTc2IDE1LjEwNTVDMTYuODA2IDE1LjMxNyAxNi41NDU0IDE1LjQyMjggMTYuMjM1NyAxNS40MjI4SDE1LjA3MjJaTTguMjQ4NDEgOS43NDMxOEM3LjUyOTY2IDkuNzQzMTggNi45MTk0NSA5LjQ5MjM0IDYuNDE3NzggOC45OTA2OEM1LjkxNjEyIDguNDg4ODcgNS42NjUyOCA3Ljg3ODU5IDUuNjY1MjggNy4xNTk4NEM1LjY2NTI4IDYuNDQxMDkgNS45MTYxMiA1LjgzMDg5IDYuNDE3NzggNS4zMjkyMkM2LjkxOTQ1IDQuODI3NDEgNy41Mjk2NiA0LjU3NjUxIDguMjQ4NDEgNC41NzY1MUM4Ljk2NzE2IDQuNTc2NTEgOS41Nzc0NCA0LjgyNzQxIDEwLjA3OTIgNS4zMjkyMkMxMC41ODA5IDUuODMwODkgMTAuODMxNyA2LjQ0MTA5IDEwLjgzMTcgNy4xNTk4NEMxMC44MzE3IDcuODc4NTkgMTAuNTgwOSA4LjQ4ODg3IDEwLjA3OTIgOC45OTA2OEM5LjU3NzQ0IDkuNDkyMzQgOC45NjcxNiA5Ljc0MzE4IDguMjQ4NDEgOS43NDMxOFpNMTQuMTEwNSA3LjE1OTg0QzE0LjExMDUgNy44Nzg1OSAxMy44NTk3IDguNDg4ODcgMTMuMzU4IDguOTkwNjhDMTIuODU2MyA5LjQ5MjM0IDEyLjI0NjEgOS43NDMxOCAxMS41Mjc0IDkuNzQzMThDMTEuNDc1IDkuNzQzMTggMTEuNDQ1NiA5Ljc0NjM3IDExLjQzOTIgOS43NTI3NkMxMS40MzI3IDkuNzU5MTUgMTEuNDAzMyA5Ljc1NjUxIDExLjM1MTEgOS43NDQ4NEMxMS42NTE5IDkuMzkxMzcgMTEuODkwNiA4Ljk5ODE4IDEyLjA2NzIgOC41NjUyNkMxMi4yNDM1IDguMTMyMzQgMTIuMzMxNyA3LjY2MzU5IDEyLjMzMTcgNy4xNTkwMUMxMi4zMzE3IDYuNjU0MjkgMTIuMjQxNyA2LjE4NzQxIDEyLjA2MTcgNS43NTgzOUMxMS44ODE3IDUuMzI5NSAxMS42NDQ5IDQuOTM1MDUgMTEuMzUxMSA0LjU3NTA1QzExLjM4NzQgNC41NzM5NCAxMS40MTY3IDQuNTczOTQgMTEuNDM5MiA0LjU3NTA1QzExLjQ2MTYgNC41NzYwMiAxMS40OTEgNC41NzY1MSAxMS41Mjc0IDQuNTc2NTFDMTIuMjQ2MSA0LjU3NjUxIDEyLjg1NjMgNC44Mjc0MSAxMy4zNTggNS4zMjkyMkMxMy44NTk3IDUuODMwODkgMTQuMTEwNSA2LjQ0MTA5IDE0LjExMDUgNy4xNTk4NFoiIGZpbGw9IndoaXRlIi8+CjwvZz4KPC9nPgo8L3N2Zz4K', +}; + +export default pollIcons; +export type {PollIconsInterface}; diff --git a/polling/ui/BaseAccordian.tsx b/polling/ui/BaseAccordian.tsx new file mode 100644 index 0000000..f8f8597 --- /dev/null +++ b/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/polling/ui/BaseButtonWithToggle.tsx b/polling/ui/BaseButtonWithToggle.tsx new file mode 100644 index 0000000..3e5d9ba --- /dev/null +++ b/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/polling/ui/BaseModal.tsx b/polling/ui/BaseModal.tsx index 591f054..9ea9b1f 100644 --- a/polling/ui/BaseModal.tsx +++ b/polling/ui/BaseModal.tsx @@ -1,10 +1,18 @@ -import {Modal, View, StyleSheet, Text} from 'react-native'; +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 { @@ -27,35 +35,61 @@ function BaseModalTitle({title, children}: TitleProps) { interface ContentProps { children: ReactNode; + noPadding?: boolean; } -function BaseModalContent({children}: ContentProps) { - return {children}; +function BaseModalContent({children, noPadding}: ContentProps) { + return ( + + + {children} + + + ); } interface ActionProps { children: ReactNode; + alignRight?: boolean; } -function BaseModalActions({children}: ActionProps) { - return {children}; +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} + {children} @@ -93,16 +127,15 @@ export { }; const style = StyleSheet.create({ - baseModalBackDrop: { + baseModalContainer: { flex: 1, position: 'relative', justifyContent: 'center', alignItems: 'center', - padding: 20, - backgroundColor: - $config.HARD_CODED_BLACK_COLOR + hexadecimalTransparency['60%'], + paddingHorizontal: 20, }, baseModal: { + zIndex: 2, backgroundColor: $config.CARD_LAYER_1_COLOR, borderWidth: 1, borderColor: $config.CARD_LAYER_3_COLOR, @@ -115,20 +148,35 @@ const style = StyleSheet.create({ shadowOpacity: 0.1, shadowRadius: 4, elevation: 5, + minWidth: 340, maxWidth: '90%', - maxHeight: 800, - overflow: 'scroll', + minHeight: 220, + maxHeight: '80%', // Set a maximum height for the modal + overflow: 'hidden', }, - scrollView: { + 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: 32, - paddingVertical: 20, + paddingHorizontal: 20, + paddingVertical: 12, alignItems: 'center', gap: 20, - minHeight: 72, + height: 60, justifyContent: 'space-between', flexDirection: 'row', borderBottomWidth: 1, @@ -143,18 +191,27 @@ const style = StyleSheet.create({ letterSpacing: -0.48, }, content: { - padding: 32, + padding: 20, gap: 20, display: 'flex', - flexDirection: 'column', - // minWidth: 620, + }, + noPadding: { + padding: 0, }, actions: { - height: 72, - paddingHorizontal: 32, - paddingVertical: 12, 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/polling/ui/BaseRadioButton.tsx b/polling/ui/BaseRadioButton.tsx index 42abcb1..4ecad6a 100644 --- a/polling/ui/BaseRadioButton.tsx +++ b/polling/ui/BaseRadioButton.tsx @@ -7,7 +7,12 @@ import { TextStyle, } from 'react-native'; import React from 'react'; -import {hexadecimalTransparency, ThemeConfig} from 'customization-api'; +import { + ThemeConfig, + $config, + ImageIcon, + hexadecimalTransparency, +} from 'customization-api'; interface Props { option: { @@ -17,22 +22,62 @@ interface Props { 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, labelStyle = {}} = props; + const { + option, + checked, + onChange, + disabled = false, + labelStyle = {}, + filledColor = '', + tickColor = '', + ignoreDisabledStyle = false, + } = props; return ( - - !disabled && onChange(option.value)}> - - {checked && } - - {option.label} - - + { + if (disabled) { + return; + } + onChange(option.value); + }}> + + {checked && ( + + + + )} + + {option.label} + ); } @@ -40,7 +85,8 @@ const style = StyleSheet.create({ optionsContainer: { flexDirection: 'row', alignItems: 'center', - marginBottom: 10, + width: '100%', + padding: 12, }, disabledContainer: { opacity: 0.5, @@ -50,7 +96,8 @@ const style = StyleSheet.create({ width: 22, borderRadius: 11, borderWidth: 2, - borderColor: $config.PRIMARY_ACTION_BRAND_COLOR, + borderColor: $config.FONT_COLOR, + display: 'flex', alignItems: 'center', justifyContent: 'center', }, @@ -58,10 +105,13 @@ const style = StyleSheet.create({ borderColor: $config.FONT_COLOR + hexadecimalTransparency['50%'], }, radioFilled: { - height: 12, - width: 12, - borderRadius: 6, - backgroundColor: $config.PRIMARY_ACTION_BRAND_COLOR, + height: 22, + width: 22, + borderRadius: 12, + backgroundColor: $config.FONT_COLOR, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', }, optionText: { color: $config.FONT_COLOR, From 21786d5939d29e794326ba8ed1d859787ec46c2b Mon Sep 17 00:00:00 2001 From: Supriya Adep Date: Thu, 24 Oct 2024 13:03:24 +0530 Subject: [PATCH 4/4] import fixes --- .../modals/PollResponseFormModal.tsx | 57 ++-- polling/context/poll-context.tsx | 296 +++++++++--------- polling/helpers.ts | 70 +++-- 3 files changed, 215 insertions(+), 208 deletions(-) diff --git a/polling/components/modals/PollResponseFormModal.tsx b/polling/components/modals/PollResponseFormModal.tsx index f0d7847..02e0a24 100644 --- a/polling/components/modals/PollResponseFormModal.tsx +++ b/polling/components/modals/PollResponseFormModal.tsx @@ -1,36 +1,40 @@ -import React, {useState} from 'react'; -import {Text, View, StyleSheet} from 'react-native'; +import React, { useState } from "react"; +import { Text, View, StyleSheet } from "react-native"; import { BaseModal, BaseModalActions, BaseModalCloseIcon, BaseModalContent, BaseModalTitle, -} from '../../ui/BaseModal'; +} from "../../ui/BaseModal"; import { PollResponseFormComplete, PollRenderResponseFormBody, PollFormSubmitButton, -} from '../form/poll-response-forms'; +} from "../form/poll-response-forms"; import { PollStatus, PollTaskRequestTypes, usePoll, -} from '../../context/poll-context'; -import {getPollTypeDesc} from '../../helpers'; +} from "../../context/poll-context"; +import { getPollTypeDesc } from "../../helpers"; import { ThemeConfig, $config, TertiaryButton, useSidePanel, -} from 'customization-api'; -import {usePollForm} from '../../hook/usePollForm'; -import {POLL_SIDEBAR_NAME} from '../../../custom-ui'; +} from "customization-api"; +import { usePollForm } from "../../hook/usePollForm"; +import { POLL_SIDEBAR_NAME } from "../../../polling-ui"; -export default function PollResponseFormModal({pollId}: {pollId: string}) { - const {polls, sendResponseToPoll, closeCurrentModal, handlePollTaskRequest} = - usePoll(); - const {setSidePanel} = useSidePanel(); +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]; @@ -79,9 +83,10 @@ export default function PollResponseFormModal({pollId}: {pollId: string}) { + ? "Here are the poll results 🎉" + : "Here’s a poll for you" + } + > @@ -110,8 +115,8 @@ export default function PollResponseFormModal({pollId}: {pollId: string}) { setAnswer={setAnswer} answer={answer} pollItem={pollItem} - submitted={buttonStatus === 'submitted'} - submitting={buttonStatus === 'submitting'} + submitted={buttonStatus === "submitted"} + submitting={buttonStatus === "submitting"} /> )} @@ -142,8 +147,8 @@ export default function PollResponseFormModal({pollId}: {pollId: string}) { } export const style = StyleSheet.create({ header: { - display: 'flex', - flexDirection: 'column', + display: "flex", + flexDirection: "column", gap: 8, }, heading: { @@ -151,20 +156,20 @@ export const style = StyleSheet.create({ fontSize: ThemeConfig.FontSize.medium, fontFamily: ThemeConfig.FontFamily.sansPro, lineHeight: 24, - fontWeight: '600', + fontWeight: "600", }, info: { color: $config.FONT_COLOR + ThemeConfig.EmphasisPlus.low, fontSize: ThemeConfig.FontSize.tiny, fontFamily: ThemeConfig.FontFamily.sansPro, - fontWeight: '600', + fontWeight: "600", lineHeight: 12, }, warning: { color: $config.SEMANTIC_ERROR, fontSize: ThemeConfig.FontSize.small, fontFamily: ThemeConfig.FontFamily.sansPro, - fontWeight: '600', + fontWeight: "600", lineHeight: 12, }, btnContainer: { @@ -174,13 +179,13 @@ export const style = StyleSheet.create({ }, submittedBtn: { backgroundColor: $config.SEMANTIC_SUCCESS, - cursor: 'default', + cursor: "default", }, btnText: { color: $config.FONT_COLOR, fontSize: ThemeConfig.FontSize.small, fontFamily: ThemeConfig.FontFamily.sansPro, - fontWeight: '600', - textTransform: 'capitalize', + fontWeight: "600", + textTransform: "capitalize", }, }); diff --git a/polling/context/poll-context.tsx b/polling/context/poll-context.tsx index 83606fa..ce41794 100644 --- a/polling/context/poll-context.tsx +++ b/polling/context/poll-context.tsx @@ -6,8 +6,8 @@ import React, { useMemo, useRef, useCallback, -} from 'react'; -import {usePollEvents} from './poll-events'; +} from "react"; +import { usePollEvents } from "./poll-events"; import { useLocalUid, useRoomInfo, @@ -15,11 +15,11 @@ import { SidePanelType, useContent, isWeb, -} from 'customization-api'; +} from "customization-api"; import { getPollExpiresAtTime, POLL_DURATION, -} from '../components/form/form-config'; +} from "../components/form/form-config"; import { addVote, arrayToCsv, @@ -28,30 +28,30 @@ import { downloadCsv, log, mergePolls, -} from '../helpers'; -import {POLL_SIDEBAR_NAME} from '../../custom-ui'; +} from "../helpers"; +import { POLL_SIDEBAR_NAME } from "../../polling-ui"; enum PollStatus { - ACTIVE = 'ACTIVE', - FINISHED = 'FINISHED', - LATER = 'LATER', + ACTIVE = "ACTIVE", + FINISHED = "FINISHED", + LATER = "LATER", } enum PollKind { - OPEN_ENDED = 'OPEN_ENDED', - MCQ = 'MCQ', - YES_NO = 'YES_NO', - NONE = 'NONE', + 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', + 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 { @@ -60,23 +60,23 @@ interface PollModalState { } 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', + 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}>; + votes: Array<{ uid: number; name: string; timestamp: number }>; percent: string; } interface PollItem { @@ -96,7 +96,7 @@ interface PollItem { anonymous: boolean; duration: boolean; expiresAt: number; - createdBy: {uid: number; name: string}; + createdBy: { uid: number; name: string }; createdAt: number; } @@ -112,17 +112,17 @@ interface PollFormErrors { } 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', + 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 = @@ -134,18 +134,18 @@ type PollAction = } | { type: PollActionKind.SAVE_POLL_ITEM; - payload: {item: PollItem}; + payload: { item: PollItem }; } | { type: PollActionKind.SEND_POLL_ITEM; - payload: {pollId: string}; + payload: { pollId: string }; } | { type: PollActionKind.SUBMIT_POLL_ITEM_RESPONSES; payload: { id: string; responses: string | string[]; - user: {name: string; uid: number}; + user: { name: string; uid: number }; timestamp: number; }; } @@ -154,25 +154,25 @@ type PollAction = payload: { id: string; responses: string | string[]; - user: {name: string; uid: number}; + user: { name: string; uid: number }; timestamp: number; }; } | { type: PollActionKind.PUBLISH_POLL_ITEM; - payload: {pollId: string}; + payload: { pollId: string }; } | { type: PollActionKind.FINISH_POLL_ITEM; - payload: {pollId: string}; + payload: { pollId: string }; } | { type: PollActionKind.EXPORT_POLL_ITEM; - payload: {pollId: string}; + payload: { pollId: string }; } | { type: PollActionKind.DELETE_POLL_ITEM; - payload: {pollId: string}; + payload: { pollId: string }; } | { type: PollActionKind.RESET; @@ -192,14 +192,14 @@ function pollReducer(state: Poll, action: PollAction): Poll { const pollId = action.payload.item.id; return { ...state, - [pollId]: {...action.payload.item}, + [pollId]: { ...action.payload.item }, }; } case PollActionKind.ADD_POLL_ITEM: { const pollId = action.payload.item.id; return { ...state, - [pollId]: {...action.payload.item}, + [pollId]: { ...action.payload.item }, }; } case PollActionKind.SEND_POLL_ITEM: { @@ -214,9 +214,9 @@ function pollReducer(state: Poll, action: PollAction): Poll { }; } case PollActionKind.SUBMIT_POLL_ITEM_RESPONSES: { - const {id: pollId, user, responses, timestamp} = action.payload; + const { id: pollId, user, responses, timestamp } = action.payload; const poll = state[pollId]; - if (poll.type === PollKind.OPEN_ENDED && typeof responses === 'string') { + if (poll.type === PollKind.OPEN_ENDED && typeof responses === "string") { return { ...state, [pollId]: { @@ -230,7 +230,7 @@ function pollReducer(state: Poll, action: PollAction): Poll { timestamp, }, ] - : [{...user, response: responses, timestamp}], + : [{ ...user, response: responses, timestamp }], }, }; } @@ -238,12 +238,12 @@ function pollReducer(state: Poll, action: PollAction): Poll { (poll.type === PollKind.MCQ || poll.type === PollKind.YES_NO) && Array.isArray(responses) ) { - const newCopyOptions = poll.options?.map(item => ({...item})) || []; + const newCopyOptions = poll.options?.map((item) => ({ ...item })) || []; const withVotesOptions = addVote( responses, newCopyOptions, user, - timestamp, + timestamp ); const withPercentOptions = calculatePercentage(withVotesOptions); return { @@ -257,9 +257,9 @@ function pollReducer(state: Poll, action: PollAction): Poll { return state; } case PollActionKind.RECEIVE_POLL_ITEM_RESPONSES: { - const {id: pollId, user, responses, timestamp} = action.payload; + const { id: pollId, user, responses, timestamp } = action.payload; const poll = state[pollId]; - if (poll.type === PollKind.OPEN_ENDED && typeof responses === 'string') { + if (poll.type === PollKind.OPEN_ENDED && typeof responses === "string") { return { ...state, [pollId]: { @@ -273,7 +273,7 @@ function pollReducer(state: Poll, action: PollAction): Poll { timestamp, }, ] - : [{...user, response: responses, timestamp}], + : [{ ...user, response: responses, timestamp }], }, }; } @@ -281,12 +281,12 @@ function pollReducer(state: Poll, action: PollAction): Poll { (poll.type === PollKind.MCQ || poll.type === PollKind.YES_NO) && Array.isArray(responses) ) { - const newCopyOptions = poll.options?.map(item => ({...item})) || []; + const newCopyOptions = poll.options?.map((item) => ({ ...item })) || []; const withVotesOptions = addVote( responses, newCopyOptions, user, - timestamp, + timestamp ); const withPercentOptions = calculatePercentage(withVotesOptions); return { @@ -308,7 +308,7 @@ function pollReducer(state: Poll, action: PollAction): Poll { if (pollId) { return { ...state, - [pollId]: {...state[pollId], status: PollStatus.FINISHED}, + [pollId]: { ...state[pollId], status: PollStatus.FINISHED }, }; } } @@ -319,7 +319,7 @@ function pollReducer(state: Poll, action: PollAction): Poll { 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'); + downloadCsv(csv, "polls.csv"); } } return state; @@ -328,7 +328,7 @@ function pollReducer(state: Poll, action: PollAction): Poll { const pollId = action.payload.pollId; if (pollId) { // eslint-disable-next-line @typescript-eslint/no-unused-vars - const {[pollId]: _, ...newItems} = state; + const { [pollId]: _, ...newItems } = state; return { ...newItems, }; @@ -354,7 +354,7 @@ interface PollContextValue { polls: Poll, pollId: string, task: PollTaskRequestTypes, - isInitialized: boolean, + isInitialized: boolean ) => void; sendResponseToPoll: (item: PollItem, responses: string | string[]) => void; onPollResponseReceived: ( @@ -364,7 +364,7 @@ interface PollContextValue { uid: number; name: string; }, - timestamp: number, + timestamp: number ) => void; sendPollResults: (pollId: string) => void; modalState: PollModalState; @@ -374,26 +374,26 @@ interface PollContextValue { } const PollContext = createContext(null); -PollContext.displayName = 'PollContext'; +PollContext.displayName = "PollContext"; -function PollProvider({children}: {children: React.ReactNode}) { +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 { setSidePanel } = useSidePanel(); const { - data: {isHost}, + data: { isHost }, } = useRoomInfo(); const localUid = useLocalUid(); - const {defaultContent} = useContent(); - const {syncPollEvt, sendResponseToPollEvt} = usePollEvents(); + const { defaultContent } = useContent(); + const { syncPollEvt, sendResponseToPollEvt } = usePollEvents(); const callDebouncedSyncPoll = useMemo( () => debounce(syncPollEvt, 800), - [syncPollEvt], + [syncPollEvt] ); const pollsRef = useRef(polls); @@ -405,11 +405,11 @@ function PollProvider({children}: {children: React.ReactNode}) { useEffect(() => { // Delete polls created by the user const deleteMyPolls = () => { - Object.values(pollsRef.current).forEach(poll => { + Object.values(pollsRef.current).forEach((poll) => { if (poll.createdBy.uid === localUid) { enhancedDispatch({ type: PollActionKind.DELETE_POLL_ITEM, - payload: {pollId: poll.id}, + payload: { pollId: poll.id }, }); } }); @@ -417,15 +417,15 @@ function PollProvider({children}: {children: React.ReactNode}) { const handleBeforeUnload = (event: BeforeUnloadEvent) => { event.preventDefault(); deleteMyPolls(); - event.returnValue = ''; // Chrome requires returnValue to be set + event.returnValue = ""; // Chrome requires returnValue to be set }; if (isWeb()) { - window.addEventListener('beforeunload', handleBeforeUnload); + window.addEventListener("beforeunload", handleBeforeUnload); } return () => { if (isWeb()) { - window.removeEventListener('beforeunload', handleBeforeUnload); + window.removeEventListener("beforeunload", handleBeforeUnload); } else { deleteMyPolls(); } @@ -439,7 +439,7 @@ function PollProvider({children}: {children: React.ReactNode}) { }; const closeCurrentModal = useCallback(() => { - log('Closing current modal.'); + log("Closing current modal."); setModalState({ modalType: PollModalType.NONE, id: null, @@ -447,14 +447,14 @@ function PollProvider({children}: {children: React.ReactNode}) { }, []); useEffect(() => { - log('useEffect for lastAction triggered', lastAction); + log("useEffect for lastAction triggered", lastAction); if (!lastAction) { - log('No lastAction to process. Exiting useEffect.'); + log("No lastAction to process. Exiting useEffect."); return; } if (!pollsRef?.current) { - log('PollsRef.current is undefined or null'); + log("PollsRef.current is undefined or null"); return; } @@ -462,30 +462,30 @@ function PollProvider({children}: {children: React.ReactNode}) { 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; + 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; + 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); + 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; + 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', + "No need to send event. User is the poll creator. We only sync data" ); syncPollEvt(pollsRef.current, id, PollTaskRequestTypes.SAVE); return; @@ -495,51 +495,51 @@ function PollProvider({children}: {children: React.ReactNode}) { pollsRef.current[id], responses, user, - timestamp, + timestamp ); } else { - log('Missing uid, localUid, or poll data for submit response.'); + 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; + 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...'); + log("Received poll response, user is the creator. Syncing..."); callDebouncedSyncPoll( pollsRef.current, receivedPollId, - PollTaskRequestTypes.SAVE, + PollTaskRequestTypes.SAVE ); } break; case PollActionKind.PUBLISH_POLL_ITEM: - log('Handling PUBLISH_POLL_ITEM'); + log("Handling PUBLISH_POLL_ITEM"); { - const {pollId} = lastAction.payload; + const { pollId } = lastAction.payload; syncPollEvt(pollsRef.current, pollId, PollTaskRequestTypes.PUBLISH); } break; case PollActionKind.FINISH_POLL_ITEM: - log('Handling FINISH_POLL_ITEM'); + log("Handling FINISH_POLL_ITEM"); { - const {pollId} = lastAction.payload; + const { pollId } = lastAction.payload; syncPollEvt(pollsRef.current, pollId, PollTaskRequestTypes.FINISH); closeCurrentModal(); } break; case PollActionKind.DELETE_POLL_ITEM: - log('Handling DELETE_POLL_ITEM'); + log("Handling DELETE_POLL_ITEM"); { - const {pollId} = lastAction.payload; + 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; + log("Handling SYNC_COMPLETE"); + const { latestTask, latestPollId } = lastAction.payload; if ( latestPollId && latestTask && @@ -558,7 +558,7 @@ function PollProvider({children}: {children: React.ReactNode}) { break; } } catch (error) { - log('Error processing last action:', error); + log("Error processing last action:", error); } }, [ lastAction, @@ -571,7 +571,7 @@ function PollProvider({children}: {children: React.ReactNode}) { ]); const startPollForm = () => { - log('Opening draft poll modal.'); + log("Opening draft poll modal."); setModalState({ modalType: PollModalType.DRAFT_POLL, id: null, @@ -591,30 +591,30 @@ function PollProvider({children}: {children: React.ReactNode}) { }; const savePoll = (item: PollItem) => { - log('Saving poll item:', item); + log("Saving poll item:", item); enhancedDispatch({ type: PollActionKind.SAVE_POLL_ITEM, - payload: {item: {...item}}, + payload: { item: { ...item } }, }); }; const addPoll = (item: PollItem) => { - log('Adding poll item:', item); + log("Adding poll item:", item); enhancedDispatch({ type: PollActionKind.ADD_POLL_ITEM, - payload: {item: {...item}}, + payload: { item: { ...item } }, }); }; const sendPoll = (pollId: string) => { if (!pollId || !polls[pollId]) { - log('Invalid pollId or poll not found for sending:', 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}, + payload: { pollId }, }); }; @@ -622,27 +622,27 @@ function PollProvider({children}: {children: React.ReactNode}) { newPoll: Poll, pollId: string, task: PollTaskRequestTypes, - initialLoad: boolean, + initialLoad: boolean ) => { - log('onPollReceived', newPoll, pollId, task); + log("onPollReceived", newPoll, pollId, task); if (!newPoll || !pollId) { - log('Invalid newPoll or pollId in onPollReceived:', {newPoll, pollId}); + log("Invalid newPoll or pollId in onPollReceived:", { newPoll, pollId }); return; } - const {mergedPolls, deletedPollIds} = mergePolls(newPoll, polls); + const { mergedPolls, deletedPollIds } = mergePolls(newPoll, polls); - log('Merged polls:', mergedPolls); - log('Deleted poll IDs:', deletedPollIds); + 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}); + 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.'); + log("I am the creator, no further action needed."); return; } @@ -651,15 +651,15 @@ function PollProvider({children}: {children: React.ReactNode}) { handlePollTaskRequest(PollTaskRequestTypes.DELETE, id); }); - log('Updating state with merged polls.'); + log("Updating state with merged polls."); Object.values(mergedPolls) - .filter(pollItem => pollItem.status !== PollStatus.LATER) - .forEach(pollItem => { + .filter((pollItem) => pollItem.status !== PollStatus.LATER) + .forEach((pollItem) => { log(`Adding poll ID ${pollItem.id} with status ${pollItem.status}`); addPoll(pollItem); }); - log('Is it an initial load ?:', initialLoad); + log("Is it an initial load ?:", initialLoad); if (!initialLoad) { enhancedDispatch({ type: PollActionKind.SYNC_COMPLETE, @@ -671,24 +671,24 @@ function PollProvider({children}: {children: React.ReactNode}) { } else { if (Object.keys(mergedPolls).length > 0) { // Check if there is an active poll - log('It is an initial load.'); + log("It is an initial load."); const activePoll = Object.values(mergedPolls).find( - pollItem => pollItem.status === PollStatus.ACTIVE, + (pollItem) => pollItem.status === PollStatus.ACTIVE ); if (activePoll) { - log('It is an initial load. There is an active poll'); + 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'); + 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); + log("Sending response to poll:", item, responses); if ( - (item.type === PollKind.OPEN_ENDED && typeof responses === 'string') || + (item.type === PollKind.OPEN_ENDED && typeof responses === "string") || (item.type !== PollKind.OPEN_ENDED && Array.isArray(responses)) ) { enhancedDispatch({ @@ -698,14 +698,14 @@ function PollProvider({children}: {children: React.ReactNode}) { responses, user: { uid: localUid, - name: defaultContent[localUid]?.name || 'user', + name: defaultContent[localUid]?.name || "user", }, timestamp: Date.now(), }, }); } else { throw new Error( - 'sendResponseToPoll received incorrect type response. Unable to send poll response', + "sendResponseToPoll received incorrect type response. Unable to send poll response" ); } }; @@ -717,9 +717,9 @@ function PollProvider({children}: {children: React.ReactNode}) { uid: number; name: string; }, - timestamp: number, + timestamp: number ) => { - log('Received poll response:', {pollId, responses, user, timestamp}); + log("Received poll response:", { pollId, responses, user, timestamp }); enhancedDispatch({ type: PollActionKind.RECEIVE_POLL_ITEM_RESPONSES, payload: { @@ -738,17 +738,17 @@ function PollProvider({children}: {children: React.ReactNode}) { const handlePollTaskRequest = ( task: PollTaskRequestTypes, - pollId: string, + pollId: string ) => { if (!pollId || !polls[pollId]) { log( - 'handlePollTaskRequest: Invalid pollId or poll not found for handling', - pollId, + "handlePollTaskRequest: Invalid pollId or poll not found for handling", + pollId ); return; } if (!(task in PollTaskRequestTypes)) { - log('handlePollTaskRequest: Invalid valid task', task); + log("handlePollTaskRequest: Invalid valid task", task); return; } log(`Handling poll task request: ${task} for pollId: ${pollId}`); @@ -774,7 +774,7 @@ function PollProvider({children}: {children: React.ReactNode}) { case PollTaskRequestTypes.PUBLISH: enhancedDispatch({ type: PollActionKind.PUBLISH_POLL_ITEM, - payload: {pollId}, + payload: { pollId }, }); break; case PollTaskRequestTypes.DELETE_CONFIRMATION: @@ -786,7 +786,7 @@ function PollProvider({children}: {children: React.ReactNode}) { case PollTaskRequestTypes.DELETE: enhancedDispatch({ type: PollActionKind.DELETE_POLL_ITEM, - payload: {pollId}, + payload: { pollId }, }); break; case PollTaskRequestTypes.FINISH_CONFIRMATION: @@ -798,13 +798,13 @@ function PollProvider({children}: {children: React.ReactNode}) { case PollTaskRequestTypes.FINISH: enhancedDispatch({ type: PollActionKind.FINISH_POLL_ITEM, - payload: {pollId}, + payload: { pollId }, }); break; case PollTaskRequestTypes.EXPORT: enhancedDispatch({ type: PollActionKind.EXPORT_POLL_ITEM, - payload: {pollId}, + payload: { pollId }, }); break; default: @@ -835,7 +835,7 @@ function PollProvider({children}: {children: React.ReactNode}) { function usePoll() { const context = React.useContext(PollContext); if (!context) { - throw new Error('usePoll must be used within a PollProvider'); + throw new Error("usePoll must be used within a PollProvider"); } return context; } @@ -850,4 +850,4 @@ export { PollTaskRequestTypes, }; -export type {Poll, PollItem, PollFormErrors, PollItemOptionItem}; +export type { Poll, PollItem, PollFormErrors, PollItemOptionItem }; diff --git a/polling/helpers.ts b/polling/helpers.ts index 2fb8c6f..2bab750 100644 --- a/polling/helpers.ts +++ b/polling/helpers.ts @@ -1,21 +1,21 @@ -import {isMobileUA, isWeb} from 'customization-api'; -import {Poll, PollItemOptionItem, PollKind} from './context/poll-context'; -import pollIcons from './poll-icons'; +import { 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::] supriya ', ...args); + console.log("[Custom-Polling::]", ...args); } function addVote( responses: string[], options: PollItemOptionItem[], - user: {name: string; uid: number}, - timestamp: number, + 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); + const isVoted = option.votes.find((item) => item.uid === user.uid); if (exists && !isVoted) { // Creating a new object explicitly const newOption: PollItemOptionItem = { @@ -37,11 +37,11 @@ function addVote( } function calculatePercentage( - options: PollItemOptionItem[], + options: PollItemOptionItem[] ): PollItemOptionItem[] { const totalVotes = options.reduce( (total, item) => total + item.votes.length, - 0, + 0 ); if (totalVotes === 0) { // As none of the users have voted, there is no need to calulate the percentage, @@ -63,30 +63,30 @@ function calculatePercentage( } function arrayToCsv(question: string, data: PollItemOptionItem[]): string { - const headers = ['Option', 'Votes', 'Percent']; // Define the headers - const rows = data.map(item => { + 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%'; + 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'); + 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'); +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'; + link.setAttribute("href", url); + link.setAttribute("download", filename); + link.setAttribute("target", "_blank"); + link.style.visibility = "hidden"; document.body.appendChild(link); link.click(); @@ -99,7 +99,9 @@ function capitalizeFirstLetter(sentence: string): string { 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)); + return options.some((option) => + option.votes.some((vote) => vote.uid === uid) + ); } type MergePollsResult = { @@ -111,18 +113,18 @@ 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}; + 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 => { + 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 => { + 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 @@ -130,7 +132,7 @@ function mergePolls(newPoll: Poll, oldPoll: Poll): MergePollsResult { }); // 5. Return the merged polls and deleted poll IDs - return {mergedPolls, deletedPollIds}; + return { mergedPolls, deletedPollIds }; } function getPollTypeIcon(type: PollKind): string { @@ -138,7 +140,7 @@ function getPollTypeIcon(type: PollKind): string { return pollIcons.question; } if (type === PollKind.YES_NO) { - return pollIcons['like-dislike']; + return pollIcons["like-dislike"]; } if (type === PollKind.MCQ) { return pollIcons.mcq; @@ -148,18 +150,18 @@ function getPollTypeIcon(type: PollKind): string { function getPollTypeDesc(type: PollKind, multiple_response?: boolean): string { if (type === PollKind.OPEN_ENDED) { - return 'Open Ended'; + return "Open Ended"; } if (type === PollKind.YES_NO) { - return 'Select Any One'; + return "Select Any One"; } if (type === PollKind.MCQ) { if (multiple_response) { - return 'MCQ - Select One or More'; + return "MCQ - Select One or More"; } - return 'MCQ - Select Any One'; + return "MCQ - Select Any One"; } - return 'None'; + return "None"; } function formatTimestampToTime(timestamp: number): string { @@ -169,7 +171,7 @@ function formatTimestampToTime(timestamp: number): string { let hours = date.getHours(); const minutes = date.getMinutes(); // Determine if it's AM or PM - const ampm = hours >= 12 ? 'PM' : 'AM'; + 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' @@ -186,7 +188,7 @@ function calculateTotalVotes(options: Array): number { const debounce = void>( func: T, - delay: number = 300, + delay: number = 300 ) => { let debounceTimer: ReturnType; return function (this: ThisParameterType, ...args: Parameters) {