From 67d0ce24b456bf689fe240fe37b94693d081bd2b Mon Sep 17 00:00:00 2001 From: Sylvain Date: Wed, 7 Apr 2021 16:21:12 +0200 Subject: [PATCH] set payzen currency also: refactored the comments in the react components to fit high quality --- app/frontend/src/javascript/api/api-client.ts | 3 + app/frontend/src/javascript/api/setting.ts | 8 +- .../components/document-filters.tsx | 22 ++- .../src/javascript/components/fab-button.tsx | 8 +- .../src/javascript/components/fab-input.tsx | 38 ++++-- .../src/javascript/components/fab-modal.tsx | 10 +- .../javascript/components/html-translate.tsx | 6 +- .../javascript/components/labelled-input.tsx | 7 +- .../src/javascript/components/loader.tsx | 6 +- .../components/payment-schedule-summary.tsx | 9 +- .../payment-schedules-dashboard.tsx | 15 ++- .../components/payment-schedules-list.tsx | 16 ++- .../components/payment-schedules-table.tsx | 17 ++- .../components/payzen-keys-form.tsx | 4 + .../javascript/components/payzen-settings.tsx | 127 +++++++++++++----- .../src/javascript/components/plan-card.tsx | 7 +- .../components/select-gateway-modal.tsx | 1 + .../javascript/components/select-schedule.tsx | 9 +- .../javascript/components/stripe-confirm.tsx | 13 +- .../javascript/components/stripe-elements.tsx | 11 +- .../components/stripe-keys-form.tsx | 29 +++- .../javascript/components/stripe-modal.tsx | 10 +- .../src/javascript/components/wallet-info.tsx | 7 +- .../javascript/controllers/admin/invoices.js | 7 + app/frontend/src/javascript/models/setting.ts | 7 +- .../src/stylesheets/modules/fab-button.scss | 1 + .../stylesheets/modules/payzen-settings.scss | 31 +++++ .../templates/admin/invoices/payment.html | 2 +- app/models/setting.rb | 3 +- app/themes/casemate/style.scss.erb | 12 ++ config/locales/app.admin.en.yml | 7 + config/locales/app.admin.fr.yml | 7 + 32 files changed, 342 insertions(+), 118 deletions(-) diff --git a/app/frontend/src/javascript/api/api-client.ts b/app/frontend/src/javascript/api/api-client.ts index a1f9c1fe4..ab6ef7833 100644 --- a/app/frontend/src/javascript/api/api-client.ts +++ b/app/frontend/src/javascript/api/api-client.ts @@ -13,6 +13,9 @@ client.interceptors.response.use(function (response) { // Any status code that lie within the range of 2xx cause this function to trigger return response; }, function (error) { + // 304 Not Modified should be considered as a success + if (error.response?.status === 304) { return Promise.resolve(error.response); } + // Any status codes that falls outside the range of 2xx cause this function to trigger const message = error.response?.data || error.message || error; return Promise.reject(extractHumanReadableMessage(message)); diff --git a/app/frontend/src/javascript/api/setting.ts b/app/frontend/src/javascript/api/setting.ts index 187e75564..7de47c264 100644 --- a/app/frontend/src/javascript/api/setting.ts +++ b/app/frontend/src/javascript/api/setting.ts @@ -17,6 +17,12 @@ export default class SettingAPI { return SettingAPI.toSettingsMap(res?.data); } + async update (name: SettingName, value: any): Promise { + const res: AxiosResponse = await apiClient.patch(`/api/settings/${name}`, { setting: { value } }); + if (res.status === 304) { return { name, value }; } + return res?.data?.setting; + } + async bulkUpdate (settings: Map): Promise> { const res: AxiosResponse = await apiClient.patch('/api/settings/bulk_update', { settings: SettingAPI.toObjectArray(settings) }); return SettingAPI.toBulkMap(res?.data?.settings); @@ -46,7 +52,7 @@ export default class SettingAPI { const dataArray: Array> = Object.entries(data); const map = new Map(); dataArray.forEach(item => { - map.set(item[0] as SettingName, item[1]); + map.set(item[0] as SettingName, item[1] || ''); }); return map; } diff --git a/app/frontend/src/javascript/components/document-filters.tsx b/app/frontend/src/javascript/components/document-filters.tsx index 6a465fffa..375d83275 100644 --- a/app/frontend/src/javascript/components/document-filters.tsx +++ b/app/frontend/src/javascript/components/document-filters.tsx @@ -1,7 +1,3 @@ -/** - * This component shows 3 input fields for filtering invoices/payment-schedules by reference, customer name and date - */ - import React, { useEffect, useState } from 'react'; import { LabelledInput } from './labelled-input'; import { useTranslation } from 'react-i18next'; @@ -10,25 +6,43 @@ interface DocumentFiltersProps { onFilterChange: (value: { reference: string, customer: string, date: Date }) => void } +/** + * This component shows 3 input fields for filtering invoices/payment-schedules by reference, customer name and date + */ export const DocumentFilters: React.FC = ({ onFilterChange }) => { const { t } = useTranslation('admin'); + // stores the value of reference input const [referenceFilter, setReferenceFilter] = useState(''); + // stores the value of the customer input const [customerFilter, setCustomerFilter] = useState(''); + // stores the value of the date input const [dateFilter, setDateFilter] = useState(null); + /** + * When any filter changes, trigger the callback with the current value of all filters + */ useEffect(() => { onFilterChange({ reference: referenceFilter, customer: customerFilter, date: dateFilter }); }, [referenceFilter, customerFilter, dateFilter]) + /** + * Callback triggered when the input 'reference' is updated. + */ const handleReferenceUpdate = (e) => { setReferenceFilter(e.target.value); } + /** + * Callback triggered when the input 'customer' is updated. + */ const handleCustomerUpdate = (e) => { setCustomerFilter(e.target.value); } + /** + * Callback triggered when the input 'date' is updated. + */ const handleDateUpdate = (e) => { let date = e.target.value; if (e.target.value === '') date = null; diff --git a/app/frontend/src/javascript/components/fab-button.tsx b/app/frontend/src/javascript/components/fab-button.tsx index 6cf025bfd..61d355deb 100644 --- a/app/frontend/src/javascript/components/fab-button.tsx +++ b/app/frontend/src/javascript/components/fab-button.tsx @@ -1,7 +1,3 @@ -/** - * This component is a template for a clickable button that wraps the application style - */ - import React, { ReactNode, BaseSyntheticEvent } from 'react'; interface FabButtonProps { @@ -13,7 +9,9 @@ interface FabButtonProps { form?: string, } - +/** + * This component is a template for a clickable button that wraps the application style + */ export const FabButton: React.FC = ({ onClick, icon, className, disabled, type, form, children }) => { /** * Check if the current component was provided an icon to display diff --git a/app/frontend/src/javascript/components/fab-input.tsx b/app/frontend/src/javascript/components/fab-input.tsx index 635f2ea43..3a935983d 100644 --- a/app/frontend/src/javascript/components/fab-input.tsx +++ b/app/frontend/src/javascript/components/fab-input.tsx @@ -1,13 +1,9 @@ -/** - * This component is a template for an input component that wraps the application style - */ - import React, { BaseSyntheticEvent, ReactNode, useCallback, useEffect, useState } from 'react'; import { debounce as _debounce } from 'lodash'; interface FabInputProps { id: string, - onChange?: (value: any) => void, + onChange?: (value: any, validity?: ValidityState) => void, defaultValue: any, icon?: ReactNode, addOn?: ReactNode, @@ -17,12 +13,22 @@ interface FabInputProps { required?: boolean, debounce?: number, readOnly?: boolean, + maxLength?: number, + pattern?: string, + placeholder?: string, type?: 'text' | 'date' | 'password' | 'url' | 'time' | 'tel' | 'search' | 'number' | 'month' | 'email' | 'datetime-local' | 'week', } -export const FabInput: React.FC = ({ id, onChange, defaultValue, icon, className, disabled, type, required, debounce, addOn, addOnClassName, readOnly }) => { +/** + * This component is a template for an input component that wraps the application style + */ +export const FabInput: React.FC = ({ id, onChange, defaultValue, icon, className, disabled, type, required, debounce, addOn, addOnClassName, readOnly, maxLength, pattern, placeholder }) => { const [inputValue, setInputValue] = useState(defaultValue); + /** + * When the component is mounted, initialize the default value for the input. + * If the default value changes, update the value of the input until there's no content in it. + */ useEffect(() => { if (!inputValue) { setInputValue(defaultValue); @@ -55,13 +61,13 @@ export const FabInput: React.FC = ({ id, onChange, defaultValue, * Handle the change of content in the input field, and trigger the parent callback, if any */ const handleChange = (e: BaseSyntheticEvent): void => { - const newValue = e.target.value; - setInputValue(newValue); + const { value, validity } = e.target; + setInputValue(value); if (typeof onChange === 'function') { if (debounce) { - debouncedOnChange(newValue); + debouncedOnChange(value, validity); } else { - onChange(newValue); + onChange(value, validity); } } } @@ -69,7 +75,17 @@ export const FabInput: React.FC = ({ id, onChange, defaultValue, return (
{hasIcon() && {icon}} - + {hasAddOn() && {addOn}}
); diff --git a/app/frontend/src/javascript/components/fab-modal.tsx b/app/frontend/src/javascript/components/fab-modal.tsx index 48db1bdab..d06792a98 100644 --- a/app/frontend/src/javascript/components/fab-modal.tsx +++ b/app/frontend/src/javascript/components/fab-modal.tsx @@ -1,7 +1,3 @@ -/** - * This component is a template for a modal dialog that wraps the application style - */ - import React, { ReactNode, BaseSyntheticEvent } from 'react'; import Modal from 'react-modal'; import { useTranslation } from 'react-i18next'; @@ -31,10 +27,16 @@ interface FabModalProps { preventConfirm?: boolean } +// initial request to the API const blackLogoFile = CustomAssetAPI.get(CustomAssetName.LogoBlackFile); +/** + * This component is a template for a modal dialog that wraps the application style + */ export const FabModal: React.FC = ({ title, isOpen, toggleModal, children, confirmButton, className, width = 'sm', closeButton, customFooter, onConfirm, preventConfirm }) => { const { t } = useTranslation('shared'); + + // the theme's logo, for back backgrounds const blackLogo = blackLogoFile.read(); /** diff --git a/app/frontend/src/javascript/components/html-translate.tsx b/app/frontend/src/javascript/components/html-translate.tsx index 42f4cfeca..08cfa6988 100644 --- a/app/frontend/src/javascript/components/html-translate.tsx +++ b/app/frontend/src/javascript/components/html-translate.tsx @@ -1,6 +1,3 @@ -/** - * This component renders a translation with some HTML content. - */ import React from 'react'; import { useTranslation } from 'react-i18next'; @@ -9,6 +6,9 @@ interface HtmlTranslateProps { options?: any } +/** + * This component renders a translation with some HTML content. + */ export const HtmlTranslate: React.FC = ({ trKey, options }) => { const { t } = useTranslation(trKey?.split('.')[1]); diff --git a/app/frontend/src/javascript/components/labelled-input.tsx b/app/frontend/src/javascript/components/labelled-input.tsx index 6714c436a..7161bf814 100644 --- a/app/frontend/src/javascript/components/labelled-input.tsx +++ b/app/frontend/src/javascript/components/labelled-input.tsx @@ -1,7 +1,3 @@ -/** - * This component shows input field with its label, styled - */ - import React from 'react'; interface LabelledInputProps { @@ -12,6 +8,9 @@ interface LabelledInputProps { onChange: (value: any) => void } +/** + * This component shows input field with its label, styled + */ export const LabelledInput: React.FC = ({ id, type, label, value, onChange }) => { return (
diff --git a/app/frontend/src/javascript/components/loader.tsx b/app/frontend/src/javascript/components/loader.tsx index ff933f86d..6da35bf5a 100644 --- a/app/frontend/src/javascript/components/loader.tsx +++ b/app/frontend/src/javascript/components/loader.tsx @@ -1,10 +1,8 @@ +import React, { Suspense } from 'react'; + /** * This component is a wrapper that display a loader while the children components have their rendering suspended */ - -import React, { Suspense } from 'react'; - - export const Loader: React.FC = ({children }) => { const loading = (
diff --git a/app/frontend/src/javascript/components/payment-schedule-summary.tsx b/app/frontend/src/javascript/components/payment-schedule-summary.tsx index 5a0caf7c3..3c9cb29d2 100644 --- a/app/frontend/src/javascript/components/payment-schedule-summary.tsx +++ b/app/frontend/src/javascript/components/payment-schedule-summary.tsx @@ -1,7 +1,3 @@ -/** - * This component displays a summary of the monthly payment schedule for the current cart, with a subscription - */ - import React, { useState } from 'react'; import { useTranslation } from 'react-i18next'; import { react2angular } from 'react2angular'; @@ -20,8 +16,13 @@ interface PaymentScheduleSummaryProps { schedule: PaymentSchedule } +/** + * This component displays a summary of the monthly payment schedule for the current cart, with a subscription + */ const PaymentScheduleSummary: React.FC = ({ schedule }) => { const { t } = useTranslation('shared'); + + // is open, the modal dialog showing the full details of the payment schedule? const [modal, setModal] = useState(false); /** diff --git a/app/frontend/src/javascript/components/payment-schedules-dashboard.tsx b/app/frontend/src/javascript/components/payment-schedules-dashboard.tsx index cfe580ef4..5193c6d8b 100644 --- a/app/frontend/src/javascript/components/payment-schedules-dashboard.tsx +++ b/app/frontend/src/javascript/components/payment-schedules-dashboard.tsx @@ -1,8 +1,3 @@ -/** - * This component shows a list of all payment schedules with their associated deadlines (aka. PaymentScheduleItem) and invoices - * for the currentUser - */ - import React, { useEffect, useState } from 'react'; import { IApplication } from '../models/application'; import { useTranslation } from 'react-i18next'; @@ -20,14 +15,24 @@ interface PaymentSchedulesDashboardProps { currentUser: User } +// how many payment schedules should we display for each page? const PAGE_SIZE = 20; +/** + * This component shows a list of all payment schedules with their associated deadlines (aka. PaymentScheduleItem) and invoices + * for the currentUser + */ const PaymentSchedulesDashboard: React.FC = ({ currentUser }) => { const { t } = useTranslation('logged'); + // list of displayed payment schedules const [paymentSchedules, setPaymentSchedules] = useState>([]); + // current page const [pageNumber, setPageNumber] = useState(1); + /** + * When the component is loaded first, refresh the list of schedules to fill the first page. + */ useEffect(() => { handleRefreshList(); }, []); diff --git a/app/frontend/src/javascript/components/payment-schedules-list.tsx b/app/frontend/src/javascript/components/payment-schedules-list.tsx index 01c381e28..268ee6408 100644 --- a/app/frontend/src/javascript/components/payment-schedules-list.tsx +++ b/app/frontend/src/javascript/components/payment-schedules-list.tsx @@ -1,7 +1,3 @@ -/** - * This component shows a list of all payment schedules with their associated deadlines (aka. PaymentScheduleItem) and invoices - */ - import React, { useEffect, useState } from 'react'; import { IApplication } from '../models/application'; import { useTranslation } from 'react-i18next'; @@ -20,17 +16,29 @@ interface PaymentSchedulesListProps { currentUser: User } +// how many payment schedules should we display for each page? const PAGE_SIZE = 20; +/** + * This component shows a list of all payment schedules with their associated deadlines (aka. PaymentScheduleItem) and invoices + */ const PaymentSchedulesList: React.FC = ({ currentUser }) => { const { t } = useTranslation('admin'); + // list of displayed payment schedules const [paymentSchedules, setPaymentSchedules] = useState>([]); + // current page const [pageNumber, setPageNumber] = useState(1); + // current filter, by reference, for the schedules const [referenceFilter, setReferenceFilter] = useState(null); + // current filter, by customer's name, for the schedules const [customerFilter, setCustomerFilter] = useState(null); + // current filter, by date, for the schedules and the deadlines const [dateFilter, setDateFilter] = useState(null); + /** + * When the component is loaded first, refresh the list of schedules to fill the first page. + */ useEffect(() => { handleRefreshList(); }, []); diff --git a/app/frontend/src/javascript/components/payment-schedules-table.tsx b/app/frontend/src/javascript/components/payment-schedules-table.tsx index e40109cf9..8e108a7b6 100644 --- a/app/frontend/src/javascript/components/payment-schedules-table.tsx +++ b/app/frontend/src/javascript/components/payment-schedules-table.tsx @@ -1,7 +1,3 @@ -/** - * This component shows a list of all payment schedules with their associated deadlines (aka. PaymentScheduleItem) and invoices - */ - import React, { ReactEventHandler, ReactNode, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Loader } from './loader'; @@ -29,18 +25,31 @@ interface PaymentSchedulesTableProps { operator: User, } +/** + * This component shows a list of all payment schedules with their associated deadlines (aka. PaymentScheduleItem) and invoices + */ const PaymentSchedulesTableComponent: React.FC = ({ paymentSchedules, showCustomer, refreshList, operator }) => { const { t } = useTranslation('shared'); + // for each payment schedule: are the details (all deadlines) shown or hidden? const [showExpanded, setShowExpanded] = useState>(new Map()); + // is open, the modal dialog to confirm the cashing of a check? const [showConfirmCashing, setShowConfirmCashing] = useState(false); + // is open, the modal dialog the resolve a pending card payment? const [showResolveAction, setShowResolveAction] = useState(false); + // the user cannot confirm the action modal (3D secure), unless he has resolved the pending action const [isConfirmActionDisabled, setConfirmActionDisabled] = useState(true); + // is open, the modal dialog to update the card details const [showUpdateCard, setShowUpdateCard] = useState(false); + // when an action is triggered on a deadline, the deadline is saved here until the action is done or cancelled. const [tempDeadline, setTempDeadline] = useState(null); + // when an action is triggered on a deadline, the parent schedule is saved here until the action is done or cancelled. const [tempSchedule, setTempSchedule] = useState(null); + // prevent submitting the form to update the card details, until all required fields are filled correctly const [canSubmitUpdateCard, setCanSubmitUpdateCard] = useState(true); + // errors are saved here, if any, for display purposes. const [errors, setErrors] = useState(null); + // is open, the modal dialog to cancel the associated subscription? const [showCancelSubscription, setShowCancelSubscription] = useState(false); /** diff --git a/app/frontend/src/javascript/components/payzen-keys-form.tsx b/app/frontend/src/javascript/components/payzen-keys-form.tsx index e8df6f94f..91d661be0 100644 --- a/app/frontend/src/javascript/components/payzen-keys-form.tsx +++ b/app/frontend/src/javascript/components/payzen-keys-form.tsx @@ -18,8 +18,12 @@ interface PayZenKeysFormProps { onValidKeys: (payZenSettings: Map) => void } +// all settings related to PayZen that are requested by this form const payZenSettings: Array = [SettingName.PayZenUsername, SettingName.PayZenPassword, SettingName.PayZenEndpoint, SettingName.PayZenHmacKey, SettingName.PayZenPublicKey]; +// settings related the to PayZen REST API (server side) const restApiSettings: Array = [SettingName.PayZenUsername, SettingName.PayZenPassword, SettingName.PayZenEndpoint, SettingName.PayZenHmacKey]; + +// initial request to the API const payZenKeys = SettingAPI.query(payZenSettings); // Prevent multiples call to the payzen keys validation endpoint. diff --git a/app/frontend/src/javascript/components/payzen-settings.tsx b/app/frontend/src/javascript/components/payzen-settings.tsx index b960f58d8..a34548035 100644 --- a/app/frontend/src/javascript/components/payzen-settings.tsx +++ b/app/frontend/src/javascript/components/payzen-settings.tsx @@ -1,8 +1,4 @@ -/** - * This component displays a summary of the PayZen account keys, with a button triggering the modal to edit them - */ - -import React, { useEffect } from 'react'; +import React, { useEffect, useState } from 'react'; import { Loader } from './loader'; import { react2angular } from 'react2angular'; import { IApplication } from '../models/application'; @@ -16,13 +12,23 @@ import { FabButton } from './fab-button'; declare var Application: IApplication; interface PayzenSettingsProps { - onEditKeys: (onlinePaymentModule: {value: boolean}) => void + onEditKeys: (onlinePaymentModule: { value: boolean }) => void, + onCurrencyUpdateSuccess: (currency: string) => void } -const PAYZEN_HIDDEN = 'testpassword_HiDdEnHIddEnHIdDEnHiDdEnHIddEnHIdDEn'; +// placeholder value for the hidden settings +const PAYZEN_HIDDEN = 'HiDdEnHIddEnHIdDEnHiDdEnHIddEnHIdDEn'; + +// settings related to PayZen that can be shown publicly const payZenPublicSettings: Array = [SettingName.PayZenPublicKey, SettingName.PayZenEndpoint, SettingName.PayZenUsername]; +// settings related to PayZen that must be kept on server-side const payZenPrivateSettings: Array = [SettingName.PayZenPassword, SettingName.PayZenHmacKey]; -const payZenSettings: Array = payZenPublicSettings.concat(payZenPrivateSettings); +// other settings related to PayZen +const payZenOtherSettings: Array = [SettingName.PayZenCurrency]; +// all PayZen settings +const payZenSettings: Array = payZenPublicSettings.concat(payZenPrivateSettings).concat(payZenOtherSettings); + +// icons for the inputs of each setting const icons:Map = new Map([ [SettingName.PayZenHmacKey, 'subscript'], [SettingName.PayZenPassword, 'key'], @@ -31,17 +37,28 @@ const icons:Map = new Map([ [SettingName.PayZenPublicKey, 'info'] ]) -const payZenKeys = SettingAPI.query(payZenPublicSettings); +// initial requests to the API +const payZenKeys = SettingAPI.query(payZenPublicSettings.concat(payZenOtherSettings)); const isPresent = { [SettingName.PayZenPassword]: SettingAPI.isPresent(SettingName.PayZenPassword), [SettingName.PayZenHmacKey]: SettingAPI.isPresent(SettingName.PayZenHmacKey) }; -export const PayzenSettings: React.FC = ({ onEditKeys }) => { +/** + * This component displays a summary of the PayZen account keys, with a button triggering the modal to edit them + */ +export const PayzenSettings: React.FC = ({ onEditKeys, onCurrencyUpdateSuccess }) => { const { t } = useTranslation('admin'); + // all the values of the settings related to PayZen const [settings, updateSettings] = useImmer>(new Map(payZenSettings.map(name => [name, '']))); + // store a possible error state for currency + const [error, setError] = useState(''); + /** + * When the component is mounted, we initialize the values of the settings with those fetched from the API. + * For the private settings, we initialize them with the placeholder value, if the setting is set. + */ useEffect(() => { const map = payZenKeys.read(); for (const setting of payZenPrivateSettings) { @@ -51,42 +68,90 @@ export const PayzenSettings: React.FC = ({ onEditKeys }) => }, []); + /** + * Callback triggered when the user clicks on the "update keys" button. + * This will open the modal dialog allowing to change the keys + */ const handleKeysUpdate = (): void => { onEditKeys({ value: true }); } + /** + * Callback triggered when the user changes the content of the currency input field. + */ + const handleCurrencyUpdate = (value: string, validity?: ValidityState): void => { + if (!validity || validity.valid) { + setError(''); + updateSettings(draft => draft.set(SettingName.PayZenCurrency, value)); + } else { + setError(t('app.admin.invoices.payment.payzen.currency_error')); + } + } + + /** + * Callback triggered when the user clicks on the "save currency" button. + * This will update the setting on the server. + */ + const saveCurrency = (): void => { + const api = new SettingAPI(); + api.update(SettingName.PayZenCurrency, settings.get(SettingName.PayZenCurrency)).then(result => { + setError(''); + updateSettings(draft => draft.set(SettingName.PayZenCurrency, result.value)); + onCurrencyUpdateSuccess(result.value); + }, reason => { + setError(t('app.admin.invoices.payment.payzen.error_while_saving')+reason); + }) + } + return (

{t('app.admin.invoices.payment.payzen.payzen_keys')}

-
- {payZenSettings.map(setting => { - return ( -
- - -1 ? 'password' : 'text'} - icon={} - readOnly - disabled /> -
- ); - })} -
- {t('app.admin.invoices.payment.edit_keys')} -
-
+
+ {payZenPublicSettings.concat(payZenPrivateSettings).map(setting => { + return ( +
+ + -1 ? 'password' : 'text'} + icon={} + readOnly + disabled /> +
+ ); + })} +
+ {t('app.admin.invoices.payment.edit_keys')} +
+
+
+

{t('app.admin.invoices.payment.payzen.currency')}

+

+ {error &&

{error}

} +
+
+ + } + onChange={handleCurrencyUpdate} + maxLength={3} + pattern="[A-Z]{3}" /> +
+ {t('app.admin.invoices.payment.payzen.save')} +
+
); } -const PayzenSettingsWrapper: React.FC = ({ onEditKeys }) => { +const PayzenSettingsWrapper: React.FC = ({ onEditKeys, onCurrencyUpdateSuccess }) => { return ( - + ); } -Application.Components.component('payzenSettings', react2angular(PayzenSettingsWrapper, ['onEditKeys'])); +Application.Components.component('payzenSettings', react2angular(PayzenSettingsWrapper, ['onEditKeys', 'onCurrencyUpdateSuccess'])); diff --git a/app/frontend/src/javascript/components/plan-card.tsx b/app/frontend/src/javascript/components/plan-card.tsx index 2911e8e12..ac02f86b8 100644 --- a/app/frontend/src/javascript/components/plan-card.tsx +++ b/app/frontend/src/javascript/components/plan-card.tsx @@ -1,7 +1,3 @@ -/** - * This component is a "card" publicly presenting the details of a plan - */ - import React from 'react'; import { useTranslation } from 'react-i18next'; import { react2angular } from 'react2angular'; @@ -26,6 +22,9 @@ interface PlanCardProps { onSelectPlan: (plan: Plan) => void, } +/** + * This component is a "card" (visually), publicly presenting the details of a plan and allowing a user to subscribe. + */ const PlanCard: React.FC = ({ plan, userId, subscribedPlanId, operator, onSelectPlan, isSelected }) => { const { t } = useTranslation('public'); /** diff --git a/app/frontend/src/javascript/components/select-gateway-modal.tsx b/app/frontend/src/javascript/components/select-gateway-modal.tsx index 2d6b1bdcb..5e0935fbe 100644 --- a/app/frontend/src/javascript/components/select-gateway-modal.tsx +++ b/app/frontend/src/javascript/components/select-gateway-modal.tsx @@ -27,6 +27,7 @@ interface SelectGatewayModalModalProps { onSuccess: (results: Map) => void, } +// initial request to the API const paymentGateway = SettingAPI.get(SettingName.PaymentGateway); const SelectGatewayModal: React.FC = ({ isOpen, toggleModal, onError, onSuccess }) => { diff --git a/app/frontend/src/javascript/components/select-schedule.tsx b/app/frontend/src/javascript/components/select-schedule.tsx index 982fc05e4..b53e3206a 100644 --- a/app/frontend/src/javascript/components/select-schedule.tsx +++ b/app/frontend/src/javascript/components/select-schedule.tsx @@ -1,8 +1,3 @@ -/** - * This component is a switch enabling the users to choose if they want to pay by monthly schedule - * or with a one time payment - */ - import React from 'react'; import { useTranslation } from 'react-i18next'; import { react2angular } from 'react2angular'; @@ -20,6 +15,10 @@ interface SelectScheduleProps { className: string, } +/** + * This component is a switch enabling the users to choose if they want to pay by monthly schedule + * or with a one time payment + */ const SelectSchedule: React.FC = ({ show, selected, onChange, className }) => { const { t } = useTranslation('shared'); diff --git a/app/frontend/src/javascript/components/stripe-confirm.tsx b/app/frontend/src/javascript/components/stripe-confirm.tsx index 4064aa3b4..296c94c30 100644 --- a/app/frontend/src/javascript/components/stripe-confirm.tsx +++ b/app/frontend/src/javascript/components/stripe-confirm.tsx @@ -7,13 +7,23 @@ interface StripeConfirmProps { onResponse: () => void, } +/** + * This component runs a 3D secure confirmation for the given Stripe payment (identified by clientSecret). + * A message is shown, depending on the result of the confirmation. + * In case of success, a callback "onResponse" is also run. + */ export const StripeConfirm: React.FC = ({ clientSecret, onResponse }) => { const stripe = useStripe(); const { t } = useTranslation('shared'); + // the message displayed to the user const [message, setMessage] = useState(t('app.shared.stripe_confirm.pending')); + // the style class of the message const [type, setType] = useState('info'); + /** + * When the component is mounted, run the 3DS confirmation. + */ useEffect(() => { stripe.confirmCardPayment(clientSecret).then(function(result) { onResponse(); @@ -27,7 +37,8 @@ export const StripeConfirm: React.FC = ({ clientSecret, onRe setMessage(t('app.shared.stripe_confirm.success')); } }); - }, []) + }, []); + return
{message}
; diff --git a/app/frontend/src/javascript/components/stripe-elements.tsx b/app/frontend/src/javascript/components/stripe-elements.tsx index a3864495b..60faf9315 100644 --- a/app/frontend/src/javascript/components/stripe-elements.tsx +++ b/app/frontend/src/javascript/components/stripe-elements.tsx @@ -1,18 +1,21 @@ -/** - * This component initializes the stripe's Elements tag with the API key - */ - import React, { memo, useEffect, useState } from 'react'; import { Elements } from '@stripe/react-stripe-js'; import { loadStripe } from "@stripe/stripe-js"; import SettingAPI from '../api/setting'; import { SettingName } from '../models/setting'; +// initial request to the API const stripePublicKey = SettingAPI.get(SettingName.StripePublicKey); +/** + * This component initializes the stripe's Elements tag with the API key + */ export const StripeElements: React.FC = memo(({ children }) => { const [stripe, setStripe] = useState(undefined); + /** + * When this component is mounted, we initialize the tag with the Stripe's public key + */ useEffect(() => { const key = stripePublicKey.read(); const promise = loadStripe(key.value); diff --git a/app/frontend/src/javascript/components/stripe-keys-form.tsx b/app/frontend/src/javascript/components/stripe-keys-form.tsx index 17b60e891..87fb78471 100644 --- a/app/frontend/src/javascript/components/stripe-keys-form.tsx +++ b/app/frontend/src/javascript/components/stripe-keys-form.tsx @@ -1,7 +1,3 @@ -/** - * Form to set the stripe's public and private keys - */ - import React, { ReactNode, useEffect, useRef, useState } from 'react'; import { Loader } from './loader'; import { useTranslation } from 'react-i18next'; @@ -15,31 +11,52 @@ interface StripeKeysFormProps { onValidKeys: (stripePublic: string, stripeSecret:string) => void } +// initial request to the API const stripeKeys = SettingAPI.query([SettingName.StripePublicKey, SettingName.StripeSecretKey]); +/** + * Form to set the stripe's public and private keys + */ const StripeKeysFormComponent: React.FC = ({ onValidKeys }) => { const { t } = useTranslation('admin'); + // used to prevent promises from resolving if the component was unmounted const mounted = useRef(false); + // Stripe's public key const [publicKey, setPublicKey] = useState(''); + // Icon of the input field for the Stripe's public key. Used to display if the key is valid. const [publicKeyAddOn, setPublicKeyAddOn] = useState(null); - const [publicKeyAddOnClassName, setPublicKeyAddOnClassName] = useState(''); + // Style class for the add-on icon, for the public key + const [publicKeyAddOnClassName, setPublicKeyAddOnClassName] = useState<'key-invalid' | 'key-valid' | ''>(''); + // Stripe's secret key const [secretKey, setSecretKey] = useState(''); + // Icon of the input field for the Stripe's secret key. Used to display if the key is valid. const [secretKeyAddOn, setSecretKeyAddOn] = useState(null); - const [secretKeyAddOnClassName, setSecretKeyAddOnClassName] = useState(''); + // Style class for the add-on icon, for the public key + const [secretKeyAddOnClassName, setSecretKeyAddOnClassName] = useState<'key-invalid' | 'key-valid' | ''>(''); + /** + * When the component loads for the first time: + * - mark it as mounted + * - initialize the keys with the values fetched from the API (if any) + */ useEffect(() => { mounted.current = true; const keys = stripeKeys.read(); setPublicKey(keys.get(SettingName.StripePublicKey)); setSecretKey(keys.get(SettingName.StripeSecretKey)); + // when the component unmounts, mark it as unmounted return () => { mounted.current = false; }; }, []); + /** + * When the style class for the public and private key are updated, check if they indicate valid keys. + * If both are valid, run the 'onValidKeys' callback + */ useEffect(() => { const validClassName = 'key-valid'; if (publicKeyAddOnClassName === validClassName && secretKeyAddOnClassName === validClassName) { diff --git a/app/frontend/src/javascript/components/stripe-modal.tsx b/app/frontend/src/javascript/components/stripe-modal.tsx index 1a5934f41..3471f8315 100644 --- a/app/frontend/src/javascript/components/stripe-modal.tsx +++ b/app/frontend/src/javascript/components/stripe-modal.tsx @@ -1,8 +1,3 @@ -/** - * This component enables the user to input his card data or process payments. - * Supports Strong-Customer Authentication (SCA). - */ - import React, { ReactNode, useEffect, useState } from 'react'; import { react2angular } from 'react2angular'; import { Loader } from './loader'; @@ -41,8 +36,13 @@ interface StripeModalProps { customer: User } +// initial request to the API const cgvFile = CustomAssetAPI.get(CustomAssetName.CgvFile); +/** + * This component enables the user to input his card data or process payments. + * Supports Strong-Customer Authentication (SCA). + */ const StripeModal: React.FC = ({ isOpen, toggleModal, afterSuccess, cartItems, currentUser, schedule, customer }) => { // customer's wallet const [wallet, setWallet] = useState(null); diff --git a/app/frontend/src/javascript/components/wallet-info.tsx b/app/frontend/src/javascript/components/wallet-info.tsx index c2a332eb9..43e7c2180 100644 --- a/app/frontend/src/javascript/components/wallet-info.tsx +++ b/app/frontend/src/javascript/components/wallet-info.tsx @@ -1,7 +1,3 @@ -/** - * This component displays a summary of the amount paid with the virtual wallet, for the current transaction - */ - import React, { useState, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; import { react2angular } from 'react2angular'; @@ -26,6 +22,9 @@ interface WalletInfoProps { price: number, } +/** + * This component displays a summary of the amount paid with the virtual wallet, for the current transaction + */ export const WalletInfo: React.FC = ({ cartItems, currentUser, wallet, price }) => { const { t } = useTranslation('shared'); const [remainingPrice, setRemainingPrice] = useState(0); diff --git a/app/frontend/src/javascript/controllers/admin/invoices.js b/app/frontend/src/javascript/controllers/admin/invoices.js index 00b7579f6..8b02b1dd2 100644 --- a/app/frontend/src/javascript/controllers/admin/invoices.js +++ b/app/frontend/src/javascript/controllers/admin/invoices.js @@ -708,6 +708,13 @@ Application.Controllers.controller('InvoicesController', ['$scope', '$state', 'I console.error(errors); }; + /** + * Callback triggered when the PayZen currency was successfully updated + */ + $scope.alertPayZenCurrencyUpdated = function (currency) { + growl.success(_t('app.admin.invoices.payment.payzen.currency_updated', { CURRENCY: currency })); + }; + /** * Setup the feature-tour for the admin/invoices page. * This is intended as a contextual help (when pressing F1) diff --git a/app/frontend/src/javascript/models/setting.ts b/app/frontend/src/javascript/models/setting.ts index 372e5c86b..bbc5e0fae 100644 --- a/app/frontend/src/javascript/models/setting.ts +++ b/app/frontend/src/javascript/models/setting.ts @@ -107,14 +107,15 @@ export enum SettingName { PayZenPassword = 'payzen_password', PayZenEndpoint = 'payzen_endpoint', PayZenPublicKey = 'payzen_public_key', - PayZenHmacKey = 'payzen_hmac' + PayZenHmacKey = 'payzen_hmac', + PayZenCurrency = 'payzen_currency' } export interface Setting { name: SettingName, value: string, - last_update: Date, - history: Array + last_update?: Date, + history?: Array } export interface SettingError { diff --git a/app/frontend/src/stylesheets/modules/fab-button.scss b/app/frontend/src/stylesheets/modules/fab-button.scss index 68cd97f54..5f70a2d3a 100644 --- a/app/frontend/src/stylesheets/modules/fab-button.scss +++ b/app/frontend/src/stylesheets/modules/fab-button.scss @@ -17,6 +17,7 @@ border-radius: 4px; user-select: none; text-decoration: none; + height: 38px; &:hover { background-color: #f2f2f2; diff --git a/app/frontend/src/stylesheets/modules/payzen-settings.scss b/app/frontend/src/stylesheets/modules/payzen-settings.scss index dac490d81..a75f0bff4 100644 --- a/app/frontend/src/stylesheets/modules/payzen-settings.scss +++ b/app/frontend/src/stylesheets/modules/payzen-settings.scss @@ -14,4 +14,35 @@ margin: 5px 15px; padding-top: 28px; } + + .payzen-currency { + .currency-info { + padding: 15px; + margin-bottom: 24px; + border: 1px solid #faebcc; + border-radius: 4px; + color: #8a6d3b; + background-color: #fcf8e3; + } + .currency-error { + padding: 15px; + margin-bottom: 24px; + border: 1px solid #ebccd1; + color: #a94442; + background-color: #f2dede; + } + .payzen-currency-form { + display: flex; + flex-direction: row; + justify-content: flex-start; + align-items: flex-end; + + .currency-wrapper { + padding: 5px 15px; + } + .save-currency { + margin: 5px 15px; + } + } + } } diff --git a/app/frontend/templates/admin/invoices/payment.html b/app/frontend/templates/admin/invoices/payment.html index 357806ab4..11323d612 100644 --- a/app/frontend/templates/admin/invoices/payment.html +++ b/app/frontend/templates/admin/invoices/payment.html @@ -65,7 +65,7 @@
- +
diff --git a/app/models/setting.rb b/app/models/setting.rb index e649e0f72..b8bf88d72 100644 --- a/app/models/setting.rb +++ b/app/models/setting.rb @@ -115,7 +115,8 @@ class Setting < ApplicationRecord payzen_password payzen_endpoint payzen_public_key - payzen_hmac] } + payzen_hmac + payzen_currency] } # WARNING: when adding a new key, you may also want to add it in app/policies/setting_policy.rb#public_whitelist def value diff --git a/app/themes/casemate/style.scss.erb b/app/themes/casemate/style.scss.erb index a4326dd4b..9387c7269 100644 --- a/app/themes/casemate/style.scss.erb +++ b/app/themes/casemate/style.scss.erb @@ -356,3 +356,15 @@ section#cookies-modal div.cookies-consent .cookies-actions button.accept { color: $secondary-text-color; } } + +.payzen-settings { + .payzen-currency { + .payzen-currency-form { + .save-currency { + background-color: $secondary; + border-color: $secondary; + color: $secondary-text-color; + } + } + } +} diff --git a/config/locales/app.admin.en.yml b/config/locales/app.admin.en.yml index f5d25a521..7469c3b2c 100644 --- a/config/locales/app.admin.en.yml +++ b/config/locales/app.admin.en.yml @@ -654,6 +654,13 @@ en: payzen_endpoint: "REST API server name" payzen_hmac: "HMAC-SHA-256 key" payzen_public_key: "Client public key" + currency: "Currency" + payzen_currency: "PayZen currency" + currency_info_html: "Please specify below the currency used for online payment. You should provide a three-letter ISO code, from the list of PayZen supported currencies." + save: "Save" + currency_error: "The inputted value is not a valid currency" + error_while_saving: "An error occurred while saving the currency: " + currency_updated: "The PayZen currency was successfully updated to {CURRENCY}." # select a payment gateway gateway_modal: select_gateway_title: "Select a payment gateway" diff --git a/config/locales/app.admin.fr.yml b/config/locales/app.admin.fr.yml index 06a45d618..3d77f692b 100644 --- a/config/locales/app.admin.fr.yml +++ b/config/locales/app.admin.fr.yml @@ -654,6 +654,13 @@ fr: payzen_endpoint: "Nom du serveur de l'API REST" payzen_hmac: "Clef HMAC-SHA-256" payzen_public_key: "Clef publique client" + currency: "Devise" + payzen_currency: "Devise PayZen" + currency_info_html: "Veuillez indiquer la devise à utiliser lors des paiements en ligne. Vous devez fournir un code ISO à trois lettres, issu de la liste des devises supportées par PayZen." + save: "Enregistrer" + currency_error: "La valeur saisie n'est pas une devise valide" + error_while_saving: "Une erreur est survenue lors de l'enregistrement de la devise : " + currency_updated: "La devise PayZen a bien été mise à jour à {CURRENCY}." # select a payment gateway gateway_modal: select_gateway_title: "Sélectionnez une passerelle de paiement"