From 338bce695168be2096e2cb9e46d83c3208e4daa4 Mon Sep 17 00:00:00 2001 From: Eric Schultz Date: Fri, 5 Nov 2021 15:19:17 -0500 Subject: [PATCH] Beginning of DonateWizard -- WIP --- .../_dependencies/ff-core/wizard/index.tsx | 119 +++++++++++++ .../components/styles/branded-wizard.tsx | 74 ++++++++ .../legacy/nonprofits/donate/amount-step.tsx | 163 ++++++++++++++++++ .../legacy/nonprofits/donate/close.svg | 13 ++ .../nonprofits/donate/followup-step.tsx | 56 ++++++ .../legacy/nonprofits/donate/get_params.ts | 49 ++++++ .../legacy/nonprofits/donate/page.tsx | 46 +++++ .../nonprofits/donate/wizard.stories.tsx | 9 + .../legacy/nonprofits/donate/wizard.tsx | 147 ++++++++++++++++ app/javascript/hooks/useSteps.ts | 2 +- 10 files changed, 677 insertions(+), 1 deletion(-) create mode 100644 app/javascript/components/legacy/_dependencies/ff-core/wizard/index.tsx create mode 100644 app/javascript/components/legacy/components/styles/branded-wizard.tsx create mode 100644 app/javascript/components/legacy/nonprofits/donate/amount-step.tsx create mode 100644 app/javascript/components/legacy/nonprofits/donate/close.svg create mode 100644 app/javascript/components/legacy/nonprofits/donate/followup-step.tsx create mode 100644 app/javascript/components/legacy/nonprofits/donate/get_params.ts create mode 100644 app/javascript/components/legacy/nonprofits/donate/page.tsx create mode 100644 app/javascript/components/legacy/nonprofits/donate/wizard.stories.tsx create mode 100644 app/javascript/components/legacy/nonprofits/donate/wizard.tsx diff --git a/app/javascript/components/legacy/_dependencies/ff-core/wizard/index.tsx b/app/javascript/components/legacy/_dependencies/ff-core/wizard/index.tsx new file mode 100644 index 000000000..e427a24e8 --- /dev/null +++ b/app/javascript/components/legacy/_dependencies/ff-core/wizard/index.tsx @@ -0,0 +1,119 @@ +/* eslint-disable react/prop-types */ +import useSteps, { InputStepsState, KeyedStep, StepsObject } from '../../../../../hooks/useSteps'; +import React, { createContext } from 'react'; +import { noop } from 'lodash'; + +// from ff-core/wizard + + +interface WizardProps extends InputStepsState { + followup: () => JSX.Element; + +} + + +export const WizardContext = createContext>({} as unknown as StepsObject<{ body: JSX.Element, title: string }>); + +export default function Wizard(props: WizardProps): JSX.Element { + + const { + steps, followup, + } = props; + const stepManager = useSteps<{ body: JSX.Element, title: string }>({ steps, addStep: noop, removeStep: noop }); + + const currentStep = stepManager.activeStep; + + + + const isCompleted = stepManager.activeStep === stepManager.steps.length - 1; + return ( + +
+ value.title)} + jump={stepManager.goto} + /> + + {stepManager.steps.map(i => i.body)} + + + {followup()} + ; +
+
); +} + + +Wizard.defaultProps = { + addStep: noop, + removeStep: noop, +}; + + + + +function Followup({ isCompleted, children }: React.PropsWithChildren<{ isCompleted: boolean }>) { + return
+ {children} +
; +} + +function StepIndex({ stepNames, isCompleted, currentStep, jump }: { stepNames: string[], isCompleted: boolean, currentStep: number, jump: (args: any) => void }) { + const width = 100 / stepNames.length + '%'; + return
+ {stepNames.map((name, idx) => + () + )} +
; + + +} + +function StepHeader({ width, jump, name, idx, currentStep }: { width: string, name: string, jump: (args: any) => void, idx: number, currentStep: number }) { + const classNames = ['ff-wizard-index-label']; + if (currentStep === idx) { + classNames.push('ff-wizard-index-label--current'); + } + if (currentStep > idx) { + classNames.push('ff-wizard-index-label--accessible'); + } + return ( jump(idx)} > + {name} + ); + +} + +function Body({ currentStep, isCompleted, children }: React.PropsWithChildren<{ currentStep: number, isCompleted: boolean }>) { + return
+ + {React.Children.map(children, (child, idx) => ( + + {child} + /* idx is a bad choice for key but we got not options */ + ))} + +
; +} + +function StepBody({ idx, currentStep, children }: React.PropsWithChildren<{ idx: number, currentStep: number }>) { + return
+ {children} +
; +} \ No newline at end of file diff --git a/app/javascript/components/legacy/components/styles/branded-wizard.tsx b/app/javascript/components/legacy/components/styles/branded-wizard.tsx new file mode 100644 index 000000000..119b4b8cb --- /dev/null +++ b/app/javascript/components/legacy/components/styles/branded-wizard.tsx @@ -0,0 +1,74 @@ +// License: LGPL-3.0-or-later + +import { makeStyles } from "@material-ui/core/styles"; +import colors from '../../../../legacy_react/src/lib/nonprofitBranding'; + + +function cssGradient(dir: string, to: string, from: string) { + return `linear-gradient(${dir}, ${to}, ${from})`; +} + +interface MakeStylesProps { + nonprofitColor: string; +} + +const useStyles = makeStyles({ + '@global': { + '.badge': { + display: 'inline-block', + minWidth: '10px', + 'padding': '3px 7px', + 'fontSize': '11px', + 'fontWeight': 'bold', + 'color': '#fff', + 'lineHeight': '1', + 'verticalAlign': 'middle', + 'whiteSpace': 'nowrap', + 'textAlign': 'center', + 'backgroundColor': '#9c9c9c', + 'borderRadius': '10px', + }, + '.badge:empty': { + display: 'none', + }, + 'button .badge': { + 'position': 'relative', + 'top': '-1px', + }, + '.wizard-steps div.is-selected, .wizard-steps button.is-selected': { + backgroundColor: (props: MakeStylesProps) => `${colors(props.nonprofitColor).lighter} !important`, + }, + 'wizard-steps .button.white': { + 'color': '#494949', + }, + '.wizard-steps a:not(.button--small), .ff-wizard-index-label.ff-wizard-index-label--accessible, .wizard-index-label.is-accessible': { + color: (props: MakeStylesProps) => `${colors(props.nonprofitColor).dark} !important`, + }, + 'wizard-steps input.is-selected': { + borderColor: (props: MakeStylesProps) => `${colors(props.nonprofitColor).light} !important`, + }, + '.wizard-steps button:not(.white):not([disabled])': { + backgroundColor: (props: MakeStylesProps) => `${colors(props.nonprofitColor).dark} !important`, + }, + '.wizard-steps .highlight': { + backgroundColor: (props: MakeStylesProps) => `${colors(props.nonprofitColor).lightest} !important`, + }, + + '.wizard-steps label, .wizard-steps th': { + color: '#636363', + }, + ".wizard-steps input[type='radio']:checked + label:before": { + backgroundColor: (props: MakeStylesProps) => `${colors(props.nonprofitColor).base} !important`, + }, + ".wizard-steps input[type='checkbox'] + label:before": { + color: (props: MakeStylesProps) => `${colors(props.nonprofitColor).base} !important`, + }, + ".ff-wizard-index-label.ff-wizard-index-label--current, .wizard-index-label.is-current": { + backgroundImage: (props: MakeStylesProps) => cssGradient('left', '#fbfbfb', colors(props.nonprofitColor).light), + }, + }, +}); + +export function useBrandedWizard(nonprofitColor: string): Record<"@global", string> { + return useStyles({ nonprofitColor }); +} diff --git a/app/javascript/components/legacy/nonprofits/donate/amount-step.tsx b/app/javascript/components/legacy/nonprofits/donate/amount-step.tsx new file mode 100644 index 000000000..de4930ec8 --- /dev/null +++ b/app/javascript/components/legacy/nonprofits/donate/amount-step.tsx @@ -0,0 +1,163 @@ +import React, { useContext } from 'react'; +import { useId } from "@reach/auto-id"; +import { Money } from '../../../../common/money'; +import { Formik, useFormikContext } from 'formik'; +import { ActionType, DonationWizardContext } from './wizard'; +declare const I18n: any; +interface AmountStepProps { + amount: Money|null; + amountOptions: Money[]; +} + + + +interface FormikFormValues { + amount: Money|null; +} +export function AmountStep(props: AmountStepProps): JSX.Element { + const {dispatch:dispatchAction} = useContext(DonationWizardContext); + + return (
+ { + dispatchAction({type: 'setAmount', amount: values.amount}); + }} initialValues={{amount: props.amount} as FormikFormValues} enableReinitialize={true}> + {/* + */} + + +
); +} + +interface RecurringCheckboxProps { + isRecurring: boolean; + showRecurring: boolean; + setRecurring: (recurring: boolean) => void; +} + +function RecurringCheckbox(props: RecurringCheckboxProps): JSX.Element { + const checkboxId = useId(); + + if (props.showRecurring) { + + return (
+ +
+ props.setRecurring(!props.isRecurring)} /> + +
+
); + + } + + else { + return null; + } + +} +function ComposeTranslation(props: { full: string, bold: string }): JSX.Element { + const texts = props.full.split(props.bold); + if (texts.length > 1) { + return (<>{texts[0]}{props.bold}{texts[2]}); + } + else { + return <>{props.full}; + } +} + +function RecurringMessage(props: { isRecurring: boolean, recurringWeekly: boolean, periodicAmount: number, singleAmount: string }): JSX.Element { + if (!props.isRecurring) return <>; + + let label = I18n.t('nonprofits.donate.amount.sustaining_selected'); + let bolded = I18n.t('nonprofits.donate.amount.sustaining_selected_bold'); + if (props.recurringWeekly) { + label = label.replace(I18n.t('nonprofits.donate.amount.monthly'), I18n.t('nonprofits.donate.amount.weekly')); + bolded = I18n.t('nonprofits.donate.amount.weekly'); + } + return (
+

+ {props.singleAmount ? '' : + + } +

+
); +} + +function prependCurrencyClassname(currency_symbol: string) { + if (currency_symbol === '$') { + return 'prepend--dollar'; + } else if (currency_symbol === '€') { + return 'prepend--euro'; + } +} + +function getCurrencySymbol(amount: Money) { + if (amount.currency == 'EUR') { + return '€'; + } + else if (amount.currency == 'USD') { + return '$'; + } +} + +interface AmountFieldsProps { + // singleAmount: string; + amounts: Money[]; + // buttonAmountSelected: boolean; + //currencySymbol: string; +} + + + +function AmountFields(props: AmountFieldsProps): JSX.Element { + const {values, setFieldValue, submitForm} = useFormikContext(); + // if (props.singleAmount) { + // return <>; + // }s + return (
+ + {props.amounts.map(amt => { + const weAreSelected = values.amount.equals(amt); + return (
+ +
); + })} +
+ + {/*
+ { throw new Error('onFocus not implemented'); }} + onChange={() => { throw new Error('onChange not implemented'); }} + /> +
+ +
+ +
*/} +
); +} + +AmountFields.defaultProps = { + amounts: [], +}; + + + diff --git a/app/javascript/components/legacy/nonprofits/donate/close.svg b/app/javascript/components/legacy/nonprofits/donate/close.svg new file mode 100644 index 000000000..5b9021dc6 --- /dev/null +++ b/app/javascript/components/legacy/nonprofits/donate/close.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + diff --git a/app/javascript/components/legacy/nonprofits/donate/followup-step.tsx b/app/javascript/components/legacy/nonprofits/donate/followup-step.tsx new file mode 100644 index 000000000..ffcc69f5a --- /dev/null +++ b/app/javascript/components/legacy/nonprofits/donate/followup-step.tsx @@ -0,0 +1,56 @@ +// License: LGPL-3.0-or-later +// based on app/javascript/legacy/nonprofits/donate/followup-step.js +import React from 'react'; +import noop from 'lodash/noop'; +declare const I18n: any; +interface FollowupStepProps { + supporterEmail?: string | null; + thankYouMessage?: string | null; + nonprofitName: string; + showFinishButton: boolean; + clickFinish: () => void; + +} +export default function FollowupStep(props: FollowupStepProps): JSX.Element { + + return (
+
+ {I18n.t('nonprofits.donate.followup.success')} +
+ { props.supporterEmail ?

{`${I18n.t('nonprofits.donate.followup.receipt_info')} ${props.supporterEmail}`}

: ''} +

