diff --git a/src/components/AccountSaas/useAccountPlan.tsx b/src/components/AccountSaas/useAccountPlan.tsx new file mode 100644 index 000000000..85dc116ca --- /dev/null +++ b/src/components/AccountSaas/useAccountPlan.tsx @@ -0,0 +1,74 @@ +import { useQuery, UseQueryOptions } from '@tanstack/react-query' +import { VotingType } from '~components/ProcessCreate/Questions/useVotingType' +import { UnimplementedVotingType } from '~components/ProcessCreate/Questions/useUnimplementedVotingType' + +type PlanType = 'free' | 'pro' | 'custom' + +export type FeaturesKeys = + | 'anonymous' + | 'secretUntilTheEnd' + | 'overwrite' + | 'personalization' + | 'emailReminder' + | 'smsNotification' + | 'whiteLabel' + | 'liveStreaming' +export type SaasVotingTypesKeys = VotingType & UnimplementedVotingType + +type SaasOrganizationInfo = { + memberships: number + subOrgs: number + maxProcesses: number + max_census_size: number + customURL: boolean +} + +type AccountPlanTypeResponse = { + plan: PlanType + stripePlanId: string + organization: SaasOrganizationInfo + votingTypes: Record + features: Record +} + +const accountPlanMock: AccountPlanTypeResponse = { + plan: 'pro', + stripePlanId: 'plan_xyz123', // Stripe plan ID for payment and plan management + organization: { + memberships: 5, // Maximum number of members or admins + subOrgs: 3, // Maximum number of sub-organizations + maxProcesses: 10, // Maximum number of voting processes + max_census_size: 10000, // Maximum number of voters (census size) for this plan + customURL: true, // Whether a custom URL for the voting page is allowed + }, + votingTypes: { + single: true, // Simple single-choice voting allowed + multiple: true, // Multiple-choice voting allowed + approval: true, // Approval voting allowed + cumulative: false, // Cumulative voting not allowed + ranked: false, // Ranked voting not allowed + weighted: false, // Weighted voting not allowed + }, + features: { + personalization: true, // Voting page customization allowed + emailReminder: true, // Email reminders allowed + smsNotification: true, // SMS notifications allowed + whiteLabel: true, // White-label voting page allowed + liveStreaming: false, // Live results streaming not allowed + anonymous: true, + secretUntilTheEnd: true, + overwrite: true, + // ... Other feature controls + }, +} + +export const useAccountPlan = (options?: Omit, 'queryKey' | 'queryFn'>) => { + return useQuery({ + queryKey: ['account', 'plan'], + queryFn: async () => { + // Simulate an API call + return accountPlanMock + }, + ...options, + }) +} diff --git a/src/components/ProcessCreate/Census/TypeSelector.tsx b/src/components/ProcessCreate/Census/TypeSelector.tsx index 84f20e190..b103371f8 100644 --- a/src/components/ProcessCreate/Census/TypeSelector.tsx +++ b/src/components/ProcessCreate/Census/TypeSelector.tsx @@ -4,7 +4,6 @@ import { CensusType, CensusTypeCsp, CensusTypeGitcoin, - CensusTypes, CensusTypeSpreadsheet, CensusTypeToken, CensusTypeWeb3, @@ -16,7 +15,6 @@ export const useCensusTypes = (): GenericFeatureObject => { const { t } = useTranslation() return { - list: CensusTypes, defined: import.meta.env.features.census as CensusType[], details: { [CensusTypeSpreadsheet]: { diff --git a/src/components/ProcessCreate/Census/UnimplementedTypeSelector.tsx b/src/components/ProcessCreate/Census/UnimplementedTypeSelector.tsx index 17081a12e..bdb0ce879 100644 --- a/src/components/ProcessCreate/Census/UnimplementedTypeSelector.tsx +++ b/src/components/ProcessCreate/Census/UnimplementedTypeSelector.tsx @@ -31,7 +31,6 @@ export const UnimplementedCensusTypes = [ export const useUnimplementedCensusTypes = (): GenericFeatureObject => { const { t } = useTranslation() return { - list: UnimplementedCensusTypes, defined: import.meta.env.features.unimplemented_census as UnimplementedCensusType[], details: { [CensusTypePhone]: { diff --git a/src/components/ProcessCreate/Questions/useSaasVotingType.ts b/src/components/ProcessCreate/Questions/useSaasVotingType.ts new file mode 100644 index 000000000..cb33879cb --- /dev/null +++ b/src/components/ProcessCreate/Questions/useSaasVotingType.ts @@ -0,0 +1,83 @@ +import { useTranslation } from 'react-i18next' +import { GenericFeatureObject, GenericFeatureObjectProps } from '~components/ProcessCreate/Steps/TabsPage' +import { SaasVotingTypesKeys, useAccountPlan } from '~components/AccountSaas/useAccountPlan' +import { GiChoice } from 'react-icons/gi' +import SingleChoice from '~components/ProcessCreate/Questions/SingleChoice' +import { useMemo } from 'react' + +const useVotingTypesTranslations = (): Record => { + const { t } = useTranslation() + return useMemo( + () => ({ + single: { + description: t('single', { defaultValue: 'single' }), + title: t('single', { defaultValue: 'single' }), + icon: GiChoice, + component: SingleChoice, + }, + multiple: { + description: t('multiple', { defaultValue: 'multiple' }), + title: t('multiple', { defaultValue: 'multiple' }), + icon: GiChoice, + component: SingleChoice, + }, + approval: { + description: t('approval', { defaultValue: 'approval' }), + title: t('approval', { defaultValue: 'approval' }), + icon: GiChoice, + component: SingleChoice, + }, + cumulative: { + description: t('cumulative', { defaultValue: 'cumulative' }), + title: t('cumulative', { defaultValue: 'cumulative' }), + icon: GiChoice, + component: SingleChoice, + }, + ranked: { + description: t('ranked', { defaultValue: 'ranked' }), + title: t('ranked', { defaultValue: 'ranked' }), + icon: GiChoice, + component: SingleChoice, + }, + weighted: { + description: t('weighted', { defaultValue: 'weighted' }), + title: t('weighted', { defaultValue: 'weighted' }), + icon: GiChoice, + component: SingleChoice, + }, + }), + [t] + ) +} + +export const useSaasVotingType = (): { + inPlan: GenericFeatureObject> + pro: GenericFeatureObject> +} => { + const { data } = useAccountPlan() + const translations = useVotingTypesTranslations() + + if (!data) return null + const inPlanDetails = {} as Record, GenericFeatureObjectProps> + const proDetails = {} as Record, GenericFeatureObjectProps> + + for (const [key, inPlan] of Object.entries(data.votingTypes)) { + const _key = key as SaasVotingTypesKeys + if (inPlan) { + inPlanDetails[_key] = translations[_key] + continue + } + proDetails[_key] = translations[_key] + } + + return { + inPlan: { + defined: Object.keys(inPlanDetails) as Partial[], + details: inPlanDetails, + }, + pro: { + defined: Object.keys(proDetails) as Partial[], + details: proDetails, + }, + } +} diff --git a/src/components/ProcessCreate/Questions/useUnimplementedVotingType.ts b/src/components/ProcessCreate/Questions/useUnimplementedVotingType.ts index 345b57ede..616052534 100644 --- a/src/components/ProcessCreate/Questions/useUnimplementedVotingType.ts +++ b/src/components/ProcessCreate/Questions/useUnimplementedVotingType.ts @@ -23,7 +23,6 @@ export const UnimplementedVotingType = [ export const useUnimplementedVotingType = (): GenericFeatureObject => { const { t } = useTranslation() return { - list: UnimplementedVotingType, defined: import.meta.env.features.unimplemented_voting_type as UnimplementedVotingType[], details: { [UnimplementedVotingTypeMulti]: { diff --git a/src/components/ProcessCreate/Questions/useVotingType.ts b/src/components/ProcessCreate/Questions/useVotingType.ts index fc25e005e..2fcf6beb5 100644 --- a/src/components/ProcessCreate/Questions/useVotingType.ts +++ b/src/components/ProcessCreate/Questions/useVotingType.ts @@ -16,7 +16,6 @@ export const VotingTypes = [VotingTypeSingle as VotingType, UnimplementedVotingT export const useVotingType = (): GenericFeatureObject => { const { t } = useTranslation() return { - list: VotingTypes, defined: import.meta.env.features.voting_type as VotingType[], details: { [VotingTypeSingle]: { diff --git a/src/components/ProcessCreate/SaasFooter.tsx b/src/components/ProcessCreate/SaasFooter.tsx new file mode 100644 index 000000000..03e39cdba --- /dev/null +++ b/src/components/ProcessCreate/SaasFooter.tsx @@ -0,0 +1,24 @@ +import { Box, Flex, HStack, Image, Stack, Text } from '@chakra-ui/react' +import vcdLogo from '/assets/logo-classic.svg' +import { Button } from '@vocdoni/chakra-components' +import { useAccountPlan } from '~components/AccountSaas/useAccountPlan' + +const SaasFooter = () => { + const { data } = useAccountPlan() + const isCustom = data?.plan === 'custom' + const isFree = data?.plan === 'free' + + return ( + + + + Privacy Policy + support@vocdoni.org + {isFree && $0.00} + {!isCustom && } + + + ) +} + +export default SaasFooter diff --git a/src/components/ProcessCreate/Settings/SaasFeatures.tsx b/src/components/ProcessCreate/Settings/SaasFeatures.tsx new file mode 100644 index 000000000..a1383c0b4 --- /dev/null +++ b/src/components/ProcessCreate/Settings/SaasFeatures.tsx @@ -0,0 +1,99 @@ +import { Box, Checkbox, CheckboxProps, Icon, Text } from '@chakra-ui/react' +import { BiCheckDouble } from 'react-icons/bi' +import { IconType } from 'react-icons' +import { FeaturesKeys, useAccountPlan } from '~components/AccountSaas/useAccountPlan' +import { Loading } from '~src/router/SuspenseLoader' +import { useTranslation } from 'react-i18next' +import { useFormContext } from 'react-hook-form' +import { useMemo } from 'react' + +const useFeaturesTranslations = (): Record => { + const { t } = useTranslation() + return useMemo( + () => ({ + anonymous: { + description: t('anonymous', { defaultValue: 'anonymous' }), + title: t('anonymous', { defaultValue: 'anonymous' }), + boxIcon: BiCheckDouble, + formKey: 'electionType.anonymous', + }, + secretUntilTheEnd: { + description: t('secretUntilTheEnd', { defaultValue: 'secretUntilTheEnd' }), + title: t('secretUntilTheEnd', { defaultValue: 'secretUntilTheEnd' }), + boxIcon: BiCheckDouble, + formKey: 'electionType.secretUntilTheEnd', + }, + overwrite: { + description: t('overwrite', { defaultValue: 'overwrite' }), + title: t('overwrite', { defaultValue: 'overwrite' }), + boxIcon: BiCheckDouble, + }, + personalization: { + description: t('personalization', { defaultValue: 'personalization' }), + title: t('personalization', { defaultValue: 'personalization' }), + boxIcon: BiCheckDouble, + }, + emailReminder: { + description: t('emailReminder', { defaultValue: 'emailReminder' }), + title: t('emailReminder', { defaultValue: 'emailReminder' }), + boxIcon: BiCheckDouble, + }, + smsNotification: { + description: t('smsNotification', { defaultValue: 'smsNotification' }), + title: t('smsNotification', { defaultValue: 'smsNotification' }), + boxIcon: BiCheckDouble, + }, + whiteLabel: { + description: t('whiteLabel', { defaultValue: 'whiteLabel' }), + title: t('whiteLabel', { defaultValue: 'whiteLabel' }), + boxIcon: BiCheckDouble, + }, + liveStreaming: { + description: t('liveStreaming', { defaultValue: 'liveStreaming' }), + title: t('liveStreaming', { defaultValue: 'liveStreaming' }), + boxIcon: BiCheckDouble, + }, + }), + [t] + ) +} +export const SaasFeatures = () => { + const { data, isLoading } = useAccountPlan() + const translations = useFeaturesTranslations() + + if (isLoading) return + if (!data) return null + + return ( + + {Object.entries(data.features).map(([feature, inPlan], i) => { + const card = translations[feature as FeaturesKeys] + if (!card) return null + return + })} + + ) +} + +interface CheckBoxCardProps { + title: string + description: string + boxIcon: IconType + isPro?: boolean + formKey?: string +} + +const CheckBoxCard = ({ title, description, boxIcon, isPro, formKey, ...rest }: CheckBoxCardProps & CheckboxProps) => { + const { register, watch } = useFormContext() + + return ( + + + + {title} + + {isPro && Pro} + {description} + + ) +} diff --git a/src/components/ProcessCreate/Settings/index.tsx b/src/components/ProcessCreate/Settings/index.tsx index 4e2775349..6836240ab 100644 --- a/src/components/ProcessCreate/Settings/index.tsx +++ b/src/components/ProcessCreate/Settings/index.tsx @@ -4,7 +4,7 @@ import Calendar from './Calendar' const CreateProcessSettings = () => ( <> - + {!import.meta.env.SAAS_URL && } ) diff --git a/src/components/ProcessCreate/StepForm/Info.tsx b/src/components/ProcessCreate/StepForm/Info.tsx index 41c687488..10bbc4b36 100644 --- a/src/components/ProcessCreate/StepForm/Info.tsx +++ b/src/components/ProcessCreate/StepForm/Info.tsx @@ -13,6 +13,9 @@ export interface InfoValues { // dates need to be string to properly reset the values to the inputs endDate: string startDate: string +} + +export interface ConfigurationValues { electionType: { autoStart: boolean interruptible: boolean @@ -26,13 +29,15 @@ export interface InfoValues { weightedVote: boolean } +type FormValues = InfoValues & ConfigurationValues + export const Info = () => { const { form, setForm, next } = useProcessCreationSteps() - const methods = useForm({ + const methods = useForm({ defaultValues: form, }) - const onSubmit: SubmitHandler = (data) => { + const onSubmit: SubmitHandler = (data) => { setForm({ ...form, ...data }) next() } diff --git a/src/components/ProcessCreate/StepForm/Questions.tsx b/src/components/ProcessCreate/StepForm/Questions.tsx index b2e570f46..67e792a8f 100644 --- a/src/components/ProcessCreate/StepForm/Questions.tsx +++ b/src/components/ProcessCreate/StepForm/Questions.tsx @@ -3,8 +3,9 @@ import { FormProvider, SubmitHandler, useFieldArray, useForm, useFormContext } f import { useTranslation } from 'react-i18next' import { useUnimplementedVotingType } from '~components/ProcessCreate/Questions/useUnimplementedVotingType' import { MultiQuestionTypes, useVotingType, VotingType } from '~components/ProcessCreate/Questions/useVotingType' -import { TabsPage } from '~components/ProcessCreate/Steps/TabsPage' +import { ITabsPageProps, TabsPage } from '~components/ProcessCreate/Steps/TabsPage' import { StepsFormValues, useProcessCreationSteps } from '../Steps/use-steps' +import { useSaasVotingType } from '~components/ProcessCreate/Questions/useSaasVotingType' export interface Option { option: string @@ -21,12 +22,23 @@ export interface QuestionsValues { questionType: VotingType | null } -const QuestionsTabs = () => { - const { t } = useTranslation() - const { form, setForm } = useProcessCreationSteps() +const SaasQuestionsTabs = () => { + const { pro, inPlan } = useSaasVotingType() + return +} +const VocdoniAppQuestionsTabs = () => { const definedVotingTypes = useVotingType() const unDefinedVotingTypes = useUnimplementedVotingType() + return +} +const QuestionsTabs = ({ + definedList, + unimplementedList, +}: Pick, 'definedList' | 'unimplementedList'>) => { + const { t } = useTranslation() + const { form, setForm } = useProcessCreationSteps() + const { questionType } = form const { watch } = useFormContext() @@ -39,17 +51,17 @@ const QuestionsTabs = () => { return ( { - const newQuestionType = definedVotingTypes.defined[index] + const newQuestionType = definedList.defined[index] // If the question type not accepts multiquestion and there are multiple questions selcted store only the first if (newQuestionType && !MultiQuestionTypes.includes(newQuestionType) && questions.length > 1) { replace(questions[0]) } const nform: StepsFormValues = { ...form, - questionType: newQuestionType, + questionType: newQuestionType as VotingType, } setForm(nform) }} @@ -73,10 +85,11 @@ export const Questions = () => { setForm({ ...form, ...data }) next() } + return ( - + {import.meta.env.SAAS_URL ? : } ) diff --git a/src/components/ProcessCreate/StepForm/SaasFeatures.tsx b/src/components/ProcessCreate/StepForm/SaasFeatures.tsx new file mode 100644 index 000000000..4a988845b --- /dev/null +++ b/src/components/ProcessCreate/StepForm/SaasFeatures.tsx @@ -0,0 +1,40 @@ +import { useProcessCreationSteps } from '../Steps/use-steps' +import { FormProvider, SubmitHandler, useForm } from 'react-hook-form' +import Wrapper from '~components/ProcessCreate/Steps/Wrapper' +import { Flex } from '@chakra-ui/react' +import { StepsNavigation } from '~components/ProcessCreate/Steps/Navigation' +import { FeaturesKeys } from '~components/AccountSaas/useAccountPlan' +import { ConfigurationValues } from '~components/ProcessCreate/StepForm/Info' +import { SaasFeatures as SaasFeaturesComponent } from '~components/ProcessCreate/Settings/SaasFeatures' + +export type SaasFeaturesValues = { saasFeatures: Record } +interface FeaturesForm extends SaasFeaturesValues, ConfigurationValues {} + +export const SaasFeatures = () => { + const { form, setForm, next } = useProcessCreationSteps() + const methods = useForm({ + defaultValues: form, + }) + + const onSubmit: SubmitHandler = (data) => { + setForm({ ...form, ...data }) + next() + } + + return ( + + + + + + + + + ) +} diff --git a/src/components/ProcessCreate/Steps/Form.tsx b/src/components/ProcessCreate/Steps/Form.tsx index 979e96e2f..aee6ef7c6 100644 --- a/src/components/ProcessCreate/Steps/Form.tsx +++ b/src/components/ProcessCreate/Steps/Form.tsx @@ -40,6 +40,20 @@ export const StepsForm = ({ steps, activeStep, next, prev, setActiveStep }: Step gpsWeighted: false, passportScore: 20, stampsUnionType: 'OR', + ...(import.meta.env.SAAS_URL + ? { + saasFeatures: { + anonymous: false, + secretUntilTheEnd: import.meta.env.features.vote.secret, + overwrite: false, + personalization: false, + emailReminder: false, + smsNotification: false, + whiteLabel: false, + liveStreaming: false, + }, + } + : {}), }) const [isLoadingPreview, setIsLoadingPreview] = useState(false) diff --git a/src/components/ProcessCreate/Steps/TabsPage.tsx b/src/components/ProcessCreate/Steps/TabsPage.tsx index 1659888c3..5f4e7a622 100644 --- a/src/components/ProcessCreate/Steps/TabsPage.tsx +++ b/src/components/ProcessCreate/Steps/TabsPage.tsx @@ -13,13 +13,14 @@ import { Check } from '~theme/icons' * These components implement the skeleton for tabs pages like questions or census types. */ +export type GenericFeatureObjectProps = { icon: any; title: string; description: string; component?: () => JSX.Element } +export type GenericFeatureObjectDetailsRecord = Record export type GenericFeatureObject = { - list: T[] defined: T[] - details: Record JSX.Element }> + details: GenericFeatureObjectDetailsRecord } -interface ITabsPageProps { +export interface ITabsPageProps { onTabChange: (index: number) => void title: string description: string diff --git a/src/components/ProcessCreate/Steps/use-steps.tsx b/src/components/ProcessCreate/Steps/use-steps.tsx index 53efc7d3a..327e53b8c 100644 --- a/src/components/ProcessCreate/Steps/use-steps.tsx +++ b/src/components/ProcessCreate/Steps/use-steps.tsx @@ -3,23 +3,26 @@ import { useTranslation } from 'react-i18next' import { CensusSpreadsheetValues } from '../StepForm/CensusSpreadsheet' import { CensusTokenValues } from '../StepForm/CensusToken' import { CensusWeb3Values } from '../StepForm/CensusWeb3' -import { Info, InfoValues } from '../StepForm/Info' +import { ConfigurationValues, Info, InfoValues } from '../StepForm/Info' import { Questions, QuestionsValues } from '../StepForm/Questions' import { Census, CensusValues } from './Census' import { Checks } from './Checks' import { Confirm } from './Confirm' import { CensusCspValues } from '../StepForm/CensusCsp' import { CensusGitcoinValues } from '~components/ProcessCreate/StepForm/CensusGitcoin' +import { SaasFeatures, SaasFeaturesValues } from '~components/ProcessCreate/StepForm/SaasFeatures' export interface StepsFormValues extends InfoValues, + ConfigurationValues, QuestionsValues, CensusValues, CensusWeb3Values, CensusTokenValues, CensusCspValues, CensusGitcoinValues, - CensusSpreadsheetValues {} + CensusSpreadsheetValues, + SaasFeaturesValues {} export interface StepsState { title: string @@ -57,13 +60,32 @@ export const useProcessCreationSteps = () => { export const useStepContents = () => { const { t } = useTranslation() - const steps = [ + let steps = [ { title: t('form.process_create.steps.checks'), Contents: Checks }, { title: t('form.process_create.steps.info'), Contents: Info, first: true }, { title: t('form.process_create.steps.questions'), Contents: Questions }, { title: t('form.process_create.steps.census'), Contents: Census }, { title: t('form.process_create.steps.confirm'), Contents: Confirm }, ] + if (import.meta.env.SAAS_URL) { + steps = [ + { + title: t('form.process_create_saas.steps.info_title', { defaultValue: 'Information' }), + Contents: Info, + first: true, + }, + { + title: t('form.process_create_saas.steps.questions_title', { defaultValue: 'Questions' }), + Contents: Questions, + }, + { title: t('form.process_create_saas.steps.census_title', { defaultValue: 'Census' }), Contents: Census }, + { + title: t('form.process_create_saas.steps.features_title', { defaultValue: 'Features' }), + Contents: SaasFeatures, + }, + { title: t('form.process_create_saas.steps.confirm_title', { defaultValue: 'Confirm' }), Contents: Confirm }, + ] + } return steps } diff --git a/src/elements/LayoutProcessCreate.tsx b/src/elements/LayoutProcessCreate.tsx index 7411a4fc8..522b9b9fa 100644 --- a/src/elements/LayoutProcessCreate.tsx +++ b/src/elements/LayoutProcessCreate.tsx @@ -1,12 +1,14 @@ import { Box, Button, Flex, Text } from '@chakra-ui/react' import { useTranslation } from 'react-i18next' -import { Outlet, Link as ReactRouterLink, ScrollRestoration, useNavigate } from 'react-router-dom' +import { Link as ReactRouterLink, Outlet, ScrollRestoration, useNavigate } from 'react-router-dom' import Logo from '~components/Layout/Logo' import { Close } from '~theme/icons' +import SaasFooter from '~components/ProcessCreate/SaasFooter' const LayoutProcessCreate = () => { const { t } = useTranslation() const navigate = useNavigate() + const isSaas = import.meta.env.SAAS_URL return ( @@ -22,7 +24,7 @@ const LayoutProcessCreate = () => { p={{ base: '12px 10px', sm: '12px 20px', md: '24px 40px' }} > - + {isSaas && }