From f96376c357f0ee6064f71b74635a9630031a11e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=92scar=20Casajuana?= Date: Thu, 14 Nov 2024 09:30:50 +0100 Subject: [PATCH] WIP What's done so far: - Created the new "subscription" view - Recovered the menu link to this new page - Show a table with the current subscription. It's been done as a table since it looks like it, but I'm not sure if it's useful considering there aren't plans for multiple subscriptions - Removed the "open modal" temporary button from the main menu - Such modal can now be opened using the "view plans & pricing" button in the new page - A new Subscription Provider/Context has been created, with a `permissions` method in order to check for permissions of the current plan, but it has not been applied yet anywhere - The pricing modal has been minimally changed to allow setting a custom title, and moving its contents into an independent component --- src/Providers.tsx | 5 +- src/components/Auth/Subscription.tsx | 86 ++++++++++++++++++ src/components/Auth/api.ts | 3 +- src/components/Dashboard/Menu/Options.tsx | 9 +- src/components/Dashboard/PricingModal.tsx | 94 +++++++++++--------- src/components/Organization/Invite.tsx | 18 +++- src/components/Organization/Subscription.tsx | 91 +++++++++++++++++++ src/components/Organization/Team.tsx | 4 +- src/constants/index.ts | 16 +++- src/elements/dashboard/subscription.tsx | 23 +++++ src/elements/dashboard/team.tsx | 2 +- src/i18n/locales/ca.json | 49 ++++++++-- src/i18n/locales/en.json | 37 +++++++- src/i18n/locales/es.json | 37 +++++++- src/queries/stripe.ts | 58 ++++++++++++ src/router/routes/dashboard.tsx | 9 ++ src/router/routes/index.ts | 1 + 17 files changed, 470 insertions(+), 72 deletions(-) create mode 100644 src/components/Auth/Subscription.tsx create mode 100644 src/components/Organization/Subscription.tsx create mode 100644 src/elements/dashboard/subscription.tsx create mode 100644 src/queries/stripe.ts diff --git a/src/Providers.tsx b/src/Providers.tsx index fc5ec1e4..08bd7c8d 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 00000000..628b540f --- /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 || !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 877a6415..768af15d 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 14f10004..098e5a5c 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 = { @@ -53,9 +52,9 @@ export const DashboardMenuOptions = () => { icon: IoIosSettings, children: [ { label: t('organization.organization'), route: Routes.dashboard.organization }, - { label: t('team'), route: Routes.dashboard.team }, + { label: t('team.title'), 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 d948d659..7fe03faa 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/Invite.tsx b/src/components/Organization/Invite.tsx index 55996557..9b38f099 100644 --- a/src/components/Organization/Invite.tsx +++ b/src/components/Organization/Invite.tsx @@ -30,9 +30,12 @@ import { FormProvider, useController, useForm, useFormContext } from 'react-hook import { Trans, useTranslation } from 'react-i18next' import { ApiEndpoints } from '~components/Auth/api' 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 { SubscriptionPermission } from '~constants' import { CallbackProvider, useCallbackContext } from '~utils/callback-provider' +import { useTeamMembers } from './Team' type InviteData = { email: string @@ -178,12 +181,21 @@ const InviteForm = () => { export const InviteToTeamModal = (props: ButtonProps) => { const { isOpen, onOpen, onClose } = useDisclosure() + const { permission } = useSubscription() + const { t } = useTranslation() + const { data: members, isLoading } = useTeamMembers() + + const canInvite = permission(SubscriptionPermission.Memberships) > (members?.length || 0) return ( <> - + {canInvite ? ( + + ) : ( + You must upgrade! + )} onClose()}> diff --git a/src/components/Organization/Subscription.tsx b/src/components/Organization/Subscription.tsx new file mode 100644 index 00000000..948b5d9f --- /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/components/Organization/Team.tsx b/src/components/Organization/Team.tsx index 99122999..0b379ce7 100644 --- a/src/components/Organization/Team.tsx +++ b/src/components/Organization/Team.tsx @@ -28,7 +28,7 @@ type PendingTeamMembersResponse = { } // Fetch hook for team members -const useTeamMembers = ({ +export const useTeamMembers = ({ options, }: { options?: Omit, 'queryKey' | 'queryFn'> @@ -44,7 +44,7 @@ const useTeamMembers = ({ } // Fetch hook for pending members -const usePendingTeamMembers = ({ +export const usePendingTeamMembers = ({ options, }: { options?: Omit, 'queryKey' | 'queryFn'> diff --git a/src/constants/index.ts b/src/constants/index.ts index e1703b2e..c12f442c 100644 --- a/src/constants/index.ts +++ b/src/constants/index.ts @@ -12,8 +12,22 @@ export const InnerContentsMaxWidth = { xl: '800px', } -const evocdoni = import.meta.env.VOCDONI_ENVIRONMENT +export enum SubscriptionPermission { + Name = 'name', + StripeID = 'stripeID', + Default = 'default', + Memberships = 'organization.memberships', + SubOrgs = 'organization.subOrgs', + CensusSize = 'organization.censusSize', + ApprovalVoting = 'votingTypes.approval', + RankedVoting = 'votingTypes.ranked', + WeightedVoting = 'votingTypes.weighted', + Personalization = 'features.personalization', + EmailReminder = 'features.emailReminder', + SmsNotification = 'features.smsNotification', +} +const evocdoni = import.meta.env.VOCDONI_ENVIRONMENT let explorer = 'https://explorer.vote' if (['stg', 'dev'].includes(evocdoni)) { explorer = `https://${evocdoni}.explorer.vote` diff --git a/src/elements/dashboard/subscription.tsx b/src/elements/dashboard/subscription.tsx new file mode 100644 index 00000000..c94521fe --- /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/elements/dashboard/team.tsx b/src/elements/dashboard/team.tsx index 13161d1e..01a45263 100644 --- a/src/elements/dashboard/team.tsx +++ b/src/elements/dashboard/team.tsx @@ -12,7 +12,7 @@ const OrganizationTeam = () => { // Set layout variables useEffect(() => { - setTitle(t('team', { defaultValue: 'Team' })) + setTitle(t('team.title', { defaultValue: 'Team' })) }, [setTitle]) return ( diff --git a/src/i18n/locales/ca.json b/src/i18n/locales/ca.json index 4b3d169e..0509254f 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,14 +708,23 @@ "title": "Esteupreparats per a una nova era en la governança Web3?" }, "invite": { + "account_not_verified": "El teu compte no està verificat. Si et plau, verifica el teu compte per continuar.", + "create_account_subtitle": "Necessites crear un compte per tal d'acceptar la invitació", + "create_account_title": "Crea un compte", "error": "Error", + "go_to_verify": "Verifica Compte", + "invalid_link": "Enllaç d'invitació no vàlid", + "processing": "Processant la teva invitació...", "select_option": "Selecciona una opció", - "subtitle": "Treballeu junts per aconseguir els vostres objectius", - "success": "Invitació enviada!", - "title": "Convida gent al teu equio", - "user_invited": "S'ha enviat una invitació a {{ email }}" + "subtitle": "Treballeu conjuntament en projectes", + "success": "Invitació enviada correctament!", + "success_description": "Ja pots iniciar sessió", + "success_title": "Invitació acceptada", + "title": "Convida gent al teu equip", + "unexpected_error": "", + "user_invited": "Email enviat a {{email}}" }, - "invite_people": "Convidar Gent", + "invite_people": "Convida Gent", "keep_me_logged": "Mantén-me connectat", "link": { "discord": "Enllaç al Discord de Vocdoni", @@ -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", @@ -1043,8 +1064,15 @@ "label": "Surname", "required": "Surname is required" }, - "team": "Equip", - "teams": "Teams", + "team": { + "expiration": "Expiració", + "member_name": "Nom", + "member_role": "Rol", + "pending_members": "Invitacions pendents", + "team_members": "Membres de l'equip", + "title": "Equip" + }, + "teams": "Equips", "terms_and_conditions": "Terms and Conditions", "terms_of_use": "Condicions d'ús", "total_votes_submitted": "Total Votes Submitted", @@ -1060,6 +1088,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 60060b7d..112f568c 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", @@ -1032,7 +1053,14 @@ "label": "Surname", "required": "Surname is required" }, - "team": "Team", + "team": { + "expiration": "Expiration", + "member_name": "Name", + "member_role": "Role", + "pending_members": "Pending Invitations", + "team_members": "Team members", + "title": "Team" + }, "teams": "Teams", "terms_and_conditions": "Terms and Conditions", "terms_of_use": "Terms of Use", @@ -1049,6 +1077,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 52247a1c..22fd615f 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", @@ -1043,7 +1064,14 @@ "label": "Surname", "required": "Surname is required" }, - "team": "Equipo", + "team": { + "expiration": "Expiración", + "member_name": "Nombre", + "member_role": "Rol", + "pending_members": "Invitaciones pendientes", + "team_members": "Miembros del equipo", + "title": "Equipo" + }, "teams": "Teams", "terms_and_conditions": "Terms and Conditions", "terms_of_use": "Condiciones de uso", @@ -1060,6 +1088,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 00000000..0e2a85ac --- /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 d115fee6..dc3647c9 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 7e81aec3..8d14e9f2 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',