+ {props.thankYouMessage || `${props.nonprofitName} ${I18n.t('nonprofits.donate.followup.message')}`} +

+ + + + {/* // we're skipping this stuff for now + // , h('div.u-inlineBlock.u-marginRight--10', [ + // h('a.button--small.facebook.u-width--full.share-button', { + // props: { + // target: '_blank' + // , href: 'https://www.facebook.com/dialog/feed?app_id='+app.facebook_app_id +"&display=popup&caption=" + encodeURIComponent(app.campaign.name || app.nonprofit.name) + "&link="+window.location.href + // } + // }, [h('i.fa.fa-facebook-square'), ` ${I18n.t('nonprofits.donate.followup.share.facebook')}`] ) + // ]) + // , h('div.u-inlineBlock.u-marginLeft--10.u-marginBottom--20', [ + // h('a.button--small.twitter.u-width--full', { + // props: { + // target: '_blank' + // , href: "https://twitter.com/intent/tweet?url="+window.location.href+"&via=CommitChange&text=Join me in supporting:" + (app.campaign.name || app.nonprofit.name) + // } + // }, [h('i.fa.fa-twitter-square'), ` ${I18n.t('nonprofits.donate.followup.share.twitter')}`] ) + // ]) */} + + { props.showFinishButton ? +
+ +
: '' + } +
); +} + +FollowupStep.defaultProps = { + clickFinish: noop, + showFinishButton: false, +} as FollowupStepProps; \ No newline at end of file diff --git a/app/javascript/components/legacy/nonprofits/donate/get_params.ts b/app/javascript/components/legacy/nonprofits/donate/get_params.ts new file mode 100644 index 000000000..66da511c2 --- /dev/null +++ b/app/javascript/components/legacy/nonprofits/donate/get_params.ts @@ -0,0 +1,49 @@ +// License: LGPL-3.0-or-later + +// based on app/javascript/legacy/nonprofits/donate/get-params.js + +function splitParam(param: string | null| undefined) : string[] { + if (param) { + return param.split(/[_;,]/); + } + else { + return []; + } +} + +function splitField(param: string) : {label:string, name:string }[] { + if (param) { + const individualFields = param.split(','); + return individualFields.map(f => { + const [name, label] = f.split(':').map(i => i.trim()); + return { name, label: label ? label : name }; + }); + } + else { + []; + } +} + +export default function getParams(params:NodeJS.Dict) : { + custom_amounts: number[]; + custom_fields: {label:string, name:string }[]; + multiple_designations:string[]; +} +{ + const defaultAmts = '10,25,50,100,250,500,1000'; + + const { + multiple_designations, + custom_amounts, + custom_fields, + ...other + } = params; + + const result = { + ...other, + multiple_designations: splitParam(multiple_designations), + custom_amounts: splitParam(custom_amounts || defaultAmts).map(i => Number(i)), + custom_fields: splitField(custom_fields), + }; + return result; +} diff --git a/app/javascript/components/legacy/nonprofits/donate/page.tsx b/app/javascript/components/legacy/nonprofits/donate/page.tsx new file mode 100644 index 000000000..8676e701d --- /dev/null +++ b/app/javascript/components/legacy/nonprofits/donate/page.tsx @@ -0,0 +1,46 @@ +// License: LGPL-3.0-or-later + +// based on: app/javascript/legacy/nonprofits/donate/page.js +import React, { useCallback, useState } from 'react'; +import url from 'url'; +import DonateWizard from './wizard'; + + + +interface DonatePageProps { + nonprofit_id: number; +} + +export default function DonatePage(props: DonatePageProps) : JSX.Element { + const [params, setParams] = useState(url.parse(location.href, true).query); + + // TODO receiveMessage is kind of complex and shouldn't be the first thing to work on + + + // const receiveMessage = useCallback((event) => { + // let ps; + + // try { ps = JSON.parse(event.data); } + // // eslint-disable-next-line no-empty + // catch (e) { } + // if (ps && ps.sender === 'commitchange') { + // if (ps.command) { + // const event = new CustomEvent('message:' + ps.command, { data: ps }); + // container.dispatchEvent(event); + // } + // if (ps.command === 'setDonationParams') { + // setParams(ps) + // // Fetch the gift option data if they passed a gift option id + // if (ps.campaign_id && ps.gift_option_id) { + // requestGiftOptionParams(ps.campaign_id, ps.gift_option_id) + // } + // } + // } + // }, [container, setParams, requestGiftOptionParams]); + + + + return ( + ); + +} \ No newline at end of file diff --git a/app/javascript/components/legacy/nonprofits/donate/wizard.stories.tsx b/app/javascript/components/legacy/nonprofits/donate/wizard.stories.tsx new file mode 100644 index 000000000..77e7bdffa --- /dev/null +++ b/app/javascript/components/legacy/nonprofits/donate/wizard.stories.tsx @@ -0,0 +1,9 @@ +// License: LGPL-3.0-or-later +import * as React from 'react'; +import { action } from '@storybook/addon-actions'; + +import DonateWizard from './wizard'; + + + + diff --git a/app/javascript/components/legacy/nonprofits/donate/wizard.tsx b/app/javascript/components/legacy/nonprofits/donate/wizard.tsx new file mode 100644 index 000000000..cfccda0c6 --- /dev/null +++ b/app/javascript/components/legacy/nonprofits/donate/wizard.tsx @@ -0,0 +1,147 @@ +// License: LGPL-3.0-or-later +// based on: app/javascript/legacy/nonprofits/donate/wizard.js +import noop from 'lodash/noop'; +import React, { useReducer, useState, Dispatch, createContext } from 'react'; +import { useBrandedWizard } from '../../components/styles/branded-wizard'; +import Wizard from '../../_dependencies/ff-core/wizard'; +import { AmountStep } from './amount-step'; +import { Money } from '../../../../common/money'; + +import closeSvg from './close.svg'; +import FollowupStep from './followup-step'; + + +declare const I18n: any; +interface DonateWizardProps { + brandColor: string; + offsite: boolean; + embedded: boolean; + onClose: () => void; + title: string; // app.campaign.name || app.nonprofit.name + logo: string; //app.nonprofit.logo.normal + nonprofitName: string; + amountOptions: Money[]; +} + +export type ActionType = { + type: 'setAmount'; + amount: Money; +}; + + +function wizardOutputReducer(state: DonateWizardOutputState, action: ActionType): DonateWizardOutputState { + switch (action.type) { + case 'setAmount': + return { ...state, amount: action.amount }; + default: + throw new Error(); + } +} + +export const DonationWizardContext = createContext<{dispatch: Dispatch}>({dispatch:noop}); + + + +export interface DonateWizardOutputState { + amount: Money|null; + +} + +export default function DonateWizard(props: DonateWizardProps): JSX.Element { + useBrandedWizard(props.brandColor); + + // You might want to combine these into the donateWizardState reducer + const [error, setError] = useState(); + const [loading, setLoading] = useState(false); + + const [donateWizardState, stateDispatch] = useReducer(wizardOutputReducer,{amount: null}); + + const canClose = props.offsite || !props.embedded; + const hiddenCloseButton = !props.offsite || !props.embedded; + + return ( +
+ { + if (canClose) { + props.onClose(); + } + }} /> + +
+ +
+

{props.title}

+

+ {/* TODO state.params$().designation && !state.params$().single_amount + ? headerDesignation(state) + : app.campaign.tagline || app.nonprofit.tagline || '' */} +

+
+
+ + + {/* I'm not putting in the footer because it's not realy a useful feature */} + +
+ + ); +} + +DonateWizard.defaultProps = { + onClose: noop, + embedded: false, + offsite: false, + amountOptions: [100, 500, 1000, 2500, 5000].map((i)=> Money.fromCents(i, 'usd')), + + +} as DonateWizardProps; + +function HeaderDesignation(props: { brandColor: string, designation_desc?: string | null }): JSX.Element { + return ( + + {I18n.t('nonprofits.donate.amount.designation.label')} + {props.designation_desc ?
{props.designation_desc}
: null} +
); +} + +HeaderDesignation.defaultProps = { + brand_color: '', +}; + +interface WizardWrapperProps { + amount:Money; + amountOptions:Money[]; + nonprofitName: string; +} + + + +function WizardWrapper(props: WizardWrapperProps): JSX.Element { + return
+ } + + steps={ + [ + { + title: I18n.t('nonprofits.donate.amount.label'), + + key: I18n.t('nonprofits.donate.amount.label'), + body: , + }, + { + title: I18n.t('nonprofits.donate.info.label'), + key: I18n.t('nonprofits.donate.info.label'), + body:
InfoStep
, + }, + { + title: I18n.t('nonprofits.donate.payment.label'), + key: I18n.t('nonprofits.donate.payment.label'), + body:
PaymentStep
, + }, + ] + } /> +
; +} + +// from ff-core/wizard diff --git a/app/javascript/hooks/useSteps.ts b/app/javascript/hooks/useSteps.ts index 1c6bcbc69..edb47b995 100644 --- a/app/javascript/hooks/useSteps.ts +++ b/app/javascript/hooks/useSteps.ts @@ -127,7 +127,7 @@ export interface InputStepsState { } -interface StepsObject extends StepsInitOptions { +export interface StepsObject extends StepsInitOptions { readonly addStep: (step: KeyedStepWith, before?: number) => void; back: () => void; complete: (step: number) => void;