diff --git a/src/Providers.tsx b/src/Providers.tsx index fc5ec1e4e..08bd7c8d7 100644 --- a/src/Providers.tsx +++ b/src/Providers.tsx @@ -8,6 +8,7 @@ import { useTranslation } from 'react-i18next' import { useAccount, useWalletClient, WagmiConfig } from 'wagmi' import { SaasAccountProvider } from '~components/Account/SaasAccountContext' import { AuthProvider } from '~components/Auth/AuthContext' +import { SubscriptionProvider } from '~components/Auth/Subscription' import { walletClientToSigner } from '~constants/wagmi-adapters' import { VocdoniEnvironment } from './constants' import { chains, wagmiConfig } from './constants/rainbow' @@ -32,7 +33,9 @@ export const Providers = () => { const SaasProviders = ({ children }: PropsWithChildren<{}>) => ( - {children} + + {children} + ) diff --git a/src/components/Auth/Subscription.tsx b/src/components/Auth/Subscription.tsx new file mode 100644 index 000000000..7470c8bc7 --- /dev/null +++ b/src/components/Auth/Subscription.tsx @@ -0,0 +1,86 @@ +import { createContext } from '@chakra-ui/react-utils' +import { useQuery } from '@tanstack/react-query' +import { useClient } from '@vocdoni/react-providers' +import { dotobject, ensure0x } from '@vocdoni/sdk' +import { ReactNode, useMemo } from 'react' +import { useAuth } from '~components/Auth/useAuth' +import { ApiEndpoints } from './api' + +type PermissionsContextType = { + permission: (key: string) => any + subscription: SubscriptionType + loading: boolean +} + +type SubscriptionType = { + subscriptionDetails: { + planID: number + startDate: string // ISO 8601 Date String + endDate: string // ISO 8601 Date String + renewalDate: string // ISO 8601 Date String + active: boolean + maxCensusSize: number + } + usage: { + sentSMS: number + sentEmails: number + subOrgs: number + members: number + } + plan: { + id: number + name: string + stripeID: string + default: boolean + organization: { + memberships: number + subOrgs: number + censusSize: number + } + votingTypes: { + approval: boolean + ranked: boolean + weighted: boolean + } + features: { + personalization: boolean + emailReminder: boolean + smsNotification: boolean + } + } +} + +const [SubscriptionProvider, useSubscription] = createContext({ + name: 'PermissionsContext', + errorMessage: 'usePermissions must be used within a PermissionsProvider', +}) + +const SubscriptionProviderComponent: React.FC<{ children: ReactNode }> = ({ children }) => { + const { bearedFetch } = useAuth() + const { account } = useClient() + + // Fetch organization subscription details + // TODO: In the future, this may be merged with the role permissions (not yet defined) + const { data: subscription, isFetching } = useQuery({ + queryKey: ['organizationSubscription', account?.address], + queryFn: () => + bearedFetch( + ApiEndpoints.OrganizationSubscription.replace('{address}', ensure0x(account?.address)) + ), + // Cache for 15 minutes + staleTime: 15 * 60 * 1000, + enabled: !!account?.address, + }) + + // Helper function to access permission using dot notation + const permission = useMemo(() => { + return (key: string) => { + if (!subscription.plan) return undefined + return dotobject(subscription.plan, key) + } + }, [subscription]) + + return +} + +export { SubscriptionProviderComponent as SubscriptionProvider, useSubscription } diff --git a/src/components/Auth/api.ts b/src/components/Auth/api.ts index 877a6415c..768af15d7 100644 --- a/src/components/Auth/api.ts +++ b/src/components/Auth/api.ts @@ -1,14 +1,15 @@ type MethodTypes = 'GET' | 'POST' | 'PUT' | 'DELETE' export enum ApiEndpoints { + InviteAccept = 'organizations/{address}/members/accept', Login = 'auth/login', Me = 'users/me', - InviteAccept = 'organizations/{address}/members/accept', Organization = 'organizations/{address}', OrganizationMembers = 'organizations/{address}/members', OrganizationPendingMembers = 'organizations/{address}/members/pending', Organizations = 'organizations', OrganizationsRoles = 'organizations/roles', + OrganizationSubscription = 'organizations/{address}/subscription', Password = 'users/password', PasswordRecovery = 'users/password/recovery', PasswordReset = 'users/password/reset', diff --git a/src/components/Dashboard/Menu/Options.tsx b/src/components/Dashboard/Menu/Options.tsx index 14f100040..c06beeb5d 100644 --- a/src/components/Dashboard/Menu/Options.tsx +++ b/src/components/Dashboard/Menu/Options.tsx @@ -1,4 +1,4 @@ -import { Box, Button, Collapse, useDisclosure } from '@chakra-ui/react' +import { Box, Collapse, useDisclosure } from '@chakra-ui/react' import { OrganizationName } from '@vocdoni/chakra-components' import { useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' @@ -6,7 +6,6 @@ import { HiSquares2X2 } from 'react-icons/hi2' import { IoIosSettings } from 'react-icons/io' import { generatePath, matchPath, useLocation } from 'react-router-dom' import { Routes } from '~src/router/routes' -import { PricingModal } from '../PricingModal' import { DashboardMenuItem } from './Item' type MenuItem = { @@ -55,7 +54,7 @@ export const DashboardMenuOptions = () => { { label: t('organization.organization'), route: Routes.dashboard.organization }, { label: t('team'), route: Routes.dashboard.team }, // { label: t('billing'), route: '#billing' }, - // { label: t('subscription'), route: '#subscription' }, + { label: t('subscription'), route: Routes.dashboard.subscription }, { label: t('profile'), route: Routes.dashboard.profile }, ], }, @@ -84,8 +83,6 @@ export const DashboardMenuOptions = () => { return ( - - {menuItems.map((item, index) => ( diff --git a/src/components/Dashboard/PricingModal.tsx b/src/components/Dashboard/PricingModal.tsx index d948d6595..7fe03faab 100644 --- a/src/components/Dashboard/PricingModal.tsx +++ b/src/components/Dashboard/PricingModal.tsx @@ -11,9 +11,11 @@ import { Select, Text, } from '@chakra-ui/react' +import { ReactNode } from 'react' import { Trans, useTranslation } from 'react-i18next' import { Link as ReactRouterLink } from 'react-router-dom' import PricingCard from '~components/Organization/Dashboard/PricingCard' +import { useStripePlans } from '~src/queries/stripe' type CardProps = { popular: boolean @@ -23,9 +25,9 @@ type CardProps = { features: string[] } -export const PricingModal = ({ isOpenModal, onCloseModal }: { isOpenModal: boolean; onCloseModal: () => void }) => { +export const PricingContents = () => { const { t } = useTranslation() - + const { data } = useStripePlans() const cards: CardProps[] = [ { popular: false, @@ -81,46 +83,54 @@ export const PricingModal = ({ isOpenModal, onCloseModal }: { isOpenModal: boole ], }, ] - return ( - - - - - You need to upgrade to use this feature - - - - {cards.map((card, idx) => ( - - ))} - - - - - If you need more voters, you can select it here: - - - + return cards.map((card, idx) => ) +} + +export const SubscriptionModal = ({ + isOpenModal, + onCloseModal, + title, +}: { + isOpenModal: boolean + onCloseModal: () => void + title?: ReactNode +}) => ( + + + + + {title || You need to upgrade to use this feature} + + + + + + + + - - Currently you are subscribed to the 'Your plan' subscription. If you upgrade, we will only charge the - yearly difference. In the next billing period, starting on 'dd/mm/yy' you will pay for the new select - plan. - + If you need more voters, you can select it here: - - - Need some help? - - - - - - - ) -} + + + + + Currently you are subscribed to the 'Your plan' subscription. If you upgrade, we will only charge the yearly + difference. In the next billing period, starting on 'dd/mm/yy' you will pay for the new select plan. + + + + + Need some help? + + + + + + +) diff --git a/src/components/Organization/Subscription.tsx b/src/components/Organization/Subscription.tsx new file mode 100644 index 000000000..948b5d9f2 --- /dev/null +++ b/src/components/Organization/Subscription.tsx @@ -0,0 +1,91 @@ +import { + Avatar, + Button, + Progress, + Table, + TableContainer, + Tag, + Tbody, + Td, + Th, + Thead, + Tr, + useDisclosure, +} from '@chakra-ui/react' +import { Trans, useTranslation } from 'react-i18next' +import { useSubscription } from '~components/Auth/Subscription' +import { SubscriptionModal } from '~components/Dashboard/PricingModal' + +export const Subscription = () => { + const { t } = useTranslation() + const { isOpen, onClose, onOpen } = useDisclosure() + + return ( + <> + + + + + ) +} + +export const SubscriptionList = () => { + const { subscription, loading } = useSubscription() + + if (loading) { + return + } + + if (!subscription) { + return null + } + + return ( + + + + + + + + + + + + + + + + + + + +
+ Your Subscription + + Price + + Since + + Next Billing +
+ + {subscription.plan.name} ({subscription.plan.organization.memberships} members) + + undefined + + {new Date(subscription.subscriptionDetails.startDate).toLocaleDateString()} + + {new Date(subscription.subscriptionDetails.renewalDate).toLocaleDateString()} + + +
+
+ ) +} + +export const SubscriptionHistory = () => {} diff --git a/src/elements/dashboard/subscription.tsx b/src/elements/dashboard/subscription.tsx new file mode 100644 index 000000000..c94521fe2 --- /dev/null +++ b/src/elements/dashboard/subscription.tsx @@ -0,0 +1,23 @@ +import { useEffect } from 'react' +import { useTranslation } from 'react-i18next' +import { useOutletContext } from 'react-router-dom' +import { DashboardContents } from '~components/Layout/Dashboard' +import { Subscription } from '~components/Organization/Subscription' +import { DashboardLayoutContext } from '~elements/LayoutDashboard' + +const SubscriptionPage = () => { + const { t } = useTranslation() + const { setTitle } = useOutletContext() + + useEffect(() => { + setTitle(t('subscription', { defaultValue: 'Subscription' })) + }, []) + + return ( + + + + ) +} + +export default SubscriptionPage diff --git a/src/i18n/locales/ca.json b/src/i18n/locales/ca.json index 4b3d169e9..2fdb76669 100644 --- a/src/i18n/locales/ca.json +++ b/src/i18n/locales/ca.json @@ -175,6 +175,8 @@ "success_title": "Els tokens s'han reclamat amb èxit" } }, + "confirm_password": "Confirm Password", + "confirm_password_placeholder": "Confirm your new password", "contact_us": "Contact us", "control": "Control:", "copy": { @@ -264,7 +266,8 @@ "loading_page": "Càrrega de pàgina errònia", "loading_roles": "Carregant rols", "not_found": "Ho sentim, no s'ha trobat la pàgina que estaves buscant.", - "return_to_home": "Ves a l'inici" + "return_to_home": "Ves a l'inici", + "title": "Error" }, "error_text": "Ho sentim, no s'ha trobat la pàgina que busqueu", "faucet": { @@ -705,11 +708,20 @@ "title": "Esteupreparats per a una nova era en la governança Web3?" }, "invite": { + "account_not_verified": "Your account is not verified. Please verify your account to continue.", + "create_account_subtitle": "You need an account first, in order to accept your invite", + "create_account_title": "Create your account", "error": "Error", + "go_to_verify": "Verify Account", + "invalid_link": "Invalid invite link received", + "processing": "Processing your invitation...", "select_option": "Selecciona una opció", "subtitle": "Treballeu junts per aconseguir els vostres objectius", "success": "Invitació enviada!", + "success_description": "You can now sign in", + "success_title": "Invitation accepted", "title": "Convida gent al teu equio", + "unexpected_error": "", "user_invited": "S'ha enviat una invitació a {{ email }}" }, "invite_people": "Convidar Gent", @@ -763,6 +775,8 @@ "description2": "Com a paquet de benvinguda, rebràs {{ faucetAmount }} tokens gratuïts per començar a utilitzar la nostra plataforma.", "title": "Nova Organització" }, + "new_password": "New Password", + "new_password_placeholder": "Enter your new password", "new_voting": "Nova votació", "not_registred_yet": "Encara no estàs registrat?", "open_in_explorer": "Obre a l'explorador", @@ -794,6 +808,11 @@ "overwrite": "overwrite", "password": "Contrasenya", "password_placeholder": "Min 8 characters", + "password_reset": { + "subtitle": "If your email corresponds to an existing account, you'll receive an email with a code to reset your password.", + "title": "Password reset" + }, + "passwords_do_not_match": "", "personalization": "personalization", "preview": "Preview", "pricing_card": { @@ -836,7 +855,8 @@ }, "premium_subtitle": "Larger amount that require more advanced features.", "premium_title": "Premium", - "title": "You need to upgrade to use this feature", + "title": "Els nostres plans", + "upgrade_title": "You need to upgrade to use this feature", "your_plan": "Currently you are subscribed to the 'Your plan' subscription. If you upgrade, we will only charge the yearly difference. In the next billing period, starting on 'dd/mm/yy' you will pay for the new select plan." }, "privacy_policy": "Privacy Policy", @@ -1008,6 +1028,7 @@ }, "ranked": "ranked", "remove": "Remove", + "reset_password_button": "Reset Password", "rights": "© 2024 Associació Vocdoni. Tots els drets reservats.", "role": { "read_permission": "Read-only access", @@ -1060,6 +1081,9 @@ "read_more": "Llegeix més" }, "user_management": "Gestió d'usuaris", + "verification_code": "Verification Code", + "verification_code_placeholder": "Enter the verification code", + "verification_code_resent": "Verification code resent!", "verify": { "account_created_succesfully": "Account created successfully!", "email_sent": "Email sent successfully", diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 60060b7da..f7e31af68 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -172,6 +172,8 @@ "success_title": "Tokens were successfully claimed" } }, + "confirm_password": "Confirm Password", + "confirm_password_placeholder": "Confirm your new password", "contact_us": "Contact us", "control": "Control:", "copy": { @@ -260,7 +262,8 @@ "loading_page": "Error loading the page", "loading_roles": "Loading roles", "not_found": "Sorry, the page you are looking for was not found", - "return_to_home": "Go to home" + "return_to_home": "Go to home", + "title": "Error" }, "error_text": "Sorry, the page you were looking for was not found", "faucet": { @@ -697,11 +700,20 @@ "title": "are youReady for a new Era of Web3 governance?" }, "invite": { + "account_not_verified": "Your account is not verified. Please verify your account to continue.", + "create_account_subtitle": "You need an account first, in order to accept your invite", + "create_account_title": "Create your account", "error": "Error", + "go_to_verify": "Verify Account", + "invalid_link": "Invalid invite link received", + "processing": "Processing your invitation...", "select_option": "Select an option", "subtitle": "Work together on projects", "success": "Invitation sent successfully!", + "success_description": "You can now sign in", + "success_title": "Invitation accepted", "title": "Invite people to your team", + "unexpected_error": "", "user_invited": "Email sent to {{email}}" }, "invite_people": "Invite People", @@ -755,6 +767,8 @@ "description2": "You will have the chance to claim tokens later from the faucet to create proposals and engage with your community.", "title": "Create your organization" }, + "new_password": "New Password", + "new_password_placeholder": "Enter your new password", "new_voting": "New Voting", "not_registred_yet": "Not registered yet?", "open_in_explorer": "Open in explorer", @@ -786,6 +800,11 @@ "overwrite": "overwrite", "password": "Password", "password_placeholder": "Min 8 characters", + "password_reset": { + "subtitle": "If your email corresponds to an existing account, you'll receive an email with a code to reset your password.", + "title": "Password reset" + }, + "passwords_do_not_match": "", "personalization": "personalization", "preview": "Preview", "pricing_card": { @@ -828,7 +847,8 @@ }, "premium_subtitle": "Larger amount that require more advanced features.", "premium_title": "Premium", - "title": "You need to upgrade to use this feature", + "title": "Our plans", + "upgrade_title": "You need to upgrade to use this feature", "your_plan": "Currently you are subscribed to the 'Your plan' subscription. If you upgrade, we will only charge the yearly difference. In the next billing period, starting on 'dd/mm/yy' you will pay for the new select plan." }, "privacy_policy": "Privacy Policy", @@ -997,6 +1017,7 @@ }, "ranked": "ranked", "remove": "Remove", + "reset_password_button": "Reset Password", "rights": "© 2024 Vocdoni Association. All Rights Reserved.", "role": { "read_permission": "Read-only access", @@ -1049,6 +1070,9 @@ "read_more": "Read more" }, "user_management": "User Managment", + "verification_code": "Verification Code", + "verification_code_placeholder": "Enter the verification code", + "verification_code_resent": "Verification code resent!", "verify": { "account_created_succesfully": "Account created successfully!", "email_sent": "Email sent successfully", diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index 52247a1cc..6cabeb704 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -175,6 +175,8 @@ "success_title": "Los tokens han sido reclamados correctamente" } }, + "confirm_password": "Confirm Password", + "confirm_password_placeholder": "Confirm your new password", "contact_us": "Contact us", "control": "Control:", "copy": { @@ -264,7 +266,8 @@ "loading_page": "Error al cargar la página", "loading_roles": "Cargando roles", "not_found": "Lo sentimos, la página que buscabas no fue encontrada.", - "return_to_home": "Ves al inicio" + "return_to_home": "Ves al inicio", + "title": "Error" }, "error_text": "Lo sentimos, no se encontró la página que buscabas.", "faucet": { @@ -705,11 +708,20 @@ "title": "¿Estáslisto para una nueva Era en la gobernanza Web3?" }, "invite": { + "account_not_verified": "Your account is not verified. Please verify your account to continue.", + "create_account_subtitle": "You need an account first, in order to accept your invite", + "create_account_title": "Create your account", "error": "Error", + "go_to_verify": "Verify Account", + "invalid_link": "Invalid invite link received", + "processing": "Processing your invitation...", "select_option": "Selecciona una opción", "subtitle": "Trabajar junto a otros es la clave para el éxito.", "success": "¡Invitación enviada!", + "success_description": "You can now sign in", + "success_title": "Invitation accepted", "title": "Invita gente a tu equipo", + "unexpected_error": "", "user_invited": "Se ha mandado a {{ email }}" }, "invite_people": "Invitar Gente", @@ -763,6 +775,8 @@ "description2": "Como paquete de bienvenida, recibirá {{ faucetAmount }} tokens gratis para comenzar a utilizar nuestra plataforma.", "title": "Nueva Organización" }, + "new_password": "New Password", + "new_password_placeholder": "Enter your new password", "new_voting": "Nueva votación", "not_registred_yet": "¿Aún no estás registrado?", "open_in_explorer": "Abrir en explorador", @@ -794,6 +808,11 @@ "overwrite": "overwrite", "password": "Contraseña", "password_placeholder": "Min 8 characters", + "password_reset": { + "subtitle": "If your email corresponds to an existing account, you'll receive an email with a code to reset your password.", + "title": "Password reset" + }, + "passwords_do_not_match": "", "personalization": "personalization", "preview": "Preview", "pricing_card": { @@ -836,7 +855,8 @@ }, "premium_subtitle": "Larger amount that require more advanced features.", "premium_title": "Premium", - "title": "You need to upgrade to use this feature", + "title": "Nuestros planes", + "upgrade_title": "You need to upgrade to use this feature", "your_plan": "Currently you are subscribed to the 'Your plan' subscription. If you upgrade, we will only charge the yearly difference. In the next billing period, starting on 'dd/mm/yy' you will pay for the new select plan." }, "privacy_policy": "Privacy Policy", @@ -1008,6 +1028,7 @@ }, "ranked": "ranked", "remove": "Remove", + "reset_password_button": "Reset Password", "rights": "© 2024 Asociación Vocdoni. Todos los derechos reservados.", "role": { "read_permission": "Read-only access", @@ -1060,6 +1081,9 @@ "read_more": "Leer más" }, "user_management": "Gestión de usuarios", + "verification_code": "Verification Code", + "verification_code_placeholder": "Enter the verification code", + "verification_code_resent": "Verification code resent!", "verify": { "account_created_succesfully": "Account created successfully!", "email_sent": "Email sent successfully", diff --git a/src/queries/stripe.ts b/src/queries/stripe.ts new file mode 100644 index 000000000..0e2a85acb --- /dev/null +++ b/src/queries/stripe.ts @@ -0,0 +1,58 @@ +import { useQuery } from '@tanstack/react-query' + +const STRIPE_API_BASE_URL = 'https://api.stripe.com/v1' + +type StripePrice = { + id: string + object: 'price' + active: boolean + unit_amount: number + unit_amount_decimal: string + currency: string + nickname: string | null + product: string + metadata: Record + recurring: { + aggregate_usage: 'last_during_period' | 'sum' | null + interval: 'day' | 'month' | 'week' | 'year' + interval_count: number + trial_period_days: number | null + usage_type: string + } +} + +type StripePlansResponse = { + data: StripePrice[] + has_more: boolean + object: 'list' + url: string +} + +const fetchStripePlans = async (): Promise => { + const publicKey = import.meta.env.STRIPE_PUBLIC_KEY + if (!publicKey) { + throw new Error('Stripe public key is missing') + } + + const response = await fetch(`${STRIPE_API_BASE_URL}/prices`, { + headers: { + Authorization: `Bearer ${publicKey}`, + }, + }) + + if (!response.ok) { + const error = await response.json() + throw new Error(error.error?.message || 'Failed to fetch Stripe plans') + } + + return response.json() +} + +export const useStripePlans = () => { + return useQuery({ + queryKey: ['stripePlans'], + queryFn: fetchStripePlans, + // Cache for 20 minutes + staleTime: 20 * 60 * 1000, + }) +} diff --git a/src/router/routes/dashboard.tsx b/src/router/routes/dashboard.tsx index d115fee68..dc3647c94 100644 --- a/src/router/routes/dashboard.tsx +++ b/src/router/routes/dashboard.tsx @@ -4,6 +4,7 @@ import { lazy } from 'react' import { useQueryClient } from '@tanstack/react-query' import { Params } from 'react-router-dom' import { Profile } from '~elements/dashboard/profile' +import SubscriptionPage from '~elements/dashboard/subscription' import Error from '~elements/Error' import LayoutDashboard from '~elements/LayoutDashboard' import { paginatedElectionsQuery } from '~src/queries/organization' @@ -91,6 +92,14 @@ export const useDashboardRoutes = () => { ), }, + { + path: Routes.dashboard.subscription, + element: ( + + + + ), + }, ], }, ], diff --git a/src/router/routes/index.ts b/src/router/routes/index.ts index 7e81aec3f..8d14e9f2c 100644 --- a/src/router/routes/index.ts +++ b/src/router/routes/index.ts @@ -16,6 +16,7 @@ export const Routes = { processes: '/admin/processes/:page?/:status?', profile: '/admin/profile', team: '/admin/team', + subscription: '/admin/subscription', }, faucet: '/faucet', organization: '/organization/:address',