UNPKG

@proca/widget

Version:

Proca is an open-source campaign toolkit designed to empower activists and organisations in their digital advocacy efforts. It provides a flexible and customisable platform for creating and managing online petitions, email campaigns, and other forms of di

521 lines (458 loc) 14.3 kB
import React, { useState } from "react"; import { useForm, Controller } from "react-hook-form"; import { TextField as LayoutTextField, Grid, Box, Button, Container, FormHelperText, CircularProgress, } from "@material-ui/core"; import TextField from "@components/TextField"; // We can't use the goodies of our material ui wrapper, because it triggers too many redraw and sometimes clear the stripe field (credit cards when it shouldn't) import EmailField from "@components/field/Email"; //import { loadStripe } from "@stripe/stripe-js"; import useScript from "react-script-hook"; import { useLayout } from "../../hooks/useLayout"; import { makeStyles } from "@material-ui/core/styles"; import { useCompactLayout } from "@hooks/useElementWidth"; import { useCampaignConfig } from "../../hooks/useConfig"; import useData from "../../hooks/useData"; import { useTranslation } from "react-i18next"; //import SendIcon from "@material-ui/icons/Send"; import LockIcon from "@material-ui/icons/Lock"; import { addDonateContact, stripeCreate } from "../../lib/server.js"; import dispatch from "../../lib/event.js"; import Url from "@lib/urlparser"; import { useStripe, Elements, useElements, CardElement, } from "@stripe/react-stripe-js"; import StripeInput from "./StripeInput"; import Country from "../field/Country"; import { atom, useRecoilValue, useSetRecoilState } from "recoil"; import DonateTitle from "./DonateTitle"; import { CallToAction } from "./DonateButton"; const STRIPE_FREQUENCY = { monthly: "month", weekly: "week", daily: "day", yearly: "year", }; const stripeErrorAtom = atom({ key: "stripe-error", default: undefined, }); const stripeCompleteAtom = atom({ key: "stripe-card-complete", default: undefined, }); // const onError = (errors, e) => console.log(errors, e); const CustomCardElement = props => { const setComplete = useSetRecoilState(stripeCompleteAtom); return ( <CardElement {...props} options={{ hidePostalCode: true }} onChange={e => setComplete(e.complete)} /> ); }; const StripeCard = () => { const layout = useLayout(); const stripe = useStripe(); return ( <Container component="main" maxWidth="sm"> <Grid item xs={12}> <LayoutTextField name="card" label="" variant={layout.variant} margin={layout.margin} fullWidth InputLabelProps={{ shrink: true }} InputProps={{ inputComponent: StripeInput, inputProps: { component: CustomCardElement, stripe: stripe, }, }} /> </Grid> </Container> ); }; const useStyles = makeStyles(theme => ({ container: { display: "flex", flexWrap: "wrap", }, textField: { marginLeft: theme.spacing(0), marginRight: theme.spacing(0), height: "auto!important", width: "100%", }, submitButton: { marginTop: theme.spacing(2), }, })); const PaymentForm = props => { const layout = useLayout(); const { t } = useTranslation(); const config = useCampaignConfig(); if (!config.component.donation?.stripe?.productId) { throw Error( "You must configure a Stripe product id " + "[component.donation.stripe.productId] to use Stripe." ); } const stripeError = useRecoilValue(stripeErrorAtom); const form = props.form; const { control, formState: { errors }, } = form; const compact = useCompactLayout("#proca-donate", 400); const useTitle = config.component.donation.useTitle; const classes = useStyles(); return ( <Container component="main" maxWidth="sm"> <Grid container spacing={1}> {useTitle && ( <Grid item xs={12}> <DonateTitle showAverage={false} /> </Grid> )} <Grid item xs={12} sm={compact ? 12 : 6}> <TextField form={form} name="firstname" label={t("First name")} autoComplete="given-name" required /> </Grid> <Grid item xs={12} sm={compact ? 12 : 6}> <TextField form={form} name="lastname" label={t("Last name")} autoComplete="family-name" required /> </Grid> {/* <Grid item xs={12} display> <Controller control={control} name="email" // KISS for email validation - something@something\.something rules={{ required: true, pattern: /^(.+)@(.+)\.(.+)$/ }} render={({ field: { onChange, onBlur, value } }) => ( <LayoutTextField className={classes.textField} label={t("Email")} autoComplete="email" type="email" name="email" placeholder="your.email@example.org" required error={!!(errors && errors["email"])} helperText={ errors && errors["email"] && errors["email"].message } variant={layout.variant} margin={layout.margin} onChange={onChange} onBlur={(e) => { setData(e.target.name, e.target.value); onBlur(e); }} value={value} /> )} /> </Grid> */} <EmailField form={form} required /> <Grid item xs={12} sm={compact ? 12 : 4}> <Controller control={control} name="postcode" render={({ field: { onChange, value } }) => ( <LayoutTextField name="postcode" className={classes.textField} label={t("Postal Code")} autoComplete="postal-code" error={!!(errors && errors["postcode"])} helperText={ errors && errors["postcode"] && errors["postcode"].message } variant={layout.variant} margin={layout.margin} onChange={onChange} value={value} /> )} /> </Grid> <Grid item xs={12} sm={compact ? 12 : 8}> <Controller control={control} name="country" rules={{ required: true }} render={({ field: { onChange, value } }) => ( <Country form={form} required onChange={onChange} value={value} /> )} /> </Grid> {stripeError ? ( <Grid item xs={12}> <FormHelperText error={true}>{stripeError.message}</FormHelperText> </Grid> ) : ( "" )} </Grid> </Container> ); }; const SubmitButton = props => { const [isSubmitting, setSubmitting] = useState(false); const setStripeError = useSetRecoilState(stripeErrorAtom); const stripeComplete = useRecoilValue(stripeCompleteAtom); const stripe = useStripe(); const [formData] = useData(); const elements = useElements(); const { t } = useTranslation(); const config = useCampaignConfig(); const donateConfig = config.component.donation; const currency = donateConfig.currency; const orderComplete = async (paymentIntent, paymentConfirm) => { const values = props.form.getValues(); const procaRequest = { ...formData, ...values }; const confirmedIntent = paymentConfirm.paymentIntent; const payload = { paymentConfirm: confirmedIntent, paymentIntent: paymentIntent, formValues: values, }; procaRequest.donation = { amount: confirmedIntent.amount, currency: confirmedIntent.currency.toUpperCase(), }; if (formData.frequency !== "oneoff" && paymentIntent.response.items) { const intentResponse = paymentIntent.response; const subscriptionPlan = intentResponse.items.data[0].plan; procaRequest.donation.frequencyUnit = subscriptionPlan.interval; payload.subscriptionId = intentResponse.id; payload.subscriptionPlan = subscriptionPlan; payload.customerId = intentResponse.customer; } procaRequest.donation.payload = payload; procaRequest.tracking = Url.utm(); if (config.test) payload.test = true; const procaResponse = await addDonateContact( "stripe", config.actionPage, procaRequest, config.test ); if (procaResponse.errors) { throw Error("Proca didn't like the request !", procaResponse.errors); } // console.log("procaResponse", procaResponse); dispatch( "donate:complete", { payment: "stripe", uuid: procaResponse.contactRef, test: !!config.test, firstname: formData.firstname, amount: formData.amount, currency: currency.code, frequency: formData.frequency || "oneoff", country: formData.country, }, procaRequest ); props.done(paymentConfirm); }; const onSubmitButtonClick = async event => { event.preventDefault(); const btn = event.target; btn.disabled = true; setSubmitting(true); const form = props.form; if (Object.keys(form.formState.errors).length > 0) { btn.disabled = false; setSubmitting(false); return false; } if (!stripeComplete) { setStripeError({ message: t("donation.error.card.missing") }); btn.disabled = false; setSubmitting(false); return false; } const values = form.getValues(); if (!props.stripe || !elements) { console.error("Stripe not loaded"); btn.disabled = false; setSubmitting(false); return false; } const cardElement = elements.getElement(CardElement); const params = { actionPage: config.actionPage, amount: Math.floor(formData.amount * 100), currency: currency.code, contact: { name: `${formData.firstname} ${formData.lastname}`, email: formData.email, address: { country: formData.country, postal_code: formData.postcode }, }, stripe_product_id: config.component.donation.stripe.productId, }; if (formData.frequency) params.frequency = STRIPE_FREQUENCY[formData.frequency]; const piResponse = await stripeCreate(params); if (piResponse.errors) { console.log("Error returned from proca backend", piResponse.errors); setStripeError({ message: t("donation.error.general"), }); btn.disabled = false; setSubmitting(false); return false; } const stripeResponse = await stripe.confirmCardPayment( piResponse.client_secret, { payment_method: { card: cardElement, billing_details: { name: values.name, address: { country: values.country, postal_code: values.postcode }, email: values.email, }, }, // expand: {}, } ); if (stripeResponse.error) { console.log("error", stripeResponse); setStripeError(stripeResponse.error); btn.disabled = false; setSubmitting(false); return false; } // console.debug("stripe confirm card payment response", stripeResponse); orderComplete(piResponse, stripeResponse); // leave button disabled - we're done! return true; }; return ( <Box mt={2}> <Button className="submit-button" name="submit" color="primary" variant={ config.layout?.button?.submit?.variant || config.layout?.button?.variant || "contained" } fullWidth type="submit" size="large" startIcon={isSubmitting ? undefined : <LockIcon />} onClick={(e, data) => { onSubmitButtonClick(e, data); }} > {isSubmitting ? ( <CircularProgress color="inherit" /> ) : ( <CallToAction amount={formData.amount} currency={currency} frequency={formData.frequency || "oneoff"} /> )} </Button> </Box> ); }; const submitButtonStyles = makeStyles(theme => ({ submitButton: { marginTop: theme.spacing(2), }, })); const PayWithStripe = props => { // const stripe = useStripe(); const form = props.form; const classes = submitButtonStyles(); return ( <form id="proca-donate"> <Grid container> <Grid item xs={12}> <PaymentForm stripe={props.stripe} form={form} {...props} /> </Grid> <Grid item xs={12}> <StripeCard stripe={props.stripe} /> </Grid> <Grid item xs={12} classes={{ root: classes.submitButton }}> <SubmitButton stripe={props.stripe} form={form} {...props} /> </Grid> </Grid> </form> ); }; const PaymentFormWrapper = props => { const config = useCampaignConfig(); const [data] = useData(); let publishableKey = config.component.donation?.stripe?.publicKey || process.env.REACT_APP_STRIPE_PUBLIC_KEY; if (config.test) { if (config.component.donation?.stripe?.testKey) { publishableKey = config.component.donation.stripe.testKey; } else { console.warn("missing config.component.donation.stripe.testKey"); } } const [stripe, loadStripe] = useState(null); const [, error] = useScript({ src: "https://js.stripe.com/v3/", onload: () => { // the object window.Stripe exists loadStripe(window.Stripe(publishableKey, { locale: config.lang })); }, }); const form = useForm({ defaultValues: { firstname: data.firstname || "", lastname: data.lastname || "", email: data.email || "", postcode: data.postcode || "", country: data.country || "", }, }); const { t } = useTranslation(); if (error) return <h3>{t("donation.error.initialisation")}</h3>; return ( <Container component="main" id="proca-donate"> <Elements stripe={stripe} options={config?.lang || "auto"}> <PayWithStripe {...props} form={form} stripe={stripe} /> </Elements> </Container> ); }; export default PaymentFormWrapper;