Skip to content

Commit

Permalink
Integrating subscription modals into the app flow
Browse files Browse the repository at this point in the history
- Created a PricingModals provider and context, with their
  usePricingModals hook
- Integrated these into the invite team memberships, showing the upgrade
  tier modal there (which already links to the pricing one)
  • Loading branch information
elboletaire committed Nov 27, 2024
1 parent 1d79126 commit 00d43e8
Show file tree
Hide file tree
Showing 14 changed files with 537 additions and 239 deletions.
33 changes: 25 additions & 8 deletions src/components/Organization/Invite.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import { HSeparator } from '~components/Auth/SignIn'
import { useSubscription } from '~components/Auth/Subscription'
import { useAuth } from '~components/Auth/useAuth'
import InputBasic from '~components/Layout/InputBasic'
import { usePricingModal } from '~components/Pricing/Modals'
import { SubscriptionPermission } from '~constants'
import { CallbackProvider, useCallbackContext } from '~utils/callback-provider'
import { useTeamMembers } from './Team'
Expand Down Expand Up @@ -184,18 +185,34 @@ export const InviteToTeamModal = (props: ButtonProps) => {
const { permission } = useSubscription()
const { t } = useTranslation()
const { data: members, isLoading } = useTeamMembers()
const { openModal } = usePricingModal()

const canInvite = permission(SubscriptionPermission.Memberships) > (members?.length || 0)
const memberships = permission(SubscriptionPermission.Memberships)
const canInvite = memberships > (members?.length || 0)

return (
<>
{canInvite ? (
<Button onClick={onOpen} {...props} isLoading={isLoading} loadingText={t('loading')}>
<Trans i18nKey='invite_people'>Invite People</Trans>
</Button>
) : (
<Text>You must upgrade!</Text>
)}
<Button
onClick={() => {
if (canInvite) {
onOpen()
} else {
openModal('planUpgrade', {
feature: 'organization.memberships',
text: t('more_than_memberships', {
defaultValue: 'more than {count} memberships',
count: memberships,
}),
value: members?.length + 1,
})
}
}}
{...props}
isLoading={isLoading}
loadingText={t('loading')}
>
<Trans i18nKey='invite_people'>Invite People</Trans>
</Button>
<CallbackProvider success={() => onClose()}>
<Modal isOpen={isOpen} onClose={onClose} size='xl' closeOnOverlayClick>
<ModalOverlay />
Expand Down
2 changes: 1 addition & 1 deletion src/components/Organization/Subscription.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ export const Subscription = () => {

return (
<>
<SubscriptionModal isOpenModal={isOpen} onCloseModal={onClose} title={t('pricing.title')} />
<SubscriptionModal isOpen={isOpen} onClose={onClose} title={t('pricing.title')} />
<Button onClick={onOpen} alignSelf='end'>
View Plans & Pricing
</Button>
Expand Down
2 changes: 1 addition & 1 deletion src/components/Pricing/Card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ const PricingCard = ({
</CardHeader>
<CardBody>
<Button isDisabled={isDisabled || false}>
<Trans i18nKey='subscribe'>Subscribe</Trans>
<Trans i18nKey='view_pricing_plan'>View Pricing Plan</Trans>
</Button>
<Text>
<Trans i18nKey='pricing_card.from' values={{ price }}>
Expand Down
52 changes: 52 additions & 0 deletions src/components/Pricing/Modals.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { useDisclosure } from '@chakra-ui/hooks'
import { createContext } from '@chakra-ui/react-utils'
import React, { ReactNode, useState } from 'react'
import { SubscriptionModal } from './Plans'
import { PlanUpgradeData, PlanUpgradeModal, TierUpgradeModal } from './Upgrading'

// Define types for the context
type PricingModalType = 'tierUpgrade' | 'planUpgrade' | 'subscription' | null

type PricingModalContextState = {
openModal: (type: PricingModalType, modalData?: PlanUpgradeData | null) => void
closeModal: () => void
modalType: PricingModalType
modalData: any
}

const [PricingModalProviderContext, usePricingModal] = createContext<PricingModalContextState>({
name: 'PricingModalProvider',
errorMessage: 'usePricingModal must be used within a PricingModalProvider',
strict: true,
})

export const PricingModalProvider: React.FC<{ children?: ReactNode }> = ({ children }) => {
const { isOpen, onOpen, onClose } = useDisclosure()
const [modalType, setModalType] = useState<PricingModalType>(null)
const [modalData, setModalData] = useState<PlanUpgradeData | null>(null)

const openModal = (type: PricingModalType, data?: PlanUpgradeData | null) => {
setModalType(type)
setModalData(data || null)
onOpen()
}

const closeModal = () => {
setModalType(null)
setModalData(null)
onClose()
}

return (
<PricingModalProviderContext value={{ openModal, closeModal, modalType, modalData }}>
{children}

{/* Render modals dynamically based on the modalType */}
{modalType === 'tierUpgrade' && isOpen && <TierUpgradeModal isOpen onClose={closeModal} {...modalData} />}
{modalType === 'planUpgrade' && isOpen && <PlanUpgradeModal isOpen onClose={closeModal} {...modalData} />}
{modalType === 'subscription' && isOpen && <SubscriptionModal isOpen onClose={closeModal} />}
</PricingModalProviderContext>
)
}

export { usePricingModal }
98 changes: 66 additions & 32 deletions src/components/Pricing/Plans.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import { Link as ReactRouterLink } from 'react-router-dom'
import { ApiEndpoints } from '~components/Auth/api'
import { useSubscription } from '~components/Auth/Subscription'
import { useAuth } from '~components/Auth/useAuth'
import { StripeId } from '~constants'
import { PlanId } from '~constants'
import PricingCard from './Card'

export type Plan = {
Expand Down Expand Up @@ -64,15 +64,10 @@ export const usePlans = () => {
})
}

export const SubscriptionPlans = () => {
export const usePlanTranslations = () => {
const { t } = useTranslation()
const { data: plans, isLoading } = usePlans()
const { permission } = useSubscription()

const [selectedCensusSize, setSelectedCensusSize] = useState<number | null>(null)

const translations = {
[StripeId.Free]: {
[PlanId.Free]: {
title: t('pricing.free_title', { defaultValue: 'Free' }),
subtitle: t('pricing.free_subtitle', {
defaultValue: 'Small organizations or community groups with basic voting needs.',
Expand All @@ -90,7 +85,7 @@ export const SubscriptionPlans = () => {
t('pricing.gpdr_compilance', { defaultValue: 'GDPR compliance' }),
],
},
[StripeId.Essential]: {
[PlanId.Essential]: {
title: t('pricing.essential_title', { defaultValue: 'Essential' }),
subtitle: t('pricing.essential_subtitle', {
defaultValue: 'Small or medium-sized orgs or community groups with basic voting needs.',
Expand All @@ -104,7 +99,7 @@ export const SubscriptionPlans = () => {
t('pricing.gpdr_compilance'),
],
},
[StripeId.Premium]: {
[PlanId.Premium]: {
title: t('pricing.premium_title', { defaultValue: 'Premium' }),
subtitle: t('pricing.premium_subtitle', {
defaultValue: 'Larger amount that require more advanced features.',
Expand All @@ -118,30 +113,69 @@ export const SubscriptionPlans = () => {
t('pricing.gpdr_compilance'),
],
},
[PlanId.Custom]: {
title: t('pricing.custom_title', { defaultValue: 'Custom' }),
subtitle: t('pricing.custom_subtitle', {
defaultValue:
'Large organizations, enterprises, and institutions requiring extensive customization and support',
}),
features: [
t('pricing.all_features', { defaultValue: 'All features & voting types' }),
t('pricing.up_to_admins', { admin: 10, org: 5 }),
t('pricing.unlimited_yearly_processes', { defaultValue: 'Unlimited yearly voting processes' }),
t('pricing.white_label', { defaultValue: 'White label solution' }),
t('pricing.advanced_analytitcs', { defaultValue: 'Advanced reporting and analytics' }),
t('pricing.dedicated_manager', { defaultValue: 'Dedicated account manager' }),
t('pricing.priority_support', { defaultValue: 'Priority ticket support' }),
t('pricing.gpdr_compilance'),
],
},
}

return translations
}

export const SubscriptionPlans = () => {
const { t } = useTranslation()
const { data: plans, isLoading } = usePlans()
const { permission } = useSubscription()
const translations = usePlanTranslations()

const [selectedCensusSize, setSelectedCensusSize] = useState<number | null>(null)

const censusSizeOptions = useMemo(() => {
const allTiers = plans
// Exclude plans with null censusSizeTiers
?.filter((plan) => plan.censusSizeTiers)
.flatMap((plan) =>
plan.censusSizeTiers!.map((tier) => {
const from = tier.upTo === 100 ? 1 : tier.upTo - 99
return {
label: t('pricing.members_size', { defaultValue: '{{ from }}-{{ to }} members', from, to: tier.upTo }),
value: tier.upTo,
}
})
)
const uniqueTiers = Array.from(new Map(allTiers?.map((tier) => [tier.value, tier])).values())
return uniqueTiers || []
}, [plans])
if (!plans) return []

// Step 1: Merge censusSizeTiers from all plans, removing duplicates
const mergedTiers = plans
.flatMap((plan) => plan.censusSizeTiers || []) // Combine all tiers
.reduce((acc, tier) => {
if (!acc.has(tier.upTo)) {
acc.set(tier.upTo, tier) // Keep unique `upTo` values
}
return acc
}, new Map<number, { upTo: number }>())

// Step 2: Create options array from merged and sorted tiers
const sortedTiers = Array.from(mergedTiers.values()).sort((a, b) => a.upTo - b.upTo)

const options = sortedTiers.map((tier, idx) => {
const previous = sortedTiers[idx - 1] || { upTo: 0 }
const from = previous.upTo + 1
return {
label: t('pricing.members_size', { defaultValue: '{{ from }}-{{ to }} members', from, to: tier.upTo }),
value: tier.upTo,
}
})

return options
}, [plans, t])

const cards = useMemo(() => {
if (!plans) return []

return plans.map((plan) => ({
popular: plan.default,
popular: plan.id === PlanId.Essential,
title: translations[plan.id]?.title || plan.name,
subtitle: translations[plan.id]?.subtitle || '',
price: plan.startingPrice / 100,
Expand All @@ -159,7 +193,7 @@ export const SubscriptionPlans = () => {
<Select options={censusSizeOptions} onChange={(selected) => setSelectedCensusSize(selected?.value || null)} />
</Flex>
{isLoading && <Progress colorScheme='brand' size='xs' isIndeterminate />}
<Flex gap={5} alignItems='start'>
<Flex gap={5} justifyContent='space-evenly' flexWrap='wrap'>
{cards.map((card, idx) => (
<PricingCard key={idx} plan={plans[idx]} {...card} />
))}
Expand All @@ -169,15 +203,15 @@ export const SubscriptionPlans = () => {
}

export const SubscriptionModal = ({
isOpenModal,
onCloseModal,
isOpen,
onClose,
title,
}: {
isOpenModal: boolean
onCloseModal: () => void
isOpen: boolean
onClose: () => void
title?: ReactNode
}) => (
<Modal isOpen={isOpenModal} onClose={onCloseModal} variant='pricing-modal' size='full'>
<Modal isOpen={isOpen} onClose={onClose} variant='pricing-modal' size='full'>
<ModalOverlay />
<ModalContent>
<ModalHeader>
Expand Down
Loading

0 comments on commit 00d43e8

Please sign in to comment.