feat: :sparkles: Integrate Stripe prices grid #554
jump to
@@ -39,4 +39,22 @@ await pMap(events, strapi.service("api::event.event").sendEndRecap, {
concurrency: 5, }); }, + /** + * Clean unpaid events + * Every sunday at 02:00 + */ + "0 2 * * 0": async ({ strapi }) => { + try { + const { count } = await strapi.entityService.deleteMany( + "api::event.event", + { + filters: { unpaid: { $eq: true } }, + } + ); + strapi.log.info(`${count} unpaid events deleted`); + } catch (error) { + strapi.log.error(`Can't delete unpaid events`); + console.error(error); + } + }, };
@@ -43,34 +43,31 @@ },
"type": "string", "required": true }, - "caroster_plus_payment_link_id": { + "caroster_plus_description": { "pluginOptions": { "i18n": { - "localized": false + "localized": true } }, - "type": "string", - "required": true, - "regex": "^plink_.*", - "private": true + "type": "richtext" }, - "caroster_plus_payment_link": { + "caroster_plus_pricing_grid_id": { "pluginOptions": { "i18n": { - "localized": false + "localized": true } }, "type": "string", - "required": true, - "regex": "https://buy.stripe.com/.*" + "required": true }, - "caroster_plus_description": { + "caroster_plus_publishable_key": { "pluginOptions": { "i18n": { "localized": true } }, - "type": "richtext" + "type": "string", + "required": true } } }
@@ -17,14 +17,10 @@ );
return; } - const paymentLink = stripeEvent.data.object.payment_link as string; - const moduleProduct = await getModuleProduct(paymentLink); - if (!moduleProduct) { - strapi.log.error( - `Can't retrieve product/module in Stripe webhook. Is module enabled ? Webhook ID: ${stripeEvent.id}` - ); - return; - } + const moduleProduct = { + name: "caroster-plus", + notificationType: "EnabledCarosterPlus" as const, + }; const event = await strapi.db.query("api::event.event").findOne({ where: { uuid: eventUuid },@@ -52,7 +48,7 @@
if (event.creator) strapi.entityService.create("api::notification.notification", { data: { - type: event.unpaid // unpaid before update + type: event.unpaid // unpaid before event update ? "EventCreated" : moduleProduct.notificationType, event,@@ -66,18 +62,3 @@ );
} }, }); - -const getModuleProduct = async (paymentLink: string) => { - const modulesConfig = await strapi.entityService.findOne( - "api::module.module", - 1 - ); - const modules = []; - if (modulesConfig.caroster_plus_enabled) { - modules.push([ - modulesConfig.caroster_plus_payment_link_id, - { name: "caroster-plus", notificationType: "EnabledCarosterPlus" }, - ]); - } - return Object.fromEntries(modules)[paymentLink]; -};
@@ -476,28 +476,27 @@ i18n: {
localized: true; }; }>; - caroster_plus_payment_link: Attribute.String & - Attribute.Required & + caroster_plus_price: Attribute.Decimal & Attribute.SetPluginOptions<{ i18n: { localized: false; }; - }>; - caroster_plus_payment_link_id: Attribute.String & + }> & + Attribute.DefaultTo<0>; + caroster_plus_pricing_grid_id: Attribute.String & Attribute.Required & - Attribute.Private & Attribute.SetPluginOptions<{ i18n: { - localized: false; + localized: true; }; }>; - caroster_plus_price: Attribute.Decimal & + caroster_plus_publishable_key: Attribute.String & + Attribute.Required & Attribute.SetPluginOptions<{ i18n: { - localized: false; + localized: true; }; - }> & - Attribute.DefaultTo<0>; + }>; createdAt: Attribute.DateTime; createdBy: Attribute.Relation< 'api::module.module',
@@ -15,7 +15,6 @@ import Markdown from '../../components/Markdown';
import useLocale from '../../hooks/useLocale'; import usePermissions from '../../hooks/usePermissions'; import {Event as EventType, Module} from '../../generated/graphql'; -import useProfile from '../../hooks/useProfile'; interface Props { event: EventType;@@ -24,18 +23,13 @@ }
const CarosterPlusOption = ({event, modulesSettings}: Props) => { const {t} = useTranslation(); - const {profile} = useProfile(); const { userPermissions: {canEditEventOptions}, } = usePermissions(); const {locale} = useLocale(); - const { - caroster_plus_name, - caroster_plus_description, - caroster_plus_price, - caroster_plus_payment_link, - } = modulesSettings; + const {caroster_plus_name, caroster_plus_description, caroster_plus_price} = + modulesSettings; return ( <Card@@ -65,7 +59,7 @@ )}
> <Box> <Button - href={`${caroster_plus_payment_link}?client_reference_id=${event.uuid}&locale=${locale}&prefilled_email=${profile?.email}`} + href={`/${locale}/e/${event.uuid}/prices`} disabled={!canEditEventOptions()} sx={{ backgroundColor: 'primary.light',
@@ -7,15 +7,14 @@ import useEventCreationStore from '../../stores/useEventCreationStore';
import {ProfileDocument, useCreateEventMutation} from '../../generated/graphql'; import useAddToEvents from '../../hooks/useAddToEvents'; import {setCookie} from '../../lib/cookies'; +import {useRouter} from 'next/router'; -type Props = { - paymentLink: string; -}; +type Props = {}; const PlusAction = (props: Props) => { - const {paymentLink} = props; const {t} = useTranslation(); const session = useSession(); + const router = useRouter(); const isAuthenticated = session.status === 'authenticated'; const {locale} = useLocale(); const event = useEventCreationStore(s => s.event);@@ -40,7 +39,9 @@ const createdEvent = data.createEvent.data;
addToUserEvents(createdEvent.id); useEventCreationStore.persist.clearStorage(); setCookie('redirectPath', `/${locale}/e/${createdEvent.attributes.uuid}`); - window.location.href = `${paymentLink}?client_reference_id=${createdEvent.attributes.uuid}&locale=${locale}&prefilled_email=${profile?.email}`; + router.push( + `/${locale}/new/prices?eventId=${createdEvent.attributes.uuid}` + ); } catch (error) { console.error(error); }
@@ -2,16 +2,17 @@ import {Box, Paper, Typography} from '@mui/material';
import {useTranslation} from 'next-i18next'; import BasicAction from './BasicAction'; import PlusAction from './PlusAction'; -import {Module} from '../../generated/graphql'; +import {Module, useModuleQuery} from '../../generated/graphql'; type Props = { type: 'basic' | 'plus'; - moduleConfig?: Module; }; const EventTypeCard = (props: Props) => { - const {type, moduleConfig} = props; + const {type} = props; const {t} = useTranslation(); + const {data} = useModuleQuery({variables: {locale: 'en'}}); + const modulesSettings = data?.module.data?.attributes; return ( <Box@@ -45,7 +46,7 @@ {t`event.creation.plus.fromPrice`}
</Typography> <Box display="flex" alignItems="baseline"> <Typography fontSize={64} lineHeight={1}> - {moduleConfig?.caroster_plus_price} + {modulesSettings?.caroster_plus_price} </Typography> <Typography fontSize={24} lineHeight={1}> €@@ -57,9 +58,7 @@ <Typography color="textSecondary" pb={3}>
{t(`event.creation.${type}.description`)} </Typography> {type === 'basic' && <BasicAction />} - {type === 'plus' && ( - <PlusAction paymentLink={moduleConfig?.caroster_plus_payment_link} /> - )} + {type === 'plus' && <PlusAction />} </Box> ); };
@@ -388,8 +388,9 @@ __typename?: 'Module';
caroster_plus_description?: Maybe<Scalars['String']['output']>; caroster_plus_enabled?: Maybe<Scalars['Boolean']['output']>; caroster_plus_name: Scalars['String']['output']; - caroster_plus_payment_link: Scalars['String']['output']; caroster_plus_price?: Maybe<Scalars['Float']['output']>; + caroster_plus_pricing_grid_id: Scalars['String']['output']; + caroster_plus_publishable_key: Scalars['String']['output']; createdAt?: Maybe<Scalars['DateTime']['output']>; locale?: Maybe<Scalars['String']['output']>; localizations?: Maybe<ModuleRelationResponseCollection>;@@ -411,9 +412,9 @@ export type ModuleInput = {
caroster_plus_description?: InputMaybe<Scalars['String']['input']>; caroster_plus_enabled?: InputMaybe<Scalars['Boolean']['input']>; caroster_plus_name?: InputMaybe<Scalars['String']['input']>; - caroster_plus_payment_link?: InputMaybe<Scalars['String']['input']>; - caroster_plus_payment_link_id?: InputMaybe<Scalars['String']['input']>; caroster_plus_price?: InputMaybe<Scalars['Float']['input']>; + caroster_plus_pricing_grid_id?: InputMaybe<Scalars['String']['input']>; + caroster_plus_publishable_key?: InputMaybe<Scalars['String']['input']>; }; export type ModuleRelationResponseCollection = {@@ -1865,7 +1866,7 @@ locale: Scalars['I18NLocaleCode']['input'];
}>; -export type ModuleQuery = { __typename?: 'Query', module?: { __typename?: 'ModuleEntityResponse', data?: { __typename?: 'ModuleEntity', attributes?: { __typename?: 'Module', caroster_plus_name: string, caroster_plus_price?: number | null, caroster_plus_enabled?: boolean | null, caroster_plus_description?: string | null, caroster_plus_payment_link: string } | null } | null } | null }; +export type ModuleQuery = { __typename?: 'Query', module?: { __typename?: 'ModuleEntityResponse', data?: { __typename?: 'ModuleEntity', attributes?: { __typename?: 'Module', caroster_plus_name: string, caroster_plus_price?: number | null, caroster_plus_enabled?: boolean | null, caroster_plus_description?: string | null, caroster_plus_pricing_grid_id: string, caroster_plus_publishable_key: string } | null } | null } | null }; export type UserNotificationsQueryVariables = Exact<{ maxItems?: InputMaybe<Scalars['Int']['input']>;@@ -2540,7 +2541,8 @@ caroster_plus_name
caroster_plus_price caroster_plus_enabled caroster_plus_description - caroster_plus_payment_link + caroster_plus_pricing_grid_id + caroster_plus_publishable_key } } }
@@ -1,13 +1,14 @@
query module($locale: I18NLocaleCode!) { module(locale: $locale) { data { - attributes{ + attributes { caroster_plus_name caroster_plus_price caroster_plus_enabled caroster_plus_description - caroster_plus_payment_link + caroster_plus_pricing_grid_id + caroster_plus_publishable_key } } } -}+}
@@ -1,12 +1,20 @@
import Container from '@mui/material/Container'; import Box from '@mui/material/Box'; import DefaultLayout from './Default'; +import {ReactNode} from 'react'; +import {Breakpoint} from '@mui/material'; -const CenteredLayout = ({children, ...props}) => { +interface Props { + children: ReactNode; + maxWidth?: Breakpoint; +} + +const CenteredLayout = ({children, ...props}: Props) => { + const {maxWidth = 'sm'} = props; return ( <DefaultLayout {...props}> <Box pt={12} position="relative"> - <Container maxWidth="sm">{children}</Container> + <Container maxWidth={maxWidth}>{children}</Container> </Box> </DefaultLayout> );
@@ -4,10 +4,12 @@ import {useTranslation} from 'next-i18next';
import {useSession} from 'next-auth/react'; import {useRouter} from 'next/router'; import {ReactNode} from 'react'; +import {Breakpoint} from '@mui/material'; interface Props { announcement?: string; children: ReactNode; + maxWidth?: Breakpoint; } const EventCreationLayout = (props: Props) => {
@@ -0,0 +1,132 @@
+import {PropsWithChildren} from 'react'; +import { + EventByUuidDocument, + Module, + ModuleDocument, + Enum_Userspermissionsuser_Lang as SupportedLocales, +} from '../../../generated/graphql'; +import EventLayout, {TabComponent} from '../../../layouts/Event'; +import useEventStore from '../../../stores/useEventStore'; +import {Box, Container, Paper, useTheme} from '@mui/material'; +import Head from 'next/head'; +import {useSession} from 'next-auth/react'; +import pageUtils from '../../../lib/pageUtils'; +import {getLocaleForLang} from '../../../lib/getLocale'; + +interface Props { + modulesSettings?: Module; + eventUUID: string; + announcement?: string; +} + +const Page = (props: PropsWithChildren<Props>) => { + return <EventLayout {...props} Tab={PricesPage} />; +}; + +const PricesPage: TabComponent<Props> = ({modulesSettings}) => { + const theme = useTheme(); + const event = useEventStore(s => s.event); + const session = useSession(); + const profile = session?.data?.profile; + + const carosterPlusActivated = + modulesSettings?.caroster_plus_enabled && + event?.enabled_modules?.includes('caroster-plus'); + + if (!event && !carosterPlusActivated) return null; + + return ( + <Box position="relative"> + <Head> + <script async src="https://js.stripe.com/v3/pricing-table.js"></script> + </Head> + <Container + sx={{ + p: 4, + mt: 6, + mb: 11, + mx: 0, + [theme.breakpoints.down('md')]: { + p: 2, + mt: 13, + }, + }} + > + <Box component={Paper} mb={4}> + {/* @ts-ignore */} + <stripe-pricing-table + pricing-table-id={modulesSettings.caroster_plus_pricing_grid_id} + publishable-key={modulesSettings.caroster_plus_publishable_key} + client-reference-id={event.uuid} + customer-email={profile?.email} + /> + </Box> + </Container> + </Box> + ); +}; + +export const getServerSideProps = pageUtils.getServerSideProps( + async (context, apolloClient) => { + const {uuid} = context.query; + const {host = ''} = context.req.headers; + let event = null; + let modulesSettings = null; + + // Fetch event + try { + const {data} = await apolloClient.query({ + query: EventByUuidDocument, + variables: {uuid}, + }); + event = data?.eventByUUID?.data; + } catch (error) { + return { + notFound: true, + }; + } + + // Fetch module settings + try { + const {data} = await apolloClient.query({ + query: ModuleDocument, + variables: {locale: context.locale}, + }); + modulesSettings = data?.module?.data?.attributes || {}; + + if (!modulesSettings?.caroster_plus_pricing_grid_id) { + console.warn( + 'Module settings are not set for locale: ', + context.locale, + ' fallback to English' + ); + const {data: enData} = await apolloClient.query({ + query: ModuleDocument, + variables: {locale: SupportedLocales['en']}, + }); + modulesSettings = enData?.module?.data?.attributes; + } + } catch (error) { + console.error("Can't fetch config for module: ", error); + } + + const description = await getLocaleForLang( + event?.attributes?.lang, + 'meta.description' + ); + + return { + props: { + modulesSettings, + eventUUID: uuid, + metas: { + title: event?.attributes?.name || '', + description, + url: `https://${host}${context.resolvedUrl}`, + }, + }, + }; + } +); + +export default Page;
@@ -0,0 +1,87 @@
+import pageUtils from '../../lib/pageUtils'; +import Layout from '../../layouts/EventCreation'; +import {Box, Paper, Typography} from '@mui/material'; +import { + Module, + ModuleDocument, + Enum_Userspermissionsuser_Lang as SupportedLocales, +} from '../../generated/graphql'; +import {useTranslation} from 'react-i18next'; +import Head from 'next/head'; +import {useRouter} from 'next/router'; +import {useSession} from 'next-auth/react'; + +interface Props { + modulesSettings?: Module; + announcement?: string; +} +const PlusPrices = (props: Props) => { + const {modulesSettings} = props; + const {t} = useTranslation(); + const session = useSession(); + const profile = session?.data?.profile; + const router = useRouter(); + const eventUUID = router.query.eventId; + + if ( + !modulesSettings?.caroster_plus_pricing_grid_id || + !modulesSettings.caroster_plus_publishable_key || + !eventUUID + ) + return ( + <Layout {...props}> + <Typography>{t`options.no_module`}</Typography> + </Layout> + ); + + return ( + <Layout {...props} maxWidth="xl"> + <Head> + <script async src="https://js.stripe.com/v3/pricing-table.js"></script> + </Head> + <Box component={Paper} mb={4}> + {/* @ts-ignore */} + <stripe-pricing-table + pricing-table-id={modulesSettings.caroster_plus_pricing_grid_id} + publishable-key={modulesSettings.caroster_plus_publishable_key} + client-reference-id={eventUUID} + customer-email={profile?.email} + /> + </Box> + </Layout> + ); +}; + +export const getServerSideProps = pageUtils.getServerSideProps( + async (context, apolloClient) => { + let modulesSettings = null; + + // Fetch module settings + try { + const {data} = await apolloClient.query({ + query: ModuleDocument, + variables: {locale: context.locale}, + }); + modulesSettings = data?.module?.data?.attributes || {}; + + if (!modulesSettings?.caroster_plus_pricing_grid_id) { + console.warn( + 'Module settings are not set for locale: ', + context.locale, + ' fallback to English' + ); + const {data: enData} = await apolloClient.query({ + query: ModuleDocument, + variables: {locale: SupportedLocales['en']}, + }); + modulesSettings = enData?.module?.data?.attributes; + } + } catch (error) { + console.error("Can't fetch config for module: ", error); + } + + return {props: {modulesSettings}}; + } +); + +export default PlusPrices;
@@ -6,8 +6,6 @@ import EventTypeCard from '../../containers/EventTypeCard';
import {useEffect} from 'react'; import {useRouter} from 'next/router'; import useEventCreationStore from '../../stores/useEventCreationStore'; -import {useModuleQuery} from '../../generated/graphql'; -import useLocale from '../../hooks/useLocale'; const NewEventType = () => { const {t} = useTranslation();@@ -16,9 +14,6 @@ const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('md')); const event = useEventCreationStore(s => s.event); const eventStoreReady = useEventCreationStore(s => s.ready); - const {locale} = useLocale(); - const {data: moduleData} = useModuleQuery({variables: {locale}}); - const moduleConfig = moduleData?.module?.data?.attributes; useEffect(() => { if (eventStoreReady && !event.name) router.push('/new');@@ -35,9 +30,7 @@ >{t`event.creation.chooseType`}</Typography>
</Box> <Box display="flex" gap={4} py={4} flexWrap={isMobile && 'wrap'}> <EventTypeCard type="basic" /> - {moduleConfig?.caroster_plus_payment_link && ( - <EventTypeCard type="plus" moduleConfig={moduleConfig} /> - )} + <EventTypeCard type="plus" /> </Box> </Layout> );