diff --git a/.env.local.example b/.env.local.example index 8ec1a117f..05f6842a5 100644 --- a/.env.local.example +++ b/.env.local.example @@ -32,7 +32,7 @@ GOOGLE_SECRET= ## Stripe ## ############## -STRIPE_PUBLIC_KEY= +STRIPE_PUBLISHABLE_KEY= ## Paypal ## ############## diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml index 0673eccbc..ff244c6d6 100644 --- a/.github/workflows/playwright.yml +++ b/.github/workflows/playwright.yml @@ -2,6 +2,8 @@ name: Playwright tests on: workflow_call: workflow_dispatch: +env: + STRIPE_DEV_PUBLISHABLE_KEY: ${{ vars.STRIPE_DEV_PUBLISHABLE_KEY }} jobs: run-playwright: @@ -95,6 +97,8 @@ jobs: - name: Start frontend working-directory: ./frontend run: yarn start &> frontend.log & + env: + STRIPE_PUBLISHABLE_KEY: ${{ env.STRIPE_DEV_PUBLISHABLE_KEY }} - name: Install Playwright Browsers working-directory: ./frontend/e2e diff --git a/e2e/data/donation-test.data.ts b/e2e/data/donation-test.data.ts new file mode 100644 index 000000000..2cd528998 --- /dev/null +++ b/e2e/data/donation-test.data.ts @@ -0,0 +1,26 @@ +export const stripeSuccessFormData = { + cardNumber: '4242 4242 4242 4242', + name: 'E2e_TEST_NAME', + email: 'e2e_test_mail@test.bg', + expiryDate: '04 / 42', + cvc: '424', + country: 'BG', +} + +export const stripeErrorNoBalanceFormData = { + cardNumber: '4000 0000 0000 9995', + name: 'E2e_TEST_NAME', + email: 'e2e_test_mail@test.bg', + expiryDate: '04 / 42', + cvc: '424', + country: 'BG', +} + +export const stripeAuthenticationRequiredFormData = { + cardNumber: '4000 0027 6000 3184', + name: 'E2e_TEST_NAME', + email: 'e2e_test_mail@test.bg', + expiryDate: '04 / 42', + cvc: '424', + country: 'BG', +} diff --git a/e2e/data/enums/donation-regions.enum.ts b/e2e/data/enums/donation-regions.enum.ts index 63d3476f6..327512e3e 100644 --- a/e2e/data/enums/donation-regions.enum.ts +++ b/e2e/data/enums/donation-regions.enum.ts @@ -1,14 +1,5 @@ -// This enum should be used as a parameter for methods in E2E tests - -// Check bgLocalizationOneTimeDonation["third-step"]["card-region"] -export enum bgDonationRegions { - EUROPE = 'Европа', - GREAT_BRITAIN = 'Великобритания', - OTHER = 'други', -} - -export enum enDonationRegions { - EUROPE = 'Europe', - GREAT_BRITAIN = 'Great Britain', - OTHER = 'other', +export enum DonationRegions { + EUROPE = 'EU', + GREAT_BRITAIN = 'UK', + OTHER = 'Other', } diff --git a/e2e/data/localization.ts b/e2e/data/localization.ts index 6397cbabd..8ce2194c2 100644 --- a/e2e/data/localization.ts +++ b/e2e/data/localization.ts @@ -13,8 +13,8 @@ import enLocalizationValidationJson from '../../public/locales/en/validation.jso import bgLocalizationCampaignsJson from '../../public/locales/bg/campaigns.json' import enLocalizationCampaignsJson from '../../public/locales/en/campaigns.json' -import bgLocalizationOneTimeDonationJson from '../../public/locales/bg/one-time-donation.json' -import enLocalizationOneTimeDonationJson from '../../public/locales/en/one-time-donation.json' +import bgLocalizationDonationFlowJson from '../../public/locales/bg/donation-flow.json' +import enLocalizationDonationFlowJson from '../../public/locales/en/donation-flow.json' // All these constants are used in the E2E test pages to manipulate web elements in a respective language // Common localization terms @@ -30,8 +30,8 @@ export const enLocalizationSupport = enLocalizationSupportJson export const bgLocalizationCampaigns = bgLocalizationCampaignsJson export const enLocalizationCampaigns = enLocalizationCampaignsJson // Donations -export const bgLocalizationOneTimeDonation = bgLocalizationOneTimeDonationJson -export const enLocalizationOneTimeDonation = enLocalizationOneTimeDonationJson +export const bgLocalizationDonationFlow = bgLocalizationDonationFlowJson +export const enLocalizationDonationFlow = enLocalizationDonationFlowJson // Validations export const bgLocalizationValidation = bgLocalizationValidationJson export const enLocalizationValidation = enLocalizationValidationJson diff --git a/e2e/data/support-page-tests.data.ts b/e2e/data/support-page-tests.data.ts index d93cddc8e..d08c6b1ad 100644 --- a/e2e/data/support-page-tests.data.ts +++ b/e2e/data/support-page-tests.data.ts @@ -5,11 +5,3 @@ export const supportPageVolutneerTestData = { phone: '+359888000000', comment: 'E2E Test comment', } - -export const anonDonationTestData = { - cardNumber: '4242 4242 4242 4242', - cardExpDate: '04 / 42', - cardCvc: '424', - billingName: 'E2E Test Anonymous Donation', - country: 'BG', -} diff --git a/e2e/pages/web-pages/campaigns/campaigns.page.ts b/e2e/pages/web-pages/campaigns/campaigns.page.ts index 720cef88a..887aeefd3 100644 --- a/e2e/pages/web-pages/campaigns/campaigns.page.ts +++ b/e2e/pages/web-pages/campaigns/campaigns.page.ts @@ -29,6 +29,9 @@ export class CampaignsPage extends HomePage { private readonly bgWishesButtonText = bgLocalizationCampaigns.campaign['wishes'] private readonly enWishesButtonText = enLocalizationCampaigns.campaign['wishes'] + /** + * Ovverride the method from the BasePage and add the specific selector for the Campaigns page as default + */ async checkPageUrlByRegExp(urlRegExpAsString?: string, timeoutParam = 10000): Promise { await this.page.waitForTimeout(1000) await expect(this.page, 'The URL is not correct!').toHaveURL( diff --git a/e2e/pages/web-pages/campaigns/donation.page.ts b/e2e/pages/web-pages/campaigns/donation.page.ts deleted file mode 100644 index 531af190e..000000000 --- a/e2e/pages/web-pages/campaigns/donation.page.ts +++ /dev/null @@ -1,244 +0,0 @@ -import { Page, expect } from '@playwright/test' -import { LanguagesEnum } from '../../../data/enums/languages.enum' -import { - bgLocalizationOneTimeDonation, - enLocalizationOneTimeDonation, -} from '../../../data/localization' -import { SLUG_REGEX } from '../../../utils/helpers' -import { CampaignsPage } from './campaigns.page' - -export class DonationPage extends CampaignsPage { - constructor(page: Page) { - super(page) - } - - // -> Select amount section <- - private readonly otherAmountInputField = ".MuiCollapse-entered input[name='otherAmount']" - private readonly allAmountsSelector = '.MuiBox-root strong' - private readonly regionsDropdownRootElement = '.MuiInputBase-root .MuiSelect-select' - private readonly regionsMenuList = '#menu-cardRegion ul.MuiMenu-list li' - private readonly gridRootSelector = '.MuiGrid-root' - private readonly forwardGridButton = this.gridRootSelector + ' button.MuiButton-contained' - // Section labels - private readonly bgSelectAmountSectionText = bgLocalizationOneTimeDonation['step-labels'].amount - private readonly enSelectAmountSectionText = enLocalizationOneTimeDonation['step-labels'].amount - // TODO Add these three IDs into the component (if possible) and update the test methods - private readonly donationAmount = this.allAmountsSelector + ' #donationAmount' - private readonly feeAmount = this.allAmountsSelector + ' #feeAmount' - private readonly totalChargedAmount = this.allAmountsSelector + ' #totalChargedAmount' - // Grid navigation buttons localization - private readonly bgForwardButtonText = bgLocalizationOneTimeDonation.btns.next - private readonly enForwardButtonText = enLocalizationOneTimeDonation.btns.next - - // -> Personal profile section <- - private readonly buttonsContainer = '.MuiTabs-flexContainer button' - private readonly bgDonateAnonymouslyText = - bgLocalizationOneTimeDonation['second-step']['donate-anonymously'] - private readonly enDonateAnonymouslyText = - enLocalizationOneTimeDonation['second-step']['donate-anonymously'] - private readonly inputRootSelector = '.MuiInputBase-root' - private readonly donateAnonymouslyEmailField = - this.inputRootSelector + " input[name='personsEmail']" - // Section labels - private readonly bgPersonalProfileSectionText = - bgLocalizationOneTimeDonation['step-labels']['personal-profile'] - private readonly enPersonalProfileSectionText = - enLocalizationOneTimeDonation['step-labels']['personal-profile'] - - // -> Send a wish section <- - // Section labels - private readonly sendAWishField = this.inputRootSelector + " textarea[name='message']" - private readonly bgSendAWishSectionText = bgLocalizationOneTimeDonation['step-labels'].wish - private readonly enSendAWishSectionText = enLocalizationOneTimeDonation['step-labels'].wish - - // -> Payment <- - // Section labels - private readonly bgPaymentSectionText = bgLocalizationOneTimeDonation['step-labels'].payment - private readonly enPaymentSectionText = enLocalizationOneTimeDonation['step-labels'].payment - private readonly bgFinishButtonText = bgLocalizationOneTimeDonation.btns.end - private readonly enFinishButtonText = enLocalizationOneTimeDonation.btns.end - private readonly bgSuccessfulDonationTitle = bgLocalizationOneTimeDonation.success.title - private readonly enSuccessfulDonationTitle = enLocalizationOneTimeDonation.success.title - - async checkPageUrlByRegExp(urlRegExpAsString?: string, timeoutParam = 10000): Promise { - await this.page.waitForTimeout(1000) - await expect(this.page, 'The URL is not correct!').toHaveURL( - new RegExp(urlRegExpAsString || `^(.*?)/campaigns/donation/${SLUG_REGEX}`), - { - timeout: timeoutParam, - }, - ) - } - - /** - * Is "Select amount" step active - */ - async isSelectAmountStepActive(language: LanguagesEnum = LanguagesEnum.BG): Promise { - if (language === LanguagesEnum.BG) { - return this.isStepActiveByLabelText(this.bgSelectAmountSectionText) - } else if (language === LanguagesEnum.EN) { - return this.isStepActiveByLabelText(this.enSelectAmountSectionText) - } else { - throw new Error('Language not found!') - } - } - - /** - * Is "Personal profile" step active - */ - async isPersonalProfileStepActive(language: LanguagesEnum = LanguagesEnum.BG): Promise { - if (language === LanguagesEnum.BG) { - return this.isStepActiveByLabelText(this.bgPersonalProfileSectionText) - } else if (language === LanguagesEnum.EN) { - return this.isStepActiveByLabelText(this.enPersonalProfileSectionText) - } else { - throw new Error('Language not found!') - } - } - - /** - * Is "Send a wish" step active - */ - async isSendAWishStepActive(language: LanguagesEnum = LanguagesEnum.BG): Promise { - if (language === LanguagesEnum.BG) { - return this.isStepActiveByLabelText(this.bgSendAWishSectionText) - } else if (language === LanguagesEnum.EN) { - return this.isStepActiveByLabelText(this.enSendAWishSectionText) - } else { - throw new Error('Language not found!') - } - } - - /** - * Is "Payment" step active - */ - async isPaymentStepActive(language: LanguagesEnum = LanguagesEnum.BG): Promise { - if (language === LanguagesEnum.BG) { - return this.isStepActiveByLabelText(this.bgPaymentSectionText) - } else if (language === LanguagesEnum.EN) { - return this.isStepActiveByLabelText(this.enPaymentSectionText) - } else { - throw new Error('Language not found!') - } - } - - /** - * Fill in the desired amount of money for donation into the Other Amount input field - * @param {string} amountMoney - */ - async fillOtherAmountInputField(amountMoney: string): Promise { - await this.waitForElementToBePresentedBySelector(this.otherAmountInputField) - await this.setInputFieldBySelector(this.otherAmountInputField, amountMoney) - } - - /** - * Set donation region from the dropdown menu - * @param {string} desiredRegion - */ - async setDonationRegionFromTheDropdown(desiredRegion: string): Promise { - await this.clickElement(this.regionsDropdownRootElement) - await this.clickElement(this.regionsMenuList, { hasText: desiredRegion }) - } - - /** - * Get Total charged amounts as text - */ - async getTotalChargedAmountsAsText(): Promise { - const donationAmount = this.page.locator(this.allAmountsSelector).nth(0) - return this.getTextOfElementByLocator(donationAmount) - // TODO Uncomment when the IDs are added - // return this.getTextOfElementBySelector(this.totalChargedAmount); - } - - /** - * Get Fee amounts as text - */ - async getFeeAmountsAsText(): Promise { - const donationAmount = this.page.locator(this.allAmountsSelector).nth(1) - return this.getTextOfElementByLocator(donationAmount) - // TODO Uncomment when the IDs are added - // return this.getTextOfElementBySelector(this.feeAmount); - } - - /** - * Get Donation amounts as text - */ - async getDonationAmountsAsText(): Promise { - const donationAmount = this.page.locator(this.allAmountsSelector).nth(2) - return this.getTextOfElementByLocator(donationAmount) - // TODO Uncomment when the IDs are added - // return this.getTextOfElementBySelector(this.donationAmount); - } - - /** - * Click Forward/Next button into the donation grid - * @param {LanguagesEnum} language - */ - async clickForwardButton(language: LanguagesEnum = LanguagesEnum.BG): Promise { - if (language === LanguagesEnum.BG) { - await this.clickElement(this.forwardGridButton, { hasText: this.bgForwardButtonText }) - } else if (language === LanguagesEnum.EN) { - await this.clickElement(this.forwardGridButton, { hasText: this.enForwardButtonText }) - } else { - throw new Error('Language not found!') - } - } - - /** - * Click Finish/Go to payment button into the donation grid - * @param {LanguagesEnum} language - */ - async clickFinishButton(language: LanguagesEnum = LanguagesEnum.BG): Promise { - if (language === LanguagesEnum.BG) { - await this.clickElement(this.forwardGridButton, { hasText: this.bgFinishButtonText }) - } else if (language === LanguagesEnum.EN) { - await this.clickElement(this.forwardGridButton, { hasText: this.enFinishButtonText }) - } else { - throw new Error('Language not found!') - } - } - - /** - * Click Donate Anonymously button into the donation grid (Personal Profile step) - * @param {LanguagesEnum} language - */ - async clickDonateAnonymouslyButton(language: LanguagesEnum = LanguagesEnum.BG): Promise { - if (language === LanguagesEnum.BG) { - await this.clickElement(this.buttonsContainer, { hasText: this.bgDonateAnonymouslyText }) - } else if (language === LanguagesEnum.EN) { - await this.clickElement(this.buttonsContainer, { hasText: this.enDonateAnonymouslyText }) - } else { - throw new Error('Language not found!') - } - } - - /** - * Fill Donate anonymously E-mail input field - * @param {string} emailText - */ - async fillDonateAnonymouslyEmailField(emailText: string): Promise { - await this.setInputFieldBySelector(this.donateAnonymouslyEmailField, emailText) - } - - /** - * Fill Send a wish input field - * @param {string} wishText - */ - async fillSendAWishField(wishText: string): Promise { - await this.setInputFieldBySelector(this.sendAWishField, wishText) - } - - /** - * Is "We thank you for your help and trust!" title visible - * @param {LanguagesEnum} language - the default value is BG - */ - async isSuccessfulDonationTitleVisible( - language: LanguagesEnum = LanguagesEnum.BG, - ): Promise { - return this.isH4HeadingVisible( - language, - this.bgSuccessfulDonationTitle, - this.enSuccessfulDonationTitle, - ) - } -} diff --git a/e2e/pages/web-pages/donation/donation-status.page.ts b/e2e/pages/web-pages/donation/donation-status.page.ts new file mode 100644 index 000000000..cdcd7f43f --- /dev/null +++ b/e2e/pages/web-pages/donation/donation-status.page.ts @@ -0,0 +1,42 @@ +import { Page, expect } from '@playwright/test' +import { LanguagesEnum } from '../../../data/enums/languages.enum' +import { bgLocalizationDonationFlow, enLocalizationDonationFlow } from '../../../data/localization' +import { SLUG_REGEX } from '../../../utils/helpers' +import { CampaignsPage } from '../campaigns/campaigns.page' +export class DonationStatusPage extends CampaignsPage { + constructor(page: Page) { + super(page) + } + + // -> Status titles <- + private readonly bgSuccessTitle = bgLocalizationDonationFlow.status.success.title + private readonly enSuccessTitle = enLocalizationDonationFlow.status.success.title + + // -> Wish form <- + private readonly wishSendText = bgLocalizationDonationFlow.status.success.wish.send + + async checkPageUrlByRegExp(urlRegExpAsString?: string, timeoutParam = 10000): Promise { + await expect(this.page, 'The URL is not correct!').toHaveURL( + new RegExp(urlRegExpAsString || `^(.*?)/campaigns/donation/${SLUG_REGEX}/status?.+$`, 'g'), + { + timeout: timeoutParam, + }, + ) + } + + async isSucceededStatusTitleDisplayed( + language: LanguagesEnum = LanguagesEnum.BG, + ): Promise { + return this.isH4HeadingVisible(language, this.bgSuccessTitle, this.enSuccessTitle) + } + + async submitWishForm(): Promise { + const wishAreaLocator = await this.page.locator('textarea[name="wish"]') + await this.waitForElementToBeReadyByLocator(wishAreaLocator) + await wishAreaLocator.fill('e2e_test_wish') + const buttonLocator = await this.page.locator('button[type="submit"]', { + hasText: this.wishSendText, + }) + await this.clickElementByLocator(buttonLocator) + } +} diff --git a/e2e/pages/web-pages/donation/donation.page.ts b/e2e/pages/web-pages/donation/donation.page.ts new file mode 100644 index 000000000..a5d58287d --- /dev/null +++ b/e2e/pages/web-pages/donation/donation.page.ts @@ -0,0 +1,232 @@ +import { Page, expect } from '@playwright/test' + +import { + DonationFormAuthState, + DonationFormPaymentMethod, + PaymentMode, +} from '../../../../src/components/client/donation-flow/helpers/types' +import { + stripeSuccessFormData, + stripeErrorNoBalanceFormData, +} from '../../../data/donation-test.data' +import { DonationRegions } from '../../../data/enums/donation-regions.enum' +import { LanguagesEnum } from '../../../data/enums/languages.enum' +import { + bgLocalizationDonationFlow, + bgLocalizationValidation, + enLocalizationDonationFlow, + enLocalizationValidation, +} from '../../../data/localization' +import { SLUG_REGEX } from '../../../utils/helpers' +import { CampaignsPage } from '../campaigns/campaigns.page' +export class DonationPage extends CampaignsPage { + constructor(page: Page) { + super(page) + } + + // -> Select amount section <- + private readonly bgSelectAmountSectionText = bgLocalizationDonationFlow.step.amount.title + private readonly enSelectAmountSectionText = enLocalizationDonationFlow.step.amount.title + private readonly otherAmountInputField = ".MuiCollapse-entered input[name='otherAmount']" + + // -> Payment method section <- + private readonly regionsDropdownRootElement = '.MuiInputBase-root .MuiSelect-select' + private readonly regionsMenuList = '#menu-cardRegion ul.MuiMenu-list li' + private readonly bgBankTransferText = + bgLocalizationDonationFlow.step['payment-method'].field.method.bank + private readonly enBankTransferText = + enLocalizationDonationFlow.step['payment-method'].field.method.bank + private readonly bgCardText = bgLocalizationDonationFlow.step['payment-method'].field.method.card + private readonly enCardText = enLocalizationDonationFlow.step['payment-method'].field.method.card + // -> Authentication section <- + private readonly bgLoginText = bgLocalizationDonationFlow.step.authentication.login.label + private readonly enLoginText = enLocalizationDonationFlow.step.authentication.login.label + private readonly bgRegisterText = bgLocalizationDonationFlow.step.authentication.register.label + private readonly enRegisterText = enLocalizationDonationFlow.step.authentication.register.label + private readonly bgNoRegitserText = + bgLocalizationDonationFlow.step.authentication.noregister.label + private readonly enNoRegitserText = + enLocalizationDonationFlow.step.authentication.noregister.label + + // -> Summary section <- + private readonly totalAmountSelector = '[data-testid="total-amount"]' + private readonly bgSubmitButtonText = bgLocalizationDonationFlow.action.submit + private readonly enSubmitButtonText = enLocalizationDonationFlow.action.submit + private readonly bgPrivacyCheckboxText = + bgLocalizationValidation['informed-agree-with'] + ' ' + bgLocalizationValidation.gdpr + private readonly enPrivacyCheckboxText = + enLocalizationValidation['informed-agree-with'] + ' ' + enLocalizationValidation.gdpr + private readonly bgStripeErrorNoBalanceText = 'Картата Ви не разполага с достатъчно средства.' + + async checkPageUrlByRegExp(urlRegExpAsString?: string, timeoutParam = 10000): Promise { + await this.page.waitForTimeout(1000) + await expect(this.page, 'The URL is not correct!').toHaveURL( + new RegExp(urlRegExpAsString || `^(.*?)/campaigns/donation/${SLUG_REGEX}`), + { + timeout: timeoutParam, + }, + ) + } + + /** + * Is "Select amount" step active + */ + async isSelectAmountStepActive(language: LanguagesEnum = LanguagesEnum.BG): Promise { + if (language === LanguagesEnum.BG) { + return this.isStepActiveByLabelText(this.bgSelectAmountSectionText) + } else if (language === LanguagesEnum.EN) { + return this.isStepActiveByLabelText(this.enSelectAmountSectionText) + } else { + throw new Error('Language not found!') + } + } + + /** + * Fill in the desired amount of money for donation into the Other Amount input field + * @param {string} amountMoney + */ + async fillOtherAmountInputField(amountMoney: string): Promise { + await this.waitForElementToBePresentedBySelector(this.otherAmountInputField) + await this.setInputFieldBySelector(this.otherAmountInputField, amountMoney) + } + + /** + * Set donation region from the dropdown menu + * @param {string} desiredRegion + */ + async setDonationRegionFromTheDropdown(desiredRegion: DonationRegions): Promise { + await this.clickElement(this.regionsDropdownRootElement) + await this.clickElement(this.regionsMenuList + `[data-value=${desiredRegion}]`) + } + + /** + * Select payment method + * @param {DonationFormPaymentMethod} method + */ + async selectPaymentMethod( + method: DonationFormPaymentMethod, + language: LanguagesEnum = LanguagesEnum.BG, + ): Promise { + const cardText = language === LanguagesEnum.BG ? this.bgCardText : this.enCardText + const bankText = + language === LanguagesEnum.BG ? this.bgBankTransferText : this.enBankTransferText + + if (method === DonationFormPaymentMethod.BANK) { + await this.page + .getByText(bankText, { + exact: true, + }) + .click() + } else if (method === DonationFormPaymentMethod.CARD) { + await this.page + .getByText(cardText, { + exact: true, + }) + .click() + } else { + throw new Error('Payment method not found!') + } + } + + /** + * Fill in the Stripe form with the test card data + */ + async fillCardForm(options: { fail?: boolean }): Promise { + const data = options.fail ? stripeErrorNoBalanceFormData : stripeSuccessFormData + const baseEmailLocator = this.page + .locator('[data-testid="stripe-payment-form"]') + .frameLocator('iframe') + .first() + const baseCardPaymentLocator = this.page + .locator('[data-testid="stripe-payment-form"]') + .frameLocator('iframe') + .last() + const emailField = baseEmailLocator.locator('input[name="email"]') + const nameField = this.page.locator('input[name="billingName"]') + const cardNumberField = baseCardPaymentLocator.locator('input[name="number"]') + const cardExpiryField = baseCardPaymentLocator.locator('input[name="expiry"]') + const cvcField = baseCardPaymentLocator.locator('input[name="cvc"]') + const countrySelect = baseCardPaymentLocator.locator('select[name="country"]') + await emailField.fill(data.email) + await nameField.fill(data.name) + await cardNumberField.fill(data.cardNumber) + await cardExpiryField.fill(data.expiryDate) + await cvcField.fill(data.cvc) + await countrySelect.selectOption(data.country) + } + + /** + * Set donation region from the radio cards + * @param {number} amount + */ + async hasPaymentErrorMessage(): Promise { + const errorAlert = await this.page.locator('strong.MuiTypography-root', { + hasText: this.bgStripeErrorNoBalanceText, + }) + await this.waitForElementToBePresentedByLocator(errorAlert) + return errorAlert.isVisible() + } + + /** + * Select authentication method + * @param {DonationFormAuthState} auth + */ + async selectAuthentication( + auth: DonationFormAuthState, + language: LanguagesEnum = LanguagesEnum.BG, + ): Promise { + const baseLocator = this.page.locator('span.MuiFormControlLabel-label') + + const loginText = language === 'BG' ? this.bgLoginText : this.enLoginText + const registerText = language === 'BG' ? this.bgRegisterText : this.enRegisterText + const noRegisterText = language === 'BG' ? this.bgNoRegitserText : this.enNoRegitserText + if (auth === DonationFormAuthState.LOGIN) { + await baseLocator + .getByText(loginText, { + exact: true, + }) + .click() + } else if (auth === DonationFormAuthState.REGISTER) { + await baseLocator.getByText(registerText, { + exact: true, + }) + } else if (auth === DonationFormAuthState.NOREGISTER) { + await baseLocator + .getByText(noRegisterText, { + exact: true, + }) + .click() + } + } + + /** + * Set donation region from the radio cards + * @param {number} amount + */ + async checkTotalAmount( + amount: number, + language: LanguagesEnum = LanguagesEnum.BG, + ): Promise { + const totalAmount = await this.page.locator(this.totalAmountSelector).first().textContent() + const totalAmountSpaceFix = totalAmount?.replace(/\s/, String.fromCharCode(160)) + const donationAmountIntl = Intl.NumberFormat(language, { + style: 'currency', + currency: 'BGN', + }).format(amount) + + expect(totalAmountSpaceFix).toEqual(donationAmountIntl) + } + + async checkPrivacyCheckbox(language: LanguagesEnum = LanguagesEnum.BG): Promise { + const privacyCheckbox = + language === 'BG' ? this.bgPrivacyCheckboxText : this.enPrivacyCheckboxText + await this.selectCheckboxByLabelText([privacyCheckbox]) + } + + async submitForm(language: LanguagesEnum = LanguagesEnum.BG): Promise { + const submitButtonText = language === 'BG' ? this.bgSubmitButtonText : this.enSubmitButtonText + console.log(submitButtonText) + const button = this.page.locator(`button:has-text("${submitButtonText}")`).last() + button.click() + } +} diff --git a/e2e/tests/regression/campaign-flow/campaign-view.spec.ts b/e2e/tests/regression/campaign-flow/campaign-view.spec.ts index dfba13f49..216413847 100644 --- a/e2e/tests/regression/campaign-flow/campaign-view.spec.ts +++ b/e2e/tests/regression/campaign-flow/campaign-view.spec.ts @@ -2,7 +2,7 @@ import { test, expect, Page } from '@playwright/test' import { HeaderPage } from '../../../pages/web-pages/header.page' import { HomePage } from '../../../pages/web-pages/home.page' import { CampaignsPage } from '../../../pages/web-pages/campaigns/campaigns.page' -import { DonationPage } from '../../../pages/web-pages/campaigns/donation.page' +import { DonationPage } from '../../../pages/web-pages/donation/donation.page' import { StripeCheckoutPage } from '../../../pages/web-pages/external/stripe-checkout.page' import { LanguagesEnum } from '../../../data/enums/languages.enum' @@ -28,7 +28,7 @@ test.describe.serial( // For local executions use method navigateToLocalhostHomepage(); // await homepage.navigateToLocalhostHomepage(); await homepage.navigateToEnvHomepage() - await headerPage.changeLanguageToBe(LanguagesEnum.EN) + await headerPage.changeLanguageToBe(LanguagesEnum.BG) }) test.afterAll(async () => { diff --git a/e2e/tests/regression/donation-flow/anon-donation-custom.spec.ts b/e2e/tests/regression/donation-flow/anon-donation-custom.spec.ts index 2d649c499..66a62a64b 100644 --- a/e2e/tests/regression/donation-flow/anon-donation-custom.spec.ts +++ b/e2e/tests/regression/donation-flow/anon-donation-custom.spec.ts @@ -2,12 +2,15 @@ import { test, expect, Page } from '@playwright/test' import { HeaderPage } from '../../../pages/web-pages/header.page' import { HomePage } from '../../../pages/web-pages/home.page' import { CampaignsPage } from '../../../pages/web-pages/campaigns/campaigns.page' -import { bgLocalizationOneTimeDonation } from '../../../data/localization' -import { DonationPage } from '../../../pages/web-pages/campaigns/donation.page' -import { bgDonationRegions } from '../../../data/enums/donation-regions.enum' -import { StripeCheckoutPage } from '../../../pages/web-pages/external/stripe-checkout.page' -import { anonDonationTestData } from '../../../data/support-page-tests.data' +import { DonationPage } from '../../../pages/web-pages/donation/donation.page' import { LanguagesEnum } from '../../../data/enums/languages.enum' +import { bgLocalizationDonationFlow } from '../../../data/localization' +import { + DonationFormAuthState, + DonationFormPaymentMethod, +} from '../../../../src/components/client/donation-flow/helpers/types' +import { DonationStatusPage } from '../../../pages/web-pages/donation/donation-status.page' +import { DonationRegions } from '../../../data/enums/donation-regions.enum' // This spec contains E2E tests related to anonymous donation flow - custom amount // The tests are dependent, the whole describe should be runned @@ -19,11 +22,12 @@ test.describe.serial( let headerPage: HeaderPage let campaignsPage: CampaignsPage let donationPage: DonationPage - let stripeCheckoutPage: StripeCheckoutPage - const testEmail = 'E2E_Test_Anon_Donation@e2etest.com' + let statusPage: DonationStatusPage // Localization texts - const otherAmountText = bgLocalizationOneTimeDonation['first-step'].other - const bgCardIncludeFeesText = bgLocalizationOneTimeDonation['third-step']['card-include-fees'] + const otherAmountText = bgLocalizationDonationFlow.step.amount.field['other-amount'].label + const paymentMode = bgLocalizationDonationFlow.step['payment-mode'].fields['one-time'] + const bgCardIncludeFeesText = + bgLocalizationDonationFlow.step['payment-method'].field['include-fees'].label test.use({ locale: 'bg-BG' }) //this is to ensure decimal separator is correctly expected @@ -33,7 +37,7 @@ test.describe.serial( headerPage = new HeaderPage(page) campaignsPage = new CampaignsPage(page) donationPage = new DonationPage(page) - stripeCheckoutPage = new StripeCheckoutPage(page) + statusPage = new DonationStatusPage(page) // For local executions use method navigateToLocalhostHomepage(); // await homepage.navigateToLocalhostHomepage(); await homepage.navigateToEnvHomepage() @@ -58,81 +62,40 @@ test.describe.serial( test('The total charge, fee tax and donation amount are visible on the Campaign page', async () => { await campaignsPage.clickDonationSupportButton() await donationPage.checkPageUrlByRegExp() - expect - .soft(await donationPage.isSelectAmountStepActive(), 'Select Amount step is not active.') - .toBeTruthy() await donationPage.selectRadioButtonByLabelText([otherAmountText]) - await donationPage.fillOtherAmountInputField('75') - await donationPage.setDonationRegionFromTheDropdown(bgDonationRegions.EUROPE) + await donationPage.fillOtherAmountInputField('8') + await donationPage.selectPaymentMethod(DonationFormPaymentMethod.CARD) + await donationPage.setDonationRegionFromTheDropdown(DonationRegions.EUROPE) await donationPage.selectCheckboxByLabelText([bgCardIncludeFeesText]) - // Expected pattern: - // За вашия превод от {totalChargedAmountText} лв., таксата на Stripe ще е {feeAmountText} лв., а кампанията ще получи {donationAmountText} лв. - const totalChargedAmountText = await donationPage.getTotalChargedAmountsAsText() - const feeAmountText = await donationPage.getFeeAmountsAsText() - const donationAmountText = await donationPage.getDonationAmountsAsText() - expect.soft(totalChargedAmountText).toEqual('76,42 лв.') - expect.soft(feeAmountText).toEqual('1,42 лв.') - expect(donationAmountText).toEqual('75,00 лв.') }) test('The total charge, fee tax and donation amount are recalculated correctly when the donation amount is changed', async () => { await donationPage.fillOtherAmountInputField('120') - // Expected pattern: - // За вашия превод от {totalChargedAmountText} лв., таксата на Stripe ще е {feeAmountText} лв., а кампанията ще получи {donationAmountText} лв. - const totalChargedAmountText = await donationPage.getTotalChargedAmountsAsText() - const feeAmountText = await donationPage.getFeeAmountsAsText() - const donationAmountText = await donationPage.getDonationAmountsAsText() - expect.soft(totalChargedAmountText).toEqual('121,96 лв.') - expect.soft(feeAmountText).toEqual('1,96 лв.') - expect(donationAmountText).toEqual('120,00 лв.') + await donationPage.checkTotalAmount(121.96) }) - test('The user is able to fill in e-mail for anonymous donation', async () => { - await donationPage.clickForwardButton() - expect - .soft( - await donationPage.isPersonalProfileStepActive(), - 'Personal Profile step is not active.', - ) - .toBeTruthy() - await donationPage.clickDonateAnonymouslyButton() - await donationPage.fillDonateAnonymouslyEmailField(testEmail) - await donationPage.clickForwardButton() - expect( - await donationPage.isSendAWishStepActive(), - 'Send a wish step is not active.', - ).toBeTruthy() + test('Select payment type', async () => { + await donationPage.selectRadioButtonByLabelText([paymentMode]) }) - test('After sending a wish, the user is redirected to Stripe', async () => { - await donationPage.fillSendAWishField('E2E test - anonymous donation.') - await donationPage.clickFinishButton() - const stripeTotalAmount = await stripeCheckoutPage.getTotalAmountText() - const actualStripeEmail = await stripeCheckoutPage.getReadonlyEmailText() - expect - .soft(stripeTotalAmount, 'The Stripe total donation amount is not correct.') - .toContain('121,96') - expect(actualStripeEmail, 'The user e-mail is not sent correctly to Stripe.').toEqual( - testEmail, - ) + test('Fill in the stripe card form', async () => { + await donationPage.fillCardForm({ + fail: false, + }) }) - test('The user is able to pay via Stripe', async () => { - await stripeCheckoutPage.fillPaymentForm([ - anonDonationTestData.cardNumber, - anonDonationTestData.cardExpDate, - anonDonationTestData.cardCvc, - anonDonationTestData.billingName, - anonDonationTestData.country, - ]) + test('The user is able to fill in e-mail for anonymous donation', async () => { + await donationPage.selectAuthentication(DonationFormAuthState.NOREGISTER) + }) + + test('The user can submit the form', async () => { + await donationPage.checkPrivacyCheckbox() + await donationPage.submitForm() + }) - expect - .soft( - await donationPage.isSuccessfulDonationTitleVisible(), - "'We thank you for your help and trust!' title is not visible.", - ) - .toBeTruthy() - expect(await donationPage.isPaymentStepActive(), 'Payment step is not active.').toBeTruthy() + test('The user is redirected to succes page', async () => { + await statusPage.checkPageUrlByRegExp() + expect(await statusPage.isSucceededStatusTitleDisplayed()).toBe(true) }) }, ) diff --git a/e2e/tests/regression/donation-flow/anon-donation-fixed.spec.ts b/e2e/tests/regression/donation-flow/anon-donation-fixed.spec.ts index 005d9557f..1d0c00ebc 100644 --- a/e2e/tests/regression/donation-flow/anon-donation-fixed.spec.ts +++ b/e2e/tests/regression/donation-flow/anon-donation-fixed.spec.ts @@ -2,15 +2,20 @@ import { test, expect, Page } from '@playwright/test' import { HeaderPage } from '../../../pages/web-pages/header.page' import { HomePage } from '../../../pages/web-pages/home.page' import { CampaignsPage } from '../../../pages/web-pages/campaigns/campaigns.page' -import { enLocalizationOneTimeDonation } from '../../../data/localization' -import { DonationPage } from '../../../pages/web-pages/campaigns/donation.page' -import { enDonationRegions } from '../../../data/enums/donation-regions.enum' -import { StripeCheckoutPage } from '../../../pages/web-pages/external/stripe-checkout.page' -import { anonDonationTestData } from '../../../data/support-page-tests.data' +import { DonationPage } from '../../../pages/web-pages/donation/donation.page' +import { DonationRegions } from '../../../data/enums/donation-regions.enum' +import { enLocalizationDonationFlow } from '../../../data/localization' +import { + DonationFormAuthState, + DonationFormPaymentMethod, +} from '../../../../src/components/client/donation-flow/helpers/types' +import { DonationStatusPage } from '../../../pages/web-pages/donation/donation-status.page' import { LanguagesEnum } from '../../../data/enums/languages.enum' -// This spec contains E2E tests related to anonymous donation flow - fixed amount +// This spec contains E2E tests related to anonymous donation flow - custom amount // The tests are dependent, the whole describe should be runned + +test.use({ locale: 'en-US' }) test.describe.serial( 'Anonymous contributor is able to donate fixed amount - EN language version', async () => { @@ -19,10 +24,11 @@ test.describe.serial( let headerPage: HeaderPage let campaignsPage: CampaignsPage let donationPage: DonationPage - let stripeCheckoutPage: StripeCheckoutPage - const testEmail = 'E2E_Test_Anon_Donation@e2etest.com' + let statusPage: DonationStatusPage // Localization texts - const enCardIncludeFeesText = enLocalizationOneTimeDonation['third-step']['card-include-fees'] + const paymentMode = enLocalizationDonationFlow.step['payment-mode'].fields['one-time'] + const bgCardIncludeFeesText = + enLocalizationDonationFlow.step['payment-method'].field['include-fees'].label test.beforeAll(async ({ browser }) => { page = await browser.newPage() @@ -30,7 +36,7 @@ test.describe.serial( headerPage = new HeaderPage(page) campaignsPage = new CampaignsPage(page) donationPage = new DonationPage(page) - stripeCheckoutPage = new StripeCheckoutPage(page) + statusPage = new DonationStatusPage(page) // For local executions use method navigateToLocalhostHomepage(); // await homepage.navigateToLocalhostHomepage(); await homepage.navigateToEnvHomepage() @@ -42,12 +48,10 @@ test.describe.serial( }) test('Particular campaign can be opened through the Campaign page', async () => { - await headerPage.clickDonateHeaderNavButton() + await headerPage.clickDonateHeaderNavButton(LanguagesEnum.EN) await campaignsPage.clickCampaignCardByIndex(0) // We move from the common Campaigns page to the particular campain page // check if the url is changed only based on the url pattern http://localhost:3040/campaigns/{slug-based-regexp} - // expect to not break - expect( await campaignsPage.checkPageUrlByRegExp(), 'The url is not changed after clicking on the campaign card.', @@ -57,86 +61,36 @@ test.describe.serial( test('The total charge, fee tax and donation amount are visible on the Campaign page', async () => { await campaignsPage.clickDonationSupportButton() await donationPage.checkPageUrlByRegExp() - expect - .soft( - await donationPage.isSelectAmountStepActive(LanguagesEnum.EN), - 'Select Amount step is not active.', - ) - .toBeTruthy() await donationPage.selectRadioButtonByLabelText(['10']) - await donationPage.setDonationRegionFromTheDropdown(enDonationRegions.EUROPE) - await donationPage.selectCheckboxByLabelText([enCardIncludeFeesText]) - // Expected pattern: - // For your transfer of {totalChargedAmountText}, the fee from Stripe will be {feeAmountText}, and the campaign will receive {donationAmountText}. - const totalChargedAmountText = await donationPage.getTotalChargedAmountsAsText() - const feeAmountText = await donationPage.getFeeAmountsAsText() - const donationAmountText = await donationPage.getDonationAmountsAsText() - expect.soft(donationAmountText).toMatch('10.00') - expect.soft(feeAmountText).toMatch('0.63') - expect(totalChargedAmountText).toMatch('10.63') + await donationPage.selectRadioButtonByLabelText([paymentMode]) + await donationPage.selectPaymentMethod(DonationFormPaymentMethod.CARD, LanguagesEnum.EN) + await donationPage.setDonationRegionFromTheDropdown(DonationRegions.EUROPE) + await donationPage.selectCheckboxByLabelText([bgCardIncludeFeesText]) }) test('The total charge, fee tax and donation amount are recalculated correctly when the donation amount is changed', async () => { await donationPage.selectRadioButtonByLabelText(['20']) - // Expected pattern: - // For your transfer of {totalChargedAmountText}, the fee from Stripe will be {feeAmountText}, and the campaign will receive {donationAmountText}. - const totalChargedAmountText = await donationPage.getTotalChargedAmountsAsText() - const feeAmountText = await donationPage.getFeeAmountsAsText() - const donationAmountText = await donationPage.getDonationAmountsAsText() - expect.soft(donationAmountText).toMatch('20.00') - expect.soft(feeAmountText).toMatch('0.75') - expect(totalChargedAmountText).toMatch('20.75') + await donationPage.checkTotalAmount(20.75, LanguagesEnum.EN) + }) + + test('Fill in the stripe card form', async () => { + await donationPage.fillCardForm({ + fail: false, + }) }) test('The user is able to fill in e-mail for anonymous donation', async () => { - await donationPage.clickForwardButton(LanguagesEnum.EN) - expect - .soft( - await donationPage.isPersonalProfileStepActive(LanguagesEnum.EN), - 'Personal Profile step is not active.', - ) - .toBeTruthy() - await donationPage.clickDonateAnonymouslyButton(LanguagesEnum.EN) - await donationPage.fillDonateAnonymouslyEmailField(testEmail) - await donationPage.clickForwardButton(LanguagesEnum.EN) - expect( - await donationPage.isSendAWishStepActive(LanguagesEnum.EN), - 'Send a wish step is not active.', - ).toBeTruthy() + await donationPage.selectAuthentication(DonationFormAuthState.NOREGISTER, LanguagesEnum.EN) }) - test('After sending a wish, the user is redirected to Stripe', async () => { - await donationPage.fillSendAWishField('E2E test - anonymous donation.') - await donationPage.clickFinishButton(LanguagesEnum.EN) - const stripeTotalAmount = await stripeCheckoutPage.getTotalAmountText() - const actualStripeEmail = await stripeCheckoutPage.getReadonlyEmailText() - expect - .soft(stripeTotalAmount, 'The Stripe total donation amount is not correct.') - .toContain('20.75') - expect(actualStripeEmail, 'The user e-mail is not sent correctly to Stripe.').toEqual( - testEmail, - ) + test('The user can submit the form', async () => { + await donationPage.checkPrivacyCheckbox(LanguagesEnum.EN) + await donationPage.submitForm(LanguagesEnum.EN) }) - test('The user is able to pay via Stripe', async () => { - await stripeCheckoutPage.fillPaymentForm([ - anonDonationTestData.cardNumber, - anonDonationTestData.cardExpDate, - anonDonationTestData.cardCvc, - anonDonationTestData.billingName, - anonDonationTestData.country, - ]) - // Now we're redirected to the Donation page - expect - .soft( - await donationPage.isSuccessfulDonationTitleVisible(LanguagesEnum.EN), - "'We thank you for your help and trust!' title is not visible.", - ) - .toBeTruthy() - expect( - await donationPage.isPaymentStepActive(LanguagesEnum.EN), - 'Payment step is not active.', - ).toBeTruthy() + test('The user is redirected to succes page', async () => { + await statusPage.checkPageUrlByRegExp() + expect(await statusPage.isSucceededStatusTitleDisplayed(LanguagesEnum.EN)).toBe(true) }) }, ) diff --git a/e2e/tests/regression/donation-flow/donation-fail.spec.ts b/e2e/tests/regression/donation-flow/donation-fail.spec.ts new file mode 100644 index 000000000..c87cd2701 --- /dev/null +++ b/e2e/tests/regression/donation-flow/donation-fail.spec.ts @@ -0,0 +1,88 @@ +import { test, expect, Page } from '@playwright/test' +import { HeaderPage } from '../../../pages/web-pages/header.page' +import { HomePage } from '../../../pages/web-pages/home.page' +import { CampaignsPage } from '../../../pages/web-pages/campaigns/campaigns.page' +import { DonationPage } from '../../../pages/web-pages/donation/donation.page' +import { DonationRegions } from '../../../data/enums/donation-regions.enum' +import { bgLocalizationDonationFlow } from '../../../data/localization' +import { + DonationFormAuthState, + DonationFormPaymentMethod, +} from '../../../../src/components/client/donation-flow/helpers/types' + +// This spec contains E2E tests related to anonymous donation flow - custom amount +// The tests are dependent, the whole describe should be runned +test.describe.serial('Donations should fail for cards deemed invalid by Stripe', async () => { + let page: Page + let homepage: HomePage + let headerPage: HeaderPage + let campaignsPage: CampaignsPage + let donationPage: DonationPage + // Localization texts + const bgCardIncludeFeesText = + bgLocalizationDonationFlow.step['payment-method'].field['include-fees'].label + + const paymentMode = bgLocalizationDonationFlow.step['payment-mode'].fields['one-time'] + + test.beforeAll(async ({ browser }) => { + page = await browser.newPage() + homepage = new HomePage(page) + headerPage = new HeaderPage(page) + campaignsPage = new CampaignsPage(page) + donationPage = new DonationPage(page) + // For local executions use method navigateToLocalhostHomepage(); + // await homepage.navigateToLocalhostHomepage(); + await homepage.navigateToEnvHomepage() + }) + + test.afterAll(async () => { + await page.close() + }) + + test('Particular campaign can be opened through the Campaign page', async () => { + await headerPage.clickDonateHeaderNavButton() + await campaignsPage.clickCampaignCardByIndex(0) + // We move from the common Campaigns page to the particular campain page + // check if the url is changed only based on the url pattern http://localhost:3040/campaigns/{slug-based-regexp} + expect( + await campaignsPage.checkPageUrlByRegExp(), + 'The url is not changed after clicking on the campaign card.', + ) + }) + + test('The total charge, fee tax and donation amount are visible on the Campaign page', async () => { + await campaignsPage.clickDonationSupportButton() + await donationPage.checkPageUrlByRegExp() + await donationPage.selectRadioButtonByLabelText(['10']) + await donationPage.selectPaymentMethod(DonationFormPaymentMethod.CARD) + await donationPage.selectRadioButtonByLabelText([paymentMode]) + await donationPage.setDonationRegionFromTheDropdown(DonationRegions.EUROPE) + await donationPage.selectCheckboxByLabelText([bgCardIncludeFeesText]) + }) + + test('The total charge, fee tax and donation amount are recalculated correctly when the donation amount is changed', async () => { + await donationPage.selectRadioButtonByLabelText(['20']) + await donationPage.checkTotalAmount(20.75) + }) + + test('Fill in the stripe card form', async () => { + await donationPage.fillCardForm({ + fail: true, + }) + }) + + test('The user is able to fill in e-mail for anonymous donation', async () => { + await donationPage.selectAuthentication(DonationFormAuthState.NOREGISTER) + }) + + test('The user can submit the form', async () => { + await donationPage.checkPrivacyCheckbox() + await donationPage.submitForm() + }) + + test('Submit error is visible', async () => { + await donationPage.submitForm() + const message = await donationPage.hasPaymentErrorMessage() + expect(message).toBe(true) + }) +}) diff --git a/e2e/tests/smoke/smoke-campaigns.spec.ts b/e2e/tests/smoke/smoke-campaigns.spec.ts index 402ae6f35..017e901e2 100644 --- a/e2e/tests/smoke/smoke-campaigns.spec.ts +++ b/e2e/tests/smoke/smoke-campaigns.spec.ts @@ -1,7 +1,7 @@ import { test, expect, Page } from '@playwright/test' import { CampaignsPage } from '../../pages/web-pages/campaigns/campaigns.page' -import { DonationPage } from '../../pages/web-pages/campaigns/donation.page' +import { DonationPage } from '../../pages/web-pages/donation/donation.page' import { HeaderPage } from '../../pages/web-pages/header.page' import { HomePage } from '../../pages/web-pages/home.page' @@ -52,8 +52,5 @@ test.describe('Campaigns page smoke tests - BG language version', async () => { test('Support Now action button navigates to the Donation page for particular campaign', async () => { await campaignsPage.clickCampaignCardButtonByIndex(0) await donationPage.checkPageUrlByRegExp() - expect - .soft(await donationPage.isSelectAmountStepActive(), 'Select Amount step is not active.') - .toBeTruthy() }) }) diff --git a/e2e/utils/helpers.ts b/e2e/utils/helpers.ts index 285e098a0..24ceda9d9 100644 --- a/e2e/utils/helpers.ts +++ b/e2e/utils/helpers.ts @@ -27,4 +27,4 @@ export const expectCopied = async (page: Page, textToCheck: string) => { * - (?:-[a-z0-9]+)* matches the characters - and a-z0-9 between one and unlimited times, as many times as possible, giving back as needed (greedy) and does not remember the match * - $ asserts position at the end of the string */ -export const SLUG_REGEX = `[a-z0-9]+(?:-[a-z0-9]+)*$` +export const SLUG_REGEX = `[a-z0-9]+(?:-[a-z0-9]+)*` diff --git a/next.config.js b/next.config.js index a968f4c27..6105690a7 100644 --- a/next.config.js +++ b/next.config.js @@ -20,6 +20,10 @@ const moduleExports = { tsconfigPath: 'tsconfig.build.json', }, swcMinify: true, + webpack: (config) => { + config.experiments = { ...config.experiments, topLevelAwait: true } + return config + }, env: { APP_ENV: process.env.APP_ENV, APP_VERSION: version, @@ -32,6 +36,7 @@ const moduleExports = { APP_URL: process.env.APP_URL, GTM_ID: process.env.GTM_ID ?? 'GTM-TWQBXM6', PAYPAL_CLIENT_ID: process.env.PAYPAL_CLIENT_ID, + STRIPE_PUBLISHABLE_KEY: process.env.STRIPE_PUBLISHABLE_KEY, FEATURE_ENABLED: { CAMPAIGN: process.env.FEATURE_CAMPAIGN ?? false, }, @@ -87,11 +92,20 @@ const moduleExports = { ] }, modularizeImports: { + lodash: { + transform: 'lodash/{{member}}', + }, '@mui/material': { transform: '@mui/material/{{member}}', }, - '@mui/icons-material/?(((\\w*)?/?)*)': { - transform: '@mui/icons-material/{{ matches.[1] }}/{{member}}', + '@mui/icons-material': { + transform: '@mui/icons-material/{{member}}', + }, + '@mui/core/': { + transform: '@mui/core/{{member}}', + }, + '@mui/lab/': { + transform: '@mui/lab/{{member}}', }, }, } diff --git a/package.json b/package.json index 814c4fd66..7bbf0915f 100644 --- a/package.json +++ b/package.json @@ -41,8 +41,8 @@ "@ramonak/react-progress-bar": "^5.0.3", "@react-pdf/renderer": "^3.1.3", "@sentry/nextjs": "^7.80.0", - "@stripe/react-stripe-js": "^1.16.1", - "@stripe/stripe-js": "^1.46.0", + "@stripe/react-stripe-js": "^2.7.0", + "@stripe/stripe-js": "^3.3.0", "@tanstack/react-query": "^4.16.1", "@tryghost/content-api": "^1.11.4", "axios": "^1.6.8", @@ -53,6 +53,7 @@ "date-fns": "2.24.0", "dompurify": "^3.0.3", "formik": "2.2.9", + "formik-persist-values": "^1.4.1", "i18next": "^23.5.1", "jwt-decode": "^3.1.2", "lodash": "^4.17.21", diff --git a/public/gif-full-hq.gif b/public/gif-full-hq.gif new file mode 100644 index 000000000..413b5dc2e Binary files /dev/null and b/public/gif-full-hq.gif differ diff --git a/public/gif-full.gif b/public/gif-full.gif new file mode 100644 index 000000000..1be3b218d Binary files /dev/null and b/public/gif-full.gif differ diff --git a/public/locales/bg/common.json b/public/locales/bg/common.json index e91c63625..5d61c6cff 100644 --- a/public/locales/bg/common.json +++ b/public/locales/bg/common.json @@ -83,6 +83,10 @@ "youtube": "YouTube", "instagram": "Instagram" } + }, + "social-share": { + "share": "Сподели в", + "copy": "Копирай линка" } }, "or": "или", @@ -99,6 +103,7 @@ "email": "Имейл" }, "cta": { + "share": "Сподели", "more-information": "Повече информация" }, "notifications": { diff --git a/public/locales/bg/donation-flow.json b/public/locales/bg/donation-flow.json new file mode 100644 index 000000000..8514038a1 --- /dev/null +++ b/public/locales/bg/donation-flow.json @@ -0,0 +1,176 @@ +{ + "general": { + "BGN": "лева", + "error": { + "email": "Трябва да въведете валиден имейл адрес", + "select-field": "Моля, направете своя избор" + } + }, + "cancel-dialog": { + "title": "Сигурни ли сте, че искате да се откажете", + "content": "Ако се откажете, ще загубите информацията попълнена до сега", + "btn-cancel": "Откажи Дарението", + "btn-continue": "Продължи Дарението" + }, + "action": { + "back": "Назад", + "submit": "Дари" + }, + "step": { + "amount": { + "title": "Каква сума желаете да дарите", + "field": { + "final-amount": { + "error": "Моля, изберете сума за дарение." + }, + "other-amount": { + "label": "Друга сума", + "error": "Трябва да изберете валидна сума", + "currency": "лв", + "only-number": "Полето може да съдържа само цифри.", + "transaction-limit": "Дарението не може да надхвърля сумата от {{limit}}" + } + } + }, + "payment-mode": { + "title": "Колко често желаете да дарявате", + "error": "Моля, изберете колко често желаете да дарявате", + "fields": { + "one-time": "Еднократно", + "monthly": "Ежемесечно" + } + }, + "payment-method": { + "title": "Как желаете да дарите", + "error": "Моля, изберете начин на плащане", + "field": { + "method": { + "card": "Карта", + "bank": "Банков превод", + "paypal": "PayPal", + "error": "Трябва да изберете начин на плащане" + }, + "card-region": { + "title": "Регион", + "EU": "Европа", + "UK": "Великобритания", + "Other": "Друг" + }, + "card-data": { + "name-label": "Име на картодържателя", + "errors": { + "email": "Моля, въведете вашия емайл", + "name": "Моля, въведете вашето име" + } + }, + "include-fees": { + "label": "Искам да покрия таксите за карта издадена в", + "error": "Трябва да изберете регион" + } + }, + "bank": { + "bank-payment": "Банков превод", + "bank-instructions1": "За дарение по банков път, моля използвайте приложението препоръчано от Вашата банка като въведете данните посочени по-долу. Дарението Ви няма да се отрази веднага в системата, тъй като все още предстои разработка на интеграцията с банката", + "bank-instructions2": "Разчитаме на Вас да си направите профил или да ни оставите email на следващaтa стъпкa, за да можем да свържем дарението с Вашия потребителски профил. Благодарим предварително!", + "bank-details": "Детайли на банкова сметка", + "btn-copy": "Копирай", + "owner": "Сдружение Подкрепи БГ", + "bank": "Уникредит Булбанк", + "reason-donation": "Като основание за превод въведете", + "message-warning": "Ако не въведете точно основанието, може да не успеем да разпределим парите към предназначената кампания", + "recurring-donation": "Дарявай повторно всеки месец тази сума до края на кампанията! Може да се откажете по всяко време", + "alert": { + "important": "ВАЖНО", + "authenticate": "Моля попълнете следващата стъпка свързана с аутентикацията, за да се свържем с Вас, ако има проблем" + } + }, + "alert": { + "card-fee": "Таксата на Stripe се изчислява според района на картодържателя: 1.2% + 0.5лв. за Европейската икономическа зона", + "bank-fee": "Таксата за транзакция при банков превод зависи от индивидуалните условия на Вашата банка (от 0 до 4 лв.)", + "calculated-fees": "За вашия превод от {{totalAmount}}, таксата на Stripe ще е {{fees}}, а кампанията ще получи {{amount}}" + } + }, + "authentication": { + "title": "Как предпочитате да продължите", + "error": "Моля, изберете, метод за автентикация", + "logged-as": "Вие сте влязъл като", + "login": { + "label": "Влизане" + }, + "register": { + "label": "Регистрация" + }, + "noregister": { + "label": "Продължете без регистрация ", + "description": "Продължавайки без регистрация, нямате възможност да запазите дарението в историята на профила си както и да правите месечни дарения по избрана кампания" + }, + "field": { + "password": "Парола", + "email": { + "error": "Трябва да въведете валиден имейл" + } + }, + "alert": { + "authenticate": { + "title": "Избирайки да се впишете, ще можете да", + "create-account": "създадете акаунт като физическо или юридическо лице", + "certificate": "получите сертификат за дарение", + "monthly-donation": "правите месечни дарения по избрана кампания", + "notification": "можете да получавате и известия за статуса на подкрепени вече кампании" + } + } + }, + "summary": { + "donation": "Дарение", + "transaction": { + "title": "Трансакция", + "description": "Начислената такса трансакция е единствено за покриване на паричния превод и се определя от метода на плащане. “Подкрепи.бг” работи с 0% комисионна." + }, + "field": { + "anonymous": { + "label": "Искам да съм анонимен", + "description": "Ако останете анонимен името ви няма да бъде показано на кампанията" + }, + "privacy": { + "error": "Трябва да приемете политиката за поверителност" + } + }, + "alerts": { + "error": "Нещо се обърка, моля опитайте пак или презаредете страницата" + }, + "total": "Общо" + } + }, + "status": { + "success": { + "title": "Благодарим ви за доверието и подкрепата", + "title-logged": "благодарим ви за доверието и подкрепата", + "email": "Изпратихме ви имейл с повече информация", + "wish": { + "title": "Помогни с пожелание", + "thanks": "Благодарим за пожеланието ви", + "error": "Не можахме да запазим пожелнието. Моля опитайте пак", + "write": "Напиши пожелание", + "send": "Изпрати" + }, + "share": { + "title": "Помогни на кампанията като споделиш с приятели", + "description": "Кампании, които се споделят по-често има по-голям шанс да бъдат завършени" + }, + "link": { + "see": "Виж други кампании", + "donations": "Виж твоите дарения", + "volunteer": "Стани доброволец", + "return": "Върни се към кампанията" + } + }, + "fail": { + "title": "Нещо се обърка", + "description": "Съжаляваме, но нещо се обърка, моля опитайте пак или се върнете по-късно", + "link": { + "return": "Върни се към кампанията", + "retry": "Опитай пак" + } + } + } +} diff --git a/public/locales/bg/one-time-donation.json b/public/locales/bg/one-time-donation.json deleted file mode 100644 index 1ef17057e..000000000 --- a/public/locales/bg/one-time-donation.json +++ /dev/null @@ -1,96 +0,0 @@ -{ - "step-labels": { - "amount": "Изберете сума", - "personal-profile": "Личен профил", - "wish": "Пожелайте нещо", - "payment": "Плащане" - }, - "anonymous-menu": { - "checkbox-label": "Дарение без регистрация", - "info-start": "При дарение без регистрация, няма да можем да Ви изпратим сертификат за дарение, който да използвате за данъчни облекчения. Ако искате да получите сертификат, регистрирайте се или влезте в профила си.", - "firstName": "Име", - "lastName": "Фамилия", - "phone": "Телефон" - }, - "first-step": { - "wish": "Искате ли да пожелаете нещо на бенефициента?", - "message": "Вашето послание", - "check-box-label": "Анонимно дарение", - "amount": "Каква сума желаете да дарите?", - "other": "Друга сума", - "BGN": "лв.", - "only-number": "Полето може да съдържа само цифри.", - "transaction-limit": "Дарението не може да надхвърля сумата от {{limit}}" - }, - "second-step": { - "login": "Влизане в профил", - "password": "Парола", - "checkbox-label": "Запомни", - "btn-login": "Влизане", - "new-create": "Или", - "new-create-profile": "Създайте нов профил", - "donate-anonymously": "Дарете анонимно", - "intro-text": "Можете да дарите с личен профил или анонимно.", - "logged-user": "Вече сте влезли във Вашия профил", - "info-logged-user": "Вашето дарение ще бъде свързано с име: {{fullName}} и email: {{email}}, освен ако не решите да дарите анонимно." - }, - "success": { - "title": "Благодарим за доверието и подкрепата!", - "title-bank": "Ще очакваме Вашето дарение!", - "subtitle": "Вашето дарение ще помогне на кампанията по-бързо да постигне своята цел!", - "subtitle-bank": "Благодарим за доверието и запомнете да впишете кода на дарението в основанието на превода си!", - "say-to-us": "Вашата обратна връзка е важна за нас!", - "share-to": "Подкрепете кампанията, като споделите информация за нея в социалните мрежи.", - "btn-generate": "Генерирай Сертификат", - "btn-say-to-us": "Oбратна връзка", - "btn-other-campaign": "Oще кампании", - "btn-back-to-campaign": "Виж кампанията" - }, - "third-step": { - "title": "Как желаете да дарите?", - "card": "Карта", - "card-include-fees": "Искам да покрия банкова такса за карта издадена в регион:", - "card-fees": "Даренията, платени с карта се обработват през системата на Stripe и за всеки трансфер Stripe удържат такса. Моля, изберете сумата, която желаете да преведете и по-надолу ще видите изчислена конкретната такса.", - "card-calculated-fees": "За вашия превод от {{totalAmount}}, Stripe ще удържи {{fees}} и по кампанията като дарение ще се отразят {{amount}} Повече информация за таксите на Stripe може да намерите на: https://stripe.com/en-bg/pricing", - "card-region": { - "title": "регион", - "EU": "Европа", - "UK": "Великобритания", - "Other": "други" - }, - "bank-payment": "Банков превод", - "bank-instructions1": "За дарение по банков път, моля използвайте приложението препоръчано от Вашата банка като въведете данните посочени по-долу. Дарението Ви няма да се отрази веднага в системата, тъй като все още предстои разработка на интеграцията с банката.", - "bank-instructions2": "Разчитаме на Вас да си направите профил или да ни оставите email на следващaтa стъпкa, за да можем да свържем дарението с Вашия потребителски профил. Благодарим предварително!!!", - "bank-details": "Детайли на банкова сметка:", - "btn-copy": "Копирай", - "owner_name": "Получател:", - "owner_value": "Сдружение Подкрепи БГ", - "bank_name": "Банка:", - "bank_value": "Уникредит Булбанк", - "reason-donation": "Oснование за превод:", - "message-warning": "Ако не въведете точно основанието, може да не успеем да разпределим парите към предназначената кампания.", - "recurring-donation-title": "Месечно дарение", - "recurring-donation-info": "Желая да дарявам същата сума всеки месец до края на кампанията. Може да се откажете по всяко време от профила си." - }, - "alerts": { - "success": "Дарението е направено успешно!", - "error": "Възникна грешка в процеса на обработка!" - }, - "fail": { - "title": "За съжаление, възникна проблем!", - "subtitle": "Трансакцията не можа да бъде осъществена. Причините могат да бъдат няколко, включително проблем с Вашата интернет връзка.", - "btn-again": "Опитайте пак", - "btn-connect": "Пишете ни", - "btn-back-to-campaign": "Виж кампанията" - }, - "errors-fields": { - "bank-payment": "Съжаляваме за създаденото неудобство временно може да дарите само чрез банков превод!", - "amount": "Моля изберете сумата, която желаете да дарите!", - "other-amount": "Минималната сума която може да дарите с карта е 1лв." - }, - "btns": { - "back": "Назад", - "next": "Напред", - "end": "Премини към плащане" - } -} diff --git a/public/locales/en/common.json b/public/locales/en/common.json index 0a80d1b29..0720d65d0 100644 --- a/public/locales/en/common.json +++ b/public/locales/en/common.json @@ -83,6 +83,10 @@ "youtube": "YouTube", "instagram": "Instagram" } + }, + "social-share": { + "share": "Share in", + "copy": "Copy link" } }, "or": "or", @@ -99,6 +103,7 @@ "email": "Email" }, "cta": { + "share": "Share", "more-information": "More information" }, "notifications": { diff --git a/public/locales/en/donation-flow.json b/public/locales/en/donation-flow.json new file mode 100644 index 000000000..b22973a45 --- /dev/null +++ b/public/locales/en/donation-flow.json @@ -0,0 +1,177 @@ +{ + "general": { + "BGN": "BGN", + "error": { + "email": "You have to enter a valid email", + "select-field": "Please make your decision" + } + }, + "cancel-dialog": { + "title": "Are you sure you want to cancel?", + "content": "If you cancel, you will lose all the information you have entered so far.", + "btn-cancel": "Cancel", + "btn-continue": "Continue" + }, + "action": { + "back": "Back", + "submit": "Donate" + }, + "step": { + "amount": { + "title": "How much would you like to donate", + "field": { + "final-amount": { + "error": "You have to select an amount or enter a custom amount" + }, + "other-amount": { + "label": "Other amount", + "error": "You have to enter a valid amount", + "currency": "BGN", + "only-number": "This field can contain only numbers.", + "transaction-limit": "Donation can't exceed the sum of {{limit}}" + } + } + }, + "payment-mode": { + "title": "How often would you want to donate", + "error": "Please select, how often would you want to donate", + "fields": { + "one-time": "One time", + "monthly": "Monthly" + } + }, + "payment-method": { + "title": "How would you like to pay", + "error": "Please select payment method", + "field": { + "method": { + "card": "Card", + "bank": "Bank transfer", + "paypal": "PayPal", + "error": "You have to select a payment method" + }, + "tax-box": { + "label": "I want to cover transaction fees for card issued in", + "error": "You have to select a region" + }, + "card-data": { + "name-label": "Cardholder name", + "error": { + "email": "Please enter your email", + "name": "Please enter your name" + } + }, + "card-region": { + "title": "Region", + "EU": "Europe", + "UK": "Great Britain", + "Other": "Other" + }, + "include-fees": { + "label": "I want to cover transaction fees for card issued in", + "error": "You have to select a region" + } + }, + "bank": { + "bank-payment": "Bank transfer", + "bank-instructions1": "To donate via bank transfer, please use your bank recommonded application with entering our bank details from below. The donation will not be updated immediatelly as the implemnetation of the automated bank integration is still pending.", + "bank-instructions2": "We trust you to register or to leave an email on the next step, so that we can link your donation to your account. Thank you!", + "bank-details": "Details of our bank account:", + "btn-copy": "Copy", + "owner": "Association Podkrepi BG", + "bank": "Unicredit Bulbank", + "reason-donation": "For payment reference use:", + "message-warning": "If you don't enter the exact reference we may not be able to assign the money to the desired campaign.", + "recurring-donation": "Donate the same amount every month until the end of the campaign! Cancel anytime.", + "alert": { + "important": "IMPORTANT", + "authenticate": "Please fill in the next step regarding authentication so we can contact you if there is something wrong" + } + }, + "alert": { + "card-fee": "Stripe tax is calculated based on the region of the card's issuer: 1.2% + 0.5 BGN for the EU Economic Zone", + "bank-fee": "Bank fee depends on the individual terms of your bank. Varies from (0-4 BGN)", + "calculated-fees": "For your donation of {{amount}}, the fee from Stripe will be {{fees}}, and the total charged amount will be {{totalAmount}}" + } + }, + "authentication": { + "title": "How would you like to continue", + "error": "Please select authentication method", + "logged-as": "You are logged in as", + "login": { + "label": "Log in" + }, + "register": { + "label": "Register" + }, + "noregister": { + "label": "Continue without registration", + "description": "You will not be able to get a donation certificate or a list of your donations. If you still want to receive a receipt, please share your email - it will not be visible in the platform" + }, + "field": { + "password": "Password", + "email": { + "error": "You have to enter a valid email" + } + }, + "alert": { + "authenticate": { + "title": "Choosing to login you will be able to", + "create-account": "Create an account", + "certificate": "Get a donation certificate", + "monthly-donation": "Make a monthly donation", + "notification": "Get notifications about campaigns you donated to" + } + } + }, + "summary": { + "donation": "Donation", + "transaction": { + "title": "Transaction", + "description": "The transaction is only to compensate the transfer and is calculated based on your method of payment. \"Podkrepi.bg\" works with 0% commission" + }, + "total": "Total", + "field": { + "anonymous": { + "label": "I want to be anonymous", + "description": "If you choose to be anonymous, your name will not be visible in the campaign" + }, + "privacy": { + "error": "You have to accept the privacy policy" + } + } + } + }, + "status": { + "success": { + "title": "Thank you for your trust and support", + "title-logged": "we thank you for your trust and support", + "email": "We have sent you an email with the details of your donation", + "wish": { + "title": "Help the beneficiary with a wish", + "thanks": "Thank you for your wish", + "error": "We could not save your wish. Please try again later", + "write": "Write a wish", + "send": "Send" + }, + "share": { + "title": "Help the campaign by sharing with your friends", + "description": "Campaigns that are shared more often are more likely to be successful" + }, + "link": { + "see": "See other campaigns", + "donations": "See your donations", + "volunteer": "Become a volunteer", + "return": "Return to the campaign" + } + }, + "fail": { + "title": "Something went wrong", + "description": "We are sorry, but something went wrong. Please try again later", + "link": { + "return": "Return to the campaign", + "retry": "Try again" + } + } + } +} diff --git a/public/locales/en/one-time-donation.json b/public/locales/en/one-time-donation.json deleted file mode 100644 index 2ea3f5e63..000000000 --- a/public/locales/en/one-time-donation.json +++ /dev/null @@ -1,96 +0,0 @@ -{ - "step-labels": { - "amount": "Select amount", - "personal-profile": "Personal profile", - "wish": "Send a wish", - "payment": "Payment" - }, - "anonymous-menu": { - "checkbox-label": "Donate anonymously", - "info-start": "When donating without registration we won't be able to send you back a donation certificate or a list of your donations. If you still want to receive a certificate, please share at least your email - it will not be visible in the platform.", - "firstName": "First name", - "lastName": "Last name", - "phone": "Telephone" - }, - "first-step": { - "wish": "Would you like to wish something to the beneficiary?", - "message": "Your wish", - "check-box-label": "Anonymous donation", - "amount": "How much would you like to donate?", - "other": "Other amount", - "BGN": "BGN", - "only-number": "This field can contain only numbers.", - "transaction-limit": "Donation can't exceed the sum of {{limit}}" - }, - "second-step": { - "login": "Log in", - "password": "Password", - "checkbox-label": "Remember me", - "btn-login": "Log in", - "new-create": "Or", - "new-create-profile": "Create new profile", - "donate-anonymously": "Donate anonymously", - "intro-text": "You can donate with personal profile or anonymously.", - "logged-user": "You are already logged in to your account", - "info-logged-user": "Your donation would be connected with name: {{fullName}} and email: {{email}}, unless you decide to donate anonymously." - }, - "success": { - "title": "We thank you for your help and trust!", - "title-bank": "We look forward to your donation!", - "subtitle": "Your donation would help the campaign get to it's target sooner!", - "subtitle-bank": "Thank you for your trust and don't forget to enter the donation code as the reason for your transfer!", - "say-to-us": "Your feedback is important to us!", - "share-to": "Please support this campaign by sharing its link to social networks for reaching more people!", - "btn-generate": "Generate a certificate", - "btn-say-to-us": "Feedback", - "btn-other-campaign": "See more campaigns", - "btn-back-to-campaign": "See the campaign" - }, - "third-step": { - "title": "How would you like to pay", - "card": "Card", - "card-include-fees": "I want to cover transaction fees for card issued in:", - "card-fees": "For donations by card we use the services of Stripe and for every transfer they charge a fee depending on region of your card issuer. To orient you about the net donation, after you choose the desired amount, we will show you the calculated fee below.", - "card-calculated-fees": "For your transfer of {{totalAmount}}, the fee from Stripe will be {{fees}}, and the campaign will receive {{amount}}. Additional information regarding Stripe's fees can be found at: https://stripe.com/en-bg/pricing", - "card-region": { - "title": "region", - "EU": "Europe", - "UK": "Great Britain", - "Other": "other" - }, - "bank-payment": "Bank transfer", - "bank-instructions1": "To donate via bank transfer, please use your bank recommonded application with entering our bank details from below. The donation will not be updated immediatelly as the implemnetation of the automated bank integration is still pending.", - "bank-instructions2": "We trust you to register or to leave an email on the next step, so that we can link your donation to your account. Thank you!!!", - "bank-details": "Details of our bank account:", - "btn-copy": "Copy", - "owner_name": "Recipient:", - "owner_value": "Association Podkrepi BG", - "bank_name": "Bank:", - "bank_value": "Unicredit Bulbank", - "reason-donation": "Payment reference:", - "message-warning": "If you don't enter the exact reference we may not be able to assign the money to the desired campaign.", - "recurring-donation-title": "Monthly donation", - "recurring-donation-info": "Donate the same amount every month until the end of the campaign! Cancel anytime in your profile!" - }, - "alerts": { - "success": "Donation was processed successfully!", - "error": "Error ocurred during processing of the donation!" - }, - "fail": { - "title": "Unfortunately there was a problem!", - "subtitle": "The transaction could not be done. The reasons for that many, including a problem with your internet connection.", - "btn-again": "Try again", - "btn-connect": "Contact us", - "btn-back-to-campaign": "See the campaign" - }, - "errors-fields": { - "bank-payment": "We are sorry for the inconvenience, but you can currently donate only through a bank transfer", - "amount": "Please select the amount you would like to donate!", - "other-amount": "The minimum amount you can donate with a card is BGN 1." - }, - "btns": { - "back": "Back", - "next": "Next", - "end": "Finish" - } -} diff --git a/src/common/hooks/donation.ts b/src/common/hooks/donation.ts index 5c7d175dd..c1b2549fb 100644 --- a/src/common/hooks/donation.ts +++ b/src/common/hooks/donation.ts @@ -1,45 +1,24 @@ import { useSession } from 'next-auth/react' -import { useTranslation } from 'next-i18next' -import { AxiosError, AxiosResponse } from 'axios' -import { QueryClient, useMutation, useQuery } from '@tanstack/react-query' - -import { ApiErrors } from 'service/apiErrors' -import { AlertStore } from 'stores/AlertStore' +import { QueryClient, useQuery } from '@tanstack/react-query' import { endpoints } from 'service/apiEndpoints' import { authQueryFnFactory } from 'service/restRequests' import { - CheckoutSessionInput, - CheckoutSessionResponse, + DonationPrice, DonationResponse, - DonorsCountResult, + UserDonationResult, PaymentAdminResponse, TPaymentResponse, TotalDonatedMoneyResponse, - UserDonationResult, + DonorsCountResult, } from 'gql/donations' -import { createCheckoutSession } from 'service/donation' import { CampaignDonationHistoryResponse } from 'gql/campaigns' import { FilterData, PaginationData } from 'gql/types' -export function useDonationSession() { - const { t } = useTranslation() - const mutation = useMutation< - AxiosResponse, - AxiosError, - CheckoutSessionInput - >({ - mutationFn: createCheckoutSession, - onError: () => AlertStore.show(t('common:alerts.error'), 'error'), - onSuccess: () => AlertStore.show(t('common:alerts.message-sent'), 'success'), - retry(failureCount) { - if (failureCount < 4) { - return true - } - return false - }, - retryDelay: 1000, - }) - return mutation +export function usePriceList() { + return useQuery([endpoints.donation.prices.url]) +} +export function useSinglePriceList() { + return useQuery([endpoints.donation.singlePrices.url]) } export function useDonationsList( @@ -100,6 +79,12 @@ export function useGetPayment(id: string) { export async function prefetchDonationById(client: QueryClient, id: string) { await client.prefetchQuery([endpoints.donation.getDonation(id).url]) } + +export function useFindDonationById(id: string) { + return useQuery>([ + endpoints.donation.getDonationByPaymentIntent(id).url, + ]) +} export function useUserDonations() { const { data: session } = useSession() return useQuery([endpoints.donation.userDonations.url], { diff --git a/src/common/routes.ts b/src/common/routes.ts index 60a0d6560..4aba7c2f3 100644 --- a/src/common/routes.ts +++ b/src/common/routes.ts @@ -94,6 +94,8 @@ export const routes = { viewCampaignBySlug: (slug: string) => `/campaigns/${slug}`, viewExpenses: (slug: string) => `/campaigns/${slug}/expenses`, oneTimeDonation: (slug: string) => `/campaigns/donation/${slug}`, + donationStatus: (slug: string) => `/campaigns/donation/${slug}/status`, + finalizeDonation: `api/donation/finalize`, expenses: { create: (slug: string) => `/campaigns/${slug}/expenses/create`, edit: (slug: string, id: string) => `/campaigns/${slug}/expenses/${id}`, diff --git a/src/common/theme.ts b/src/common/theme.ts index 8dc4191cd..7179b473f 100644 --- a/src/common/theme.ts +++ b/src/common/theme.ts @@ -67,6 +67,9 @@ export const themeOptions: ThemeOptions = { light: colors.blue.mainDark, dark: darken(colors.blue.dark, 0.2), }, + error: { + main: '#D32F2F', + }, }, shape: { borderRadius: 3, diff --git a/src/components/client/donation-flow/DonationFlowForm.tsx b/src/components/client/donation-flow/DonationFlowForm.tsx new file mode 100644 index 000000000..5221f71b5 --- /dev/null +++ b/src/components/client/donation-flow/DonationFlowForm.tsx @@ -0,0 +1,355 @@ +import React, { useEffect, useRef } from 'react' +import { useTranslation } from 'next-i18next' +import { useRouter } from 'next/router' +import { useSession } from 'next-auth/react' +import { useElements, useStripe } from '@stripe/react-stripe-js' +import * as yup from 'yup' +import { Form, Formik, FormikProps } from 'formik' +import { PersistFormikValues } from 'formik-persist-values' +import { + Box, + Button, + IconButton, + Tooltip, + Typography, + Unstable_Grid2 as Grid2, +} from '@mui/material' +import { ArrowBack, Info } from '@mui/icons-material' + +import { routes } from 'common/routes' +import CheckboxField from 'components/common/form/CheckboxField' +import AcceptPrivacyPolicyField from 'components/common/form/AcceptPrivacyPolicyField' +import ConfirmationDialog from 'components/common/ConfirmationDialog' +import SubmitButton from 'components/common/form/SubmitButton' +import { useCancelSetupIntent, useUpdateSetupIntent } from 'service/donation' + +import StepSplitter from './common/StepSplitter' +import PaymentMethod from './steps/payment-method/PaymentMethod' +import Authentication from './steps/authentication/Authentication' +import Amount, { amountValidation, initialAmountFormValues } from './steps/Amount' +import { initialLoginFormValues, loginValidation } from './steps/authentication/InlineLoginForm' +import { + initialRegisterFormValues, + registerFormValidation, +} from './steps/authentication/InlineRegisterForm' +import { useDonationFlow } from './contexts/DonationFlowProvider' +import AlertsColumn from './alerts/AlertsColumn' +import PaymentSummaryAlert from './alerts/PaymentSummaryAlert' +import { + DonationFormAuthState, + DonationFormPaymentMethod, + DonationFormData, + PaymentMode, +} from './helpers/types' +import { DonationType } from 'gql/donations.enums' +import PaymentModeSelect from './steps/PaymentModeSelect' + +import { useCurrentPerson } from 'common/util/useCurrentPerson' +import { confirmStripePayment } from './helpers/confirmStripeDonation' + +import { StripeError } from '@stripe/stripe-js' +import { DonationFormErrorList } from './common/DonationFormErrors' + +const initialGeneralFormValues = { + mode: null, + payment: null, + authentication: null, + isAnonymous: false, + privacy: false, + billingName: '', + billingEmail: '', +} + +const initialValues: DonationFormData = { + ...initialGeneralFormValues, + ...initialAmountFormValues, + ...initialLoginFormValues, + ...initialRegisterFormValues, +} + +const generalValidation = { + mode: yup + .string() + .oneOf(['one-time', 'subscription'], 'donation-flow:step.payment-mode.error') + .nullable() + .required() as yup.SchemaOf, + payment: yup + .string() + .oneOf(Object.values(DonationFormPaymentMethod), 'donation-flow:step.payment-method.error') + .nullable() + .required() as yup.SchemaOf, + billingName: yup.string().when('payment', { + is: 'card', + then: yup.string().required('donation-flow:step.payment-method.field.card-data.errors.name'), + }), + billingEmail: yup.string().when('payment', { + is: 'card', + then: yup.string().required('donation-flow:step.payment-method.field.card-data.errors.email'), + }), + authentication: yup + .string() + .oneOf(Object.values(DonationFormAuthState), 'donation-flow:step.authentication.error') + .nullable() + .required() as yup.SchemaOf, + isAnonymous: yup.boolean().required(), + privacy: yup.bool().required().isTrue('donation-flow:step.summary.field.privacy.error'), +} + +export const validationSchema: yup.SchemaOf = yup + .object() + .defined() + .shape({ + ...generalValidation, + ...amountValidation, + ...loginValidation, + ...registerFormValidation, + }) + +export function DonationFlowForm() { + const formikRef = useRef | null>(null) + const { t } = useTranslation('donation-flow') + const { data: session } = useSession({ + required: false, + onUnauthenticated: () => { + formikRef.current?.setFieldValue('authentication', null) + }, + }) + useEffect(() => { + if (session?.user) { + formikRef.current?.setFieldValue('email', session.user.email, false) + formikRef.current?.setFieldValue('authentication', DonationFormAuthState.AUTHENTICATED, false) + formikRef.current?.setFieldValue('isAnonymous', false) + return + } + formikRef.current?.setFieldValue('email', '') + formikRef.current?.setFieldValue('isAnonymous', true, false) + }, [session]) + const { campaign, setupIntent, paymentError, setPaymentError, idempotencyKey } = useDonationFlow() + const stripe = useStripe() + const elements = useElements() + const router = useRouter() + const updateSetupIntentMutation = useUpdateSetupIntent() + const cancelSetupIntentMutation = useCancelSetupIntent() + const paymentMethodSectionRef = React.useRef(null) + const authenticationSectionRef = React.useRef(null) + const [showCancelDialog, setShowCancelDialog] = React.useState(false) + const [submitPaymentLoading, setSubmitPaymentLoading] = React.useState(false) + const { data: { user: person } = { user: null } } = useCurrentPerson() + + return ( + { + setSubmitPaymentLoading(true) + if (values.payment === DonationFormPaymentMethod.BANK) { + cancelSetupIntentMutation.mutate({ id: setupIntent.id }) + helpers.resetForm() + return router.push( + `${routes.campaigns.donationStatus(campaign.slug)}?${new URLSearchParams({ + bank_payment: 'true', + p_status: 'succeeded', + }).toString()}`, + ) + } + + if (!stripe || !elements || !setupIntent) { + // Stripe.js has not yet loaded. + // Form should be disabled but TS doesn't know that. + setSubmitPaymentLoading(false) + setPaymentError({ + type: 'invalid_request_error', + message: t('step.summary.alerts.error'), + }) + return + } + + if (!values.finalAmount) { + setSubmitPaymentLoading(false) + setPaymentError({ + type: 'invalid_request_error', + message: t('step.summary.alerts.error'), + }) + return + } + + // Update the setup intent with the latest calculated amount + try { + const updatedIntent = await updateSetupIntentMutation.mutateAsync({ + id: setupIntent.id, + idempotencyKey, + payload: { + metadata: { + type: person?.company ? DonationType.corporate : DonationType.donation, + campaignId: campaign.id, + amount: values.finalAmount, + currency: campaign.currency, + isAnonymous: values.isAnonymous.toString(), + return_url: `${window.location.origin}/${routes.campaigns.donationStatus( + campaign.slug, + )}`, + }, + }, + }) + // Confirm the payment + const payment = await confirmStripePayment( + updatedIntent.data, + elements, + stripe, + campaign, + values, + session, + idempotencyKey, + ) + router.push( + `${window.location.origin}${routes.campaigns.donationStatus(campaign.slug)}?p_status=${ + payment.status + }&payment_intent=${payment.id}`, + ) + } catch (error) { + setSubmitPaymentLoading(false) + setPaymentError({ + type: 'invalid_request_error', + message: (error as StripeError).message ?? t('step.summary.alerts.error'), + }) + + return + } + + // Confirm the payment + }} + validateOnMount={false} + validateOnChange={true} + validateOnBlur={true}> + {({ handleSubmit, values, errors, submitCount, isValid }) => ( + + +
+ { + cancelSetupIntentMutation.mutate({ id: setupIntent.id }) + router.push(routes.campaigns.viewCampaignBySlug(campaign.slug)) + }} + title={t('cancel-dialog.title')} + content={t('cancel-dialog.content')} + confirmButtonLabel={t('cancel-dialog.btn-continue')} + cancelButtonLabel={t('cancel-dialog.btn-cancel')} + handleConfirm={() => { + setShowCancelDialog(false) + }} + /> + + + + 0)} + /> + + + + 0)} /> + + + + + 0} + /> + + + + + + + + + 0} + /> + + + + + + + + + + + + + {t('step.summary.field.anonymous.label')} + + + + + + + } + name="isAnonymous" + checkboxProps={{ + disabled: !session?.user, + }} + /> + + 0} + paymentError={paymentError} + /> + 0 && !isValid)} + loading={submitPaymentLoading} + label={t('action.submit')} + sx={{ maxWidth: 150 }} + /> + + + + + + + +
+
+ )} +
+ ) +} diff --git a/src/components/client/donation-flow/DonationFlowLayout.tsx b/src/components/client/donation-flow/DonationFlowLayout.tsx new file mode 100644 index 000000000..0ebdf18bd --- /dev/null +++ b/src/components/client/donation-flow/DonationFlowLayout.tsx @@ -0,0 +1,100 @@ +import React, { PropsWithChildren } from 'react' +import Link from 'next/link' +import Image from 'next/image' +import { Typography, Box, Unstable_Grid2 as Grid2, useMediaQuery } from '@mui/material' +import { styled } from '@mui/material/styles' + +import { + backgroundCampaignPictureUrl, + beneficiaryCampaignPictureUrl, +} from 'common/util/campaignImageUrls' +import theme from 'common/theme' +import { routes } from 'common/routes' +import Layout from 'components/client/layout/Layout' +import { CampaignResponse } from 'gql/campaigns' + +const StyledBannerWrapper = styled(Box)(() => ({ + '& span': { + position: 'inherit !important', + }, +})) + +const StyledBanner = styled(Image)(({ theme }) => ({ + zIndex: -1, + maxHeight: '350px !important', + marginTop: `${theme.spacing(10)} !important`, + [theme.breakpoints.up('md')]: { + marginTop: `${theme.spacing(14)} !important`, + }, + objectFit: 'cover', +})) + +const StyledBeneficiaryAvatarWrapper = styled(Grid2)(({ theme }) => ({ + textAlign: 'center', + [theme.breakpoints.up('md')]: { + textAlign: 'center', + }, +})) + +const StyledBeneficiaryAvatar = styled(Image)(({ theme }) => ({ + borderRadius: '50%', + border: `4px solid ${theme.palette.common.white} !important`, + textAlign: 'center', + [theme.breakpoints.up('md')]: { + border: `4px solid ${theme.palette.common.white} !important`, + }, +})) + +type StyledStepsWrapperProps = { + maxWidth?: string | number +} +const StyledStepsWrapper = styled(Grid2)(({ maxWidth }) => ({ + width: '100%', + maxWidth: maxWidth ?? 'auto', +})) + +function DonationFlowLayout({ + children, + campaign, + maxWidth, +}: PropsWithChildren<{ campaign: CampaignResponse; maxWidth?: string | number }>) { + const bannerSource = backgroundCampaignPictureUrl(campaign) + const beneficiaryAvatarSource = beneficiaryCampaignPictureUrl(campaign) + const matches = useMediaQuery('sm') + return ( + + + + {/* A11Y TODO: Translate alt text */} + + + + + + + + + + {campaign.title} + + + {children} + + + + ) +} + +export default DonationFlowLayout diff --git a/src/components/client/donation-flow/DonationFlowPage.tsx b/src/components/client/donation-flow/DonationFlowPage.tsx index 78728dcfa..4145bba02 100644 --- a/src/components/client/donation-flow/DonationFlowPage.tsx +++ b/src/components/client/donation-flow/DonationFlowPage.tsx @@ -1,137 +1,35 @@ -import Link from 'next/link' -import Image from 'next/image' -import { styled } from '@mui/material/styles' -import { Box, Grid, Typography, useMediaQuery } from '@mui/material' - -import theme from 'common/theme' -import { routes } from 'common/routes' -import { - backgroundCampaignPictureUrl, - beneficiaryCampaignPictureUrl, -} from 'common/util/campaignImageUrls' -import Layout from 'components/client/layout/Layout' +import Stripe from 'stripe' import { useViewCampaign } from 'common/hooks/campaigns' -import CenteredSpinner from 'components/common/CenteredSpinner' -// import RadioAccordionGroup, { testRadioOptions } from 'components/donation-flow/RadioAccordionGroup' -// import PaymentDetailsStripeForm from 'components/admin/donations/stripe/PaymentDetailsStripeForm' - -const PREFIX = 'OneTimeDonationPage' - -const classes = { - bannerWrapper: `${PREFIX}-bannerWrapper`, - banner: `${PREFIX}-banner`, - beneficiaryAvatarWrapper: `${PREFIX}-beneficiaryAvatarWrapper`, - beneficiaryAvatar: `${PREFIX}-beneficiaryAvatar`, - stepperWrapper: `${PREFIX}-stepperWrapper`, -} - -const StyledLayout = styled(Layout)(({ theme }) => ({ - [`& .${classes.bannerWrapper}`]: { - '& span': { - position: 'inherit !important', - }, - }, - - [`& .${classes.banner}`]: { - zIndex: -1, - maxHeight: '350px !important', - marginTop: `${theme.spacing(10)} !important`, - [theme.breakpoints.up('md')]: { - marginTop: `${theme.spacing(14)} !important`, - }, - objectFit: 'cover', - }, - - [`& .${classes.beneficiaryAvatarWrapper}`]: { - textAlign: 'center', - [theme.breakpoints.up('md')]: { - textAlign: 'center', - }, - }, - - [`& .${classes.beneficiaryAvatar}`]: { - borderRadius: '50%', - border: `4px solid ${theme.palette.common.white} !important`, - textAlign: 'center', - }, - - [`& .${classes.stepperWrapper}`]: { - gap: theme.spacing(2), - display: 'grid', - }, -})) - -export default function DonationFlowPage({ slug }: { slug: string }) { - const { data, isLoading } = useViewCampaign(slug) - const matches = useMediaQuery('sm') - // const paymentIntentMutation = useCreatePaymentIntent({ - // amount: 100, - // currency: 'BGN', - // }) - // useEffect(() => { - // paymentIntentMutation.mutate() - // }, []) - if (isLoading || !data) return - const { campaign } = data - - const bannerSource = backgroundCampaignPictureUrl(campaign) - const beneficiaryAvatarSource = beneficiaryCampaignPictureUrl(campaign) +import { DonationFlowForm } from './DonationFlowForm' +import { DonationFlowProvider } from './contexts/DonationFlowProvider' +import { StripeElementsProvider } from './contexts/StripeElementsProvider' +import DonationFlowLayout from './DonationFlowLayout' +import { CampaignResponse } from 'gql/campaigns' + +export default function DonationFlowPage({ + slug, + setupIntent, + idempotencyKey, +}: { + slug: string + setupIntent: Stripe.SetupIntent + idempotencyKey: string +}) { + const { data } = useViewCampaign(slug) + //This query needs to be prefetched in the pages folder + //otherwise on the first render the data will be undefined + const campaign = data?.campaign as CampaignResponse return ( - - - - {/* A11Y TODO: Translate alt text */} - Campaign banner image - - - - - - - - - {campaign.title} - - - {/* {paymentIntentMutation.isLoading ? ( - - ) : ( - - )} */} - {/* */} - - - + + + + + + + ) } diff --git a/src/components/client/donation-flow/DonationFlowStatusPage.tsx b/src/components/client/donation-flow/DonationFlowStatusPage.tsx new file mode 100644 index 000000000..e002a4048 --- /dev/null +++ b/src/components/client/donation-flow/DonationFlowStatusPage.tsx @@ -0,0 +1,233 @@ +import { useEffect, useRef, useState } from 'react' +import { useTranslation } from 'next-i18next' +import Link from 'next/link' +import { useRouter } from 'next/router' +import { useSession } from 'next-auth/react' +import { Form, Formik, FormikProps } from 'formik' +import { + Box, + CircularProgress, + Stack, + Typography, + Card, + CardContent, + CardActionArea, + Unstable_Grid2 as Grid2, +} from '@mui/material' +import { Email } from '@mui/icons-material' + +import { routes } from 'common/routes' + +import { useViewCampaign } from 'common/hooks/campaigns' +import FormTextField from 'components/common/form/FormTextField' +import SocialShareListButton from 'components/common/SocialShareListButton' +import SubmitButton from 'components/common/form/SubmitButton' +import theme from 'common/theme' + +import SuccessGraphic from './icons/SuccessGraphic' +import { DonationFormPaymentStatus } from './helpers/types' +import DonationFlowLayout from './DonationFlowLayout' +import StepSplitter from './common/StepSplitter' +import { useMutation } from '@tanstack/react-query' +import { createDonationWish } from 'service/donationWish' +import { AlertStore } from 'stores/AlertStore' +import { useCurrentPerson } from 'common/util/useCurrentPerson' +import { CampaignResponse } from 'gql/campaigns' +import FailGraphic from './icons/FailGraphic' +import getConfig from 'next/config' +import { useFindDonationById } from 'common/hooks/donation' +const { publicRuntimeConfig } = getConfig() +function LinkCard({ href, text }: { href: string; text: string }) { + return ( + + + + + {text} + + + + + ) +} + +export default function DonationFlowStatusPage({ slug }: { slug: string }) { + const { t } = useTranslation('donation-flow') + const { data } = useViewCampaign(slug) + //This query needs to be prefetched in the pages folder + //otherwise on the first render the data will be undefined + const router = useRouter() + const { p_status, p_error, payment_intent, bank_payment } = router.query + const campaign = data?.campaign as CampaignResponse + const [status] = useState(p_status as DonationFormPaymentStatus) + const { data: donationData, isLoading } = useFindDonationById(payment_intent as string) + + const [error] = useState(p_error as string) + const [disableWishForm, setDisableWishForm] = useState( + (!isLoading && !donationData) || !!bank_payment, + ) + + const formikRef = useRef | null>(null) + const session = useSession() + const { data: { user: person } = { user: null } } = useCurrentPerson() + + useEffect(() => { + if (p_status === 'succeeded') { + sessionStorage.removeItem(`donation-flow-${campaign.slug}`) + } + }, []) + const { mutate: createDonationWishMutate, isLoading: isWishSendLoading } = useMutation( + createDonationWish, + { + onSuccess: () => { + setDisableWishForm(true) + AlertStore.show(t('status.success.wish.thanks'), 'success', 3000) + formikRef.current?.resetForm() + }, + onError: () => { + setDisableWishForm(false) + AlertStore.show(t('status.success.wish.error'), 'error') + }, + }, + ) + + const Success = () => ( + + + {session.data?.user + ? `${session.data?.user?.given_name} ${session.data.user.family_name}, ${t( + 'status.success.title-logged', + )}` + : t('status.success.title')} + ! + + + + {t('status.success.email')} + + + { + createDonationWishMutate({ + message: values.wish, + campaignId: campaign.id, + personId: person?.id ? person.id : null, + donationId: donationData?.id, + }) + }} + validateOnMount + validateOnBlur + innerRef={formikRef}> + {({ handleSubmit }) => ( +
+ + + + {t('status.success.wish.title')}: + + + + + +
+ )} +
+ + + + + {t('status.success.share.title')}. + + {t('status.success.share.description')}! + + + + + + + + + + + + + + + + + + +
+ ) + + const Fail = () => ( + + + {t('status.fail.title')} + + {/* TODO: Provide a better instead of just an X */} + + + {error} + + + + + + + + + + + + ) + + const StatusToRender = () => + status === DonationFormPaymentStatus.SUCCEEDED ? : error ? : null + + return ( + + {status ? ( + + ) : ( + + + + )} + + ) +} diff --git a/src/components/client/donation-flow/alerts/AlertsColumn.tsx b/src/components/client/donation-flow/alerts/AlertsColumn.tsx new file mode 100644 index 000000000..fe90733c7 --- /dev/null +++ b/src/components/client/donation-flow/alerts/AlertsColumn.tsx @@ -0,0 +1,83 @@ +import React from 'react' +import { useTranslation } from 'next-i18next' +import { AlertProps, Typography } from '@mui/material' +import { useFormikContext } from 'formik' +import { AnchoredAlert } from './AnchoredAlert' +import { + DonationFormAuthState, + DonationFormPaymentMethod, + DonationFormData, +} from '../helpers/types' +import { useElements } from '@stripe/react-stripe-js' +import { AuthenticateAlertContent, NoRegisterContent } from './AlertsContent' +import { ids } from '../common/DonationFormSections' + +function AlertsColumn({ + sectionsRefArray, +}: { + sectionsRefArray: React.MutableRefObject[] +}) { + const { t } = useTranslation('donation-flow') + const { + values: { payment, authentication }, + } = useFormikContext() + const cardAlertDescription = t('step.payment-method.alert.card-fee') + const bankAlertDescription = t('step.payment-method.alert.bank-fee') + const paymentMethodAlertMap = { + [DonationFormPaymentMethod.CARD]: cardAlertDescription, + [DonationFormPaymentMethod.BANK]: bankAlertDescription, + } + const [updatedRefArray, setUpdatedRefArray] = + React.useState[]>(sectionsRefArray) + const elements = useElements() + const paymentElement = elements?.getElement('payment') + paymentElement?.once('ready', () => { + setUpdatedRefArray([...sectionsRefArray]) + }) + const alerts: { [key: string]: AlertProps } = { + 'select-payment--radiocard': { + color: 'info', + children: ( + + {payment && paymentMethodAlertMap[payment]} + + ), + icon: false, + sx: { + display: payment ? 'flex' : 'none', + }, + }, + [ids['authentication']]: { + color: 'info', + children: + authentication === DonationFormAuthState.NOREGISTER ? ( + + ) : ( + + ), + icon: false, + sx: { + display: + authentication === DonationFormAuthState.AUTHENTICATED || authentication === null + ? 'none' + : 'flex', + }, + }, + } + + return ( + <> + {updatedRefArray.map((ref, index) => { + const alert = alerts[ref.current?.id as keyof typeof alerts] + return + })} + + ) +} + +export default AlertsColumn diff --git a/src/components/client/donation-flow/alerts/AlertsContent.tsx b/src/components/client/donation-flow/alerts/AlertsContent.tsx new file mode 100644 index 000000000..1d6f522ed --- /dev/null +++ b/src/components/client/donation-flow/alerts/AlertsContent.tsx @@ -0,0 +1,55 @@ +import { useTranslation } from 'next-i18next' +import { Box, List, ListItem, ListItemText, SxProps, Typography } from '@mui/material' + +export const AuthenticateAlertContent = () => { + const { t } = useTranslation('donation-flow') + + const liSx: SxProps = { + '& .MuiTypography-root': { + fontSize: '1rem', + fontStyle: 'italic', + }, + p: 0, + } + + return ( + + {t('step.authentication.alert.authenticate.title')}: + + + + + + + + + + + + + + + + ) +} + +export const NoRegisterContent = () => { + const { t } = useTranslation('donation-flow') + return ( + + {t('step.authentication.noregister.description')} + + ) +} diff --git a/src/components/client/donation-flow/alerts/AnchoredAlert.tsx b/src/components/client/donation-flow/alerts/AnchoredAlert.tsx new file mode 100644 index 000000000..7e8af6098 --- /dev/null +++ b/src/components/client/donation-flow/alerts/AnchoredAlert.tsx @@ -0,0 +1,22 @@ +import { Alert, AlertProps } from '@mui/material' + +export interface AnchoredAlertProps extends AlertProps { + sectionRef: React.RefObject +} + +export const AnchoredAlert = (props: AnchoredAlertProps) => { + const { sectionRef, sx, ...alertProps } = props + return ( + + ) +} diff --git a/src/components/client/donation-flow/alerts/PaymentSummaryAlert.tsx b/src/components/client/donation-flow/alerts/PaymentSummaryAlert.tsx new file mode 100644 index 000000000..cc7adeb2a --- /dev/null +++ b/src/components/client/donation-flow/alerts/PaymentSummaryAlert.tsx @@ -0,0 +1,136 @@ +import React from 'react' +import { useTranslation } from 'next-i18next' +import { Info } from '@mui/icons-material' +import { BoxProps, IconButton, Theme, Tooltip, Typography } from '@mui/material' +import theme from 'common/theme' +import { moneyPublicDecimals2 } from 'common/util/money' +import { stripeFeeCalculator } from '../helpers/stripe-fee-calculator' +import { CardRegion } from 'gql/donations.enums' +import { useFormikContext } from 'formik' +import { DonationFormData } from '../helpers/types' +import Grid2 from '@mui/material/Unstable_Grid2' + +function PaymentSummaryAlert({ + donationAmount, + sx, +}: { + donationAmount: number + sx?: BoxProps['sx'] + boxProps?: BoxProps +}) { + const { t } = useTranslation('donation-flow') + const formik = useFormikContext() + const feeAmount = + donationAmount !== 0 + ? stripeFeeCalculator(donationAmount, formik.values.cardRegion as CardRegion) + : donationAmount + + return ( + + + + + {t('step.summary.donation')}:{' '} + + + {moneyPublicDecimals2(donationAmount - feeAmount)} + + + + + + {t('step.summary.transaction.title')} + ({ + backgroundColor: '#CBE9FE', + color: theme.palette.text.primary, + border: '1px solid #32A9FE', + fontSize: (theme as Theme).typography.pxToRem(16), + lineHeight: '24px', + fontStyle: 'italic', + letterSpacing: '0.15px', + fontWeight: 400, + borderRadius: 6, + maxWidth: 297, + padding: theme.spacing(1.5), + fontFamily: (theme as Theme).typography.fontFamily, + }), + }, + arrow: { + sx: { + color: '#CBE9FE', + fontSize: 50, + zIndex: 999, + '&:before': { + border: '1px solid #32A9FE', + }, + }, + }, + }} + arrow + placement="top" + sx={{ '& .MuiTooltip-arrow': { fontSize: 'large' } }}> + + + + + :{' '} + + + {moneyPublicDecimals2(feeAmount)} + + + + + + {t('step.summary.total')}:{' '} + + + {moneyPublicDecimals2(donationAmount)} + + + + ) +} + +export default PaymentSummaryAlert diff --git a/src/components/client/donation-flow/common/DonationFormErrors.tsx b/src/components/client/donation-flow/common/DonationFormErrors.tsx new file mode 100644 index 000000000..29eac9153 --- /dev/null +++ b/src/components/client/donation-flow/common/DonationFormErrors.tsx @@ -0,0 +1,103 @@ +import React from 'react' +import { Grid, Typography } from '@mui/material' +import { useTranslation } from 'next-i18next' +import { FormikErrors } from 'formik' +import { DonationFormData } from '../helpers/types' +import { ids, DonationFormSections } from './DonationFormSections' +import ArrowUpwardIcon from '@mui/icons-material/ArrowUpward' +import { ErrorTwoTone } from '@mui/icons-material' +import theme from 'common/theme' +import { StripeError } from '@stripe/stripe-js' + +type DonationFormErrorProps = { + errors: FormikErrors + show: boolean + paymentError: StripeError | null +} + +type DonationFormSectionErrorTextProps = { + message: string +} +export function DonationFormSectionErrorText({ message }: DonationFormSectionErrorTextProps) { + return ( + + + + {message} + + + ) +} + +export function DonationFormErrorList({ errors, show, paymentError }: DonationFormErrorProps) { + const { t } = useTranslation() + + return ( + + {show && ( + <> + {Object.entries(errors).map(([id, err]) => ( + { + const elementId = ids[id as keyof DonationFormSections] ?? id + const element = document.getElementById(elementId) + const elementPosition = element?.getBoundingClientRect().top + if (!elementPosition) return + const offsetPosition = elementPosition + window.scrollY - 150 + window.scrollTo({ top: offsetPosition, behavior: 'smooth' }) + }}> + + + {t(err)} + + + ))} + {paymentError && ( + { + const elementId = ids['stripeCardField'] + const element = document.getElementById(elementId) + const elementPosition = element?.getBoundingClientRect().top + if (!elementPosition) return + const offsetY = 100 + const offsetPosition = elementPosition + window.scrollY - offsetY + window.scrollTo({ top: offsetPosition, behavior: 'smooth' }) + }}> + + + {paymentError.message} + + + )} + + )} + + ) +} diff --git a/src/components/client/donation-flow/common/DonationFormSections.ts b/src/components/client/donation-flow/common/DonationFormSections.ts new file mode 100644 index 000000000..418cb3f11 --- /dev/null +++ b/src/components/client/donation-flow/common/DonationFormSections.ts @@ -0,0 +1,33 @@ +//Map formik field names to HTML ids. + +export type DonationFormSections = { + finalAmount: 'select-donation-amount' + amountChosen: 'select-donation-amount' + payment: 'select-payment-method' + authentication: 'select-authentication-method' + mode: 'select-recurring-payment' + loginEmail: 'authentication-login' + loginPassword: 'authentication-login' + registerEmail: 'authentication-register' + registerPassword: 'authentication-register' + registerFirstName: 'authentication-register' + registerLastName: 'authentication-register' + registerConfirmPassword: 'authentication-register' + stripeCardField: 'stripe-card-field' +} + +export const ids: DonationFormSections = { + finalAmount: 'select-donation-amount', + amountChosen: 'select-donation-amount', + payment: 'select-payment-method', + authentication: 'select-authentication-method', + mode: 'select-recurring-payment', + loginEmail: 'authentication-login', + loginPassword: 'authentication-login', + registerEmail: 'authentication-register', + registerPassword: 'authentication-register', + registerFirstName: 'authentication-register', + registerLastName: 'authentication-register', + registerConfirmPassword: 'authentication-register', + stripeCardField: 'stripe-card-field', +} diff --git a/src/components/client/donation-flow/common/RadioAccordionGroup.tsx b/src/components/client/donation-flow/common/RadioAccordionGroup.tsx index 6c01dac73..411c6a870 100644 --- a/src/components/client/donation-flow/common/RadioAccordionGroup.tsx +++ b/src/components/client/donation-flow/common/RadioAccordionGroup.tsx @@ -2,64 +2,62 @@ import React from 'react' import { Box, BoxProps, - Button, Collapse, FormControl, FormControlLabel, Radio, RadioGroup, RadioGroupProps, - TextField, } from '@mui/material' import { styled } from '@mui/material/styles' +import { useField } from 'formik' import theme from 'common/theme' -import CardIcon from '../icons/CardIcon' -import BankIcon from '../icons/BankIcon' -export const StyledRadioAccordionItem = styled(Box)(() => ({ +export const BaseRadioAccordionItem = styled(Box)(() => ({ + '&:first-of-type': { + borderBottom: `1px solid ${theme.borders.dark}`, + borderTopLeftRadius: theme.borders.semiRound, + borderTopRightRadius: theme.borders.semiRound, + }, '&:not(:last-child)': { borderBottom: `1px solid ${theme.borders.dark}`, }, + '&:last-child': { + borderBottomLeftRadius: theme.borders.semiRound, + borderBottomRightRadius: theme.borders.semiRound, + }, padding: theme.spacing(2), margin: 0, cursor: 'pointer', })) +export const DisabledRadioAccordionItem = styled(BaseRadioAccordionItem)(() => ({ + opacity: 0.7, + backgroundColor: `${theme.palette.grey[300]} !important`, + pointerEvents: 'none', + borderColor: `${theme.palette.grey[500]} !important`, +})) + interface RadioAccordionItemProps extends Omit { control: React.ReactNode - icon: React.ReactNode + icon?: React.ReactNode content?: React.ReactNode selected?: boolean + disabled?: boolean } -// Temporarily here for testing until the components starts being used -export const testRadioOptions: Option[] = [ - { - value: 'card', - label: 'Card', - content: ( -
- - -
- ), - icon: , - }, - { - value: 'bank', - label: 'Bank', - content:
TODO: Add bank form
, - icon: , - }, -] - function RadioAccordionItem({ control, icon, selected, content, + disabled, ...rest }: RadioAccordionItemProps) { + let StyledRadioAccordionItem = BaseRadioAccordionItem + if (disabled) { + StyledRadioAccordionItem = DisabledRadioAccordionItem + } return ( @@ -77,42 +75,84 @@ type Option = { value: string label: string content: React.ReactNode - icon: React.ReactNode + icon?: React.ReactNode + disabled?: boolean + control?: React.ReactElement } export interface RadioAccordionGroupProps extends RadioGroupProps { + /** + * The options to display in the radio group. + */ options: Option[] - defaultValue?: string + + /** + * The name of the field. + * This is used to link the radio group to the form. + */ + name: string + /** + * Whether the field has an error + */ + error?: boolean } -function RadioAccordionGroup({ options, defaultValue }: RadioAccordionGroupProps) { - const [value, setValue] = React.useState(defaultValue) +/** + * A radio group that displays a list of options. Each option can be expanded to show more content. + * @example + * , + * }, + * { + * value: 'register', + * label: 'Register', + * disabled: Boolean(session?.user), + * content: , + * }] + */ +function RadioAccordionGroup({ options, name, sx, error, ...rest }: RadioAccordionGroupProps) { + const [field, meta, { setValue }] = useField(name) const handleChange = (event: React.ChangeEvent) => { setValue(event.target.value) } + const showError = + typeof error !== undefined ? error : Boolean(meta.error) && Boolean(meta.touched) + return ( - + + ...sx, + }} + {...rest}> {options.map((option) => ( setValue(option.value)} control={ - } label={option.label} /> + } + label={option.label} + disabled={option.disabled} + /> } icon={option.icon} - selected={value === option.value} + selected={field.value === option.value} content={option.content} + disabled={option.disabled} /> ))} diff --git a/src/components/client/donation-flow/common/RadioCardGroup.tsx b/src/components/client/donation-flow/common/RadioCardGroup.tsx index d1299d34f..3070765f1 100644 --- a/src/components/client/donation-flow/common/RadioCardGroup.tsx +++ b/src/components/client/donation-flow/common/RadioCardGroup.tsx @@ -1,4 +1,5 @@ import React from 'react' +import { useField } from 'formik' import { Card, CardProps, @@ -8,50 +9,63 @@ import { RadioGroup, RadioGroupProps, Stack, - Unstable_Grid2 as Grid2, + Grid, + Skeleton, } from '@mui/material' import { styled, lighten } from '@mui/material/styles' import theme from 'common/theme' -import CardIcon from '../icons/CardIcon' -import BankIcon from '../icons/BankIcon' export const StyledRadioCardItem = styled(Card)(() => ({ padding: theme.spacing(2), margin: 0, cursor: 'pointer', border: `1px solid ${theme.borders.dark}`, + width: '100%', + '&:focus-within': { + outline: `2px solid ${theme.palette.common.black}`, + }, })) interface StyledRadioCardItemProps extends CardProps { control: React.ReactNode icon: React.ReactNode + disabled?: boolean + loading?: boolean selected?: boolean + error?: boolean } -// Temporarily here for testing until the components starts being used -export const testRadioOptions: Option[] = [ - { - value: 'card', - label: 'Card', - icon: , - }, - { - value: 'bank', - label: 'Bank', - icon: , - }, - { - value: 'paypal', - label: 'PayPal', - icon: , - }, -] +function RadioCardItem({ + control, + icon, + selected, + disabled, + loading, + error, + ...rest +}: StyledRadioCardItemProps) { + const selectedStyles = { + backgroundColor: selected ? lighten(theme.palette.primary.light, 0.7) : 'inherit', + borderColor: error ? theme.palette.error.main : 'inherit', + } + const disabledStyles = { + opacity: 0.7, + backgroundColor: `${theme.palette.grey[300]} !important`, + pointerEvents: 'none', + borderColor: `${theme.palette.grey[500]} !important`, + } -function RadioCardItem({ control, icon, selected, ...rest }: StyledRadioCardItemProps) { - return ( - + let styles = {} + if (disabled) { + styles = disabledStyles + } else if (selected) { + styles = selectedStyles + } + + return loading ? ( + + ) : ( + {icon} {control} @@ -64,45 +78,71 @@ type Option = { value: string label: string icon: React.ReactNode + disabled?: boolean } export interface RadioCardGroupProps extends RadioGroupProps { options: Option[] - defaultValue?: string + name: string + columns: 1 | 2 | 3 | 4 | 6 | 12 + loading?: boolean + error?: boolean } -function RadioCardGroup({ options, defaultValue }: RadioCardGroupProps) { - const [value, setValue] = React.useState(defaultValue) - +/** + * RadioCardGroup is a group of radio buttons that display a card for each option. + * The element is hidden, but accessible to screen readers. + * @example + * , + * }, + * { + * value: '25', + * label: '$25', + * icon: , + * }, + */ +function RadioCardGroup({ options, name, columns, loading, error }: RadioCardGroupProps) { + const [field, meta, { setValue }] = useField(name) const handleChange = (event: React.ChangeEvent) => { setValue(event.target.value) } + const showError = + typeof error !== undefined ? Boolean(error) : Boolean(meta.error) && Boolean(meta.touched) return ( - - - + + + {options.map((option) => ( - + setValue(option.value)} + style={{ + border: `1px solid ${ + showError ? theme.palette.error.main : theme.palette.common.black + }`, + }} control={ } @@ -110,11 +150,13 @@ function RadioCardGroup({ options, defaultValue }: RadioCardGroupProps) { /> } icon={option.icon} - selected={value === option.value} + selected={field.value === option.value && !option.disabled} + disabled={option.disabled} + loading={loading} /> - + ))} - + ) diff --git a/src/components/client/donation-flow/common/StepSplitter.tsx b/src/components/client/donation-flow/common/StepSplitter.tsx new file mode 100644 index 000000000..3dfa467be --- /dev/null +++ b/src/components/client/donation-flow/common/StepSplitter.tsx @@ -0,0 +1,36 @@ +import React from 'react' +import { Avatar, Box, Typography } from '@mui/material' +import { grey } from '@mui/material/colors' + +import theme from 'common/theme' + +type StepSplitterProps = { + content?: string + active?: boolean +} + +const Line = () => { + return +} + +function StepSplitter({ content, active }: StepSplitterProps) { + return ( + + + {content ? ( + + {content} + + ) : null} + + + ) +} + +export default StepSplitter diff --git a/src/components/client/donation-flow/contexts/DonationFlowProvider.tsx b/src/components/client/donation-flow/contexts/DonationFlowProvider.tsx new file mode 100644 index 000000000..c8676bb1d --- /dev/null +++ b/src/components/client/donation-flow/contexts/DonationFlowProvider.tsx @@ -0,0 +1,48 @@ +import React, { PropsWithChildren } from 'react' +import Stripe from 'stripe' +import { Stripe as StripeType, StripeError } from '@stripe/stripe-js' + +import { stripe } from 'service/stripeClient' +import { CampaignResponse } from 'gql/campaigns' + +type DonationContext = { + setupIntent: Stripe.SetupIntent + paymentError: StripeError | null + setPaymentError: React.Dispatch> + campaign: CampaignResponse + stripe: StripeType | null + idempotencyKey: string +} + +const DonationFlowContext = React.createContext({} as DonationContext) + +export const DonationFlowProvider = ({ + campaign, + setupIntent, + idempotencyKey, + children, +}: PropsWithChildren<{ + campaign: CampaignResponse + setupIntent: Stripe.SetupIntent + idempotencyKey: string +}>) => { + const [paymentError, setPaymentError] = React.useState(null) + + const value = { + idempotencyKey, + setupIntent, + paymentError, + setPaymentError, + campaign, + stripe, + } + return {children} +} + +export function useDonationFlow() { + const context = React.useContext(DonationFlowContext) + if (context === undefined) { + throw new Error('useDonationFlow must be used within a DonationFlowProvider') + } + return context +} diff --git a/src/components/client/donation-flow/contexts/StripeElementsProvider.tsx b/src/components/client/donation-flow/contexts/StripeElementsProvider.tsx new file mode 100644 index 000000000..d5af40c51 --- /dev/null +++ b/src/components/client/donation-flow/contexts/StripeElementsProvider.tsx @@ -0,0 +1,57 @@ +import React, { PropsWithChildren } from 'react' +import { useTranslation } from 'next-i18next' +import { Appearance, StripeElementLocale } from '@stripe/stripe-js' +import { Elements } from '@stripe/react-stripe-js' + +import theme from 'common/theme' + +import { useDonationFlow } from './DonationFlowProvider' + +const appearance: Appearance = { + theme: 'stripe', + variables: { + colorPrimary: theme.palette.primary.main, + colorBackground: theme.palette.background.paper, + // colorText: theme.palette.text.primary resolves to rgba(0, 0, 0, 0.87) and Stripe doesn't accept rgba values + colorText: 'rgb(0, 0, 0)', + colorDanger: theme.palette.error.main, + fontFamily: "Montserrat, 'Helvetica Neue', Helvetica, Arial, sans-serif", + fontSizeSm: theme.typography.pxToRem(14), + fontSizeBase: theme.typography.pxToRem(14), + fontSizeLg: theme.typography.pxToRem(18), + fontSizeXl: theme.typography.pxToRem(20), + spacingUnit: theme.spacing(0), + borderRadius: theme.borders.round, + focusBoxShadow: 'none', + focusOutline: `2px solid ${theme.palette.primary.main}`, + }, + rules: { + '.Input': { + boxShadow: 'none', + border: `1px solid ${theme.palette.grey[300]}`, + }, + '.Input:focus': { + border: 'none', + boxShadow: 'none', + }, + }, +} + +export function StripeElementsProvider({ children }: PropsWithChildren) { + const { i18n } = useTranslation() + + const { stripe, setupIntent } = useDonationFlow() + return ( + <> + + {children} + + + ) +} diff --git a/src/components/client/donation-flow/helpers/confirmStripeDonation.ts b/src/components/client/donation-flow/helpers/confirmStripeDonation.ts new file mode 100644 index 000000000..f47accd95 --- /dev/null +++ b/src/components/client/donation-flow/helpers/confirmStripeDonation.ts @@ -0,0 +1,54 @@ +import { DonationFormData, DonationFormPaymentStatus, PaymentMode } from './types' +import { createIntentFromSetup } from 'service/donation' +import { CampaignResponse } from 'gql/campaigns' +import { routes } from 'common/routes' +import { Stripe, StripeElements } from '@stripe/stripe-js' +import type StripeJS from 'stripe' +import { Session } from 'next-auth' + +export async function confirmStripePayment( + setupIntent: StripeJS.SetupIntent, + elements: StripeElements, + stripe: Stripe, + campaign: CampaignResponse, + values: DonationFormData, + session: Session | null, + idempotencyKey: string, +): Promise { + if (setupIntent.status !== DonationFormPaymentStatus.SUCCEEDED) { + const { error: intentError } = await stripe.confirmSetup({ + elements, + confirmParams: { + return_url: `${window.location.origin}${routes.campaigns.donationStatus(campaign.slug)}`, + payment_method_data: { + billing_details: { name: values.billingName, email: values.billingEmail }, + }, + }, + redirect: 'if_required', + }) + if (intentError) { + throw intentError + } + } + const payment = await createIntentFromSetup( + setupIntent.id, + idempotencyKey, + values.mode as PaymentMode, + session, + ) + + if (payment.data.status === DonationFormPaymentStatus.REQUIRES_ACTION) { + const { error: confirmPaymentError } = await stripe.confirmCardPayment( + payment.data.client_secret as string, + ) + if (confirmPaymentError) throw confirmPaymentError + //Retrieve latest paymentintent status + const { paymentIntent, error: retrievePaymentError } = await stripe.retrievePaymentIntent( + payment.data.client_secret as string, + ) + if (!paymentIntent || retrievePaymentError) throw retrievePaymentError + + return paymentIntent as StripeJS.PaymentIntent + } + return payment.data +} diff --git a/src/components/client/one-time-donation/helpers/stripe-fee-calculator.ts b/src/components/client/donation-flow/helpers/stripe-fee-calculator.ts similarity index 100% rename from src/components/client/one-time-donation/helpers/stripe-fee-calculator.ts rename to src/components/client/donation-flow/helpers/stripe-fee-calculator.ts diff --git a/src/components/client/donation-flow/helpers/types.ts b/src/components/client/donation-flow/helpers/types.ts new file mode 100644 index 000000000..01bff93bf --- /dev/null +++ b/src/components/client/donation-flow/helpers/types.ts @@ -0,0 +1,54 @@ +import { CardRegion } from 'gql/donations.enums' + +export enum DonationFormAuthState { + LOGIN = 'login', + REGISTER = 'register', + AUTHENTICATED = 'authenticated', + NOREGISTER = 'noregister', +} + +export enum DonationFormPaymentMethod { + CARD = 'card', + BANK = 'bank', +} + +// "canceled" | "processing" | "requires_action" | "requires_capture" | "requires_confirmation" | "requires_payment_method" | "succeeded" +export enum DonationFormPaymentStatus { + SUCCEEDED = 'succeeded', + PROCESSING = 'processing', + // This values is based on what stripe returns https://stripe.com/docs/payments/accept-a-payment?platform=web&ui=elements&client=react#blik + REQUIRES_PAYMENT = 'requires_payment_method', + CANCELED = 'canceled', + REQUIRES_ACTION = 'requires_action', + REQUIRES_CAPTURE = 'requires_capture', + REQUIRES_CONFIRMATION = 'requires_confirmation', +} + +export type PaymentMode = 'one-time' | 'subscription' +export type DonationFormData = { + //Common fields + isAnonymous: boolean + authentication: DonationFormAuthState | null + payment: DonationFormPaymentMethod | null + privacy: boolean + //Card fields + mode: PaymentMode | null + cardRegion?: CardRegion + cardIncludeFees?: boolean + finalAmount?: number + amountChosen?: string + otherAmount?: number + //Login fields + billingEmail?: string + billingName?: string + loginEmail?: string + loginPassword?: string + //Register fields + registerEmail?: string + registerPassword?: string + registerConfirmPassword?: string + registerFirstName?: string + registerLastName?: string + registerTerms?: boolean + registerGdpr?: boolean +} diff --git a/src/components/client/donation-flow/icons/FailGraphic.tsx b/src/components/client/donation-flow/icons/FailGraphic.tsx new file mode 100644 index 000000000..b53b1755e --- /dev/null +++ b/src/components/client/donation-flow/icons/FailGraphic.tsx @@ -0,0 +1,29 @@ +import { SvgIcon, SvgIconProps } from '@mui/material' +import theme from 'common/theme' + +function FailGraphic(props: SvgIconProps) { + return ( + + + + + + ) +} + +export default FailGraphic diff --git a/src/components/client/donation-flow/icons/SuccessGraphic.tsx b/src/components/client/donation-flow/icons/SuccessGraphic.tsx new file mode 100644 index 000000000..793dac33e --- /dev/null +++ b/src/components/client/donation-flow/icons/SuccessGraphic.tsx @@ -0,0 +1,1367 @@ +import { SvgIcon } from '@mui/material' +import { styled } from '@mui/styles' +import React from 'react' + +const StyledSvgIcon = styled(SvgIcon)({ + width: '100%', + height: '100%', + '& .st1': { + fill: '#DAEBE8', + }, + '& .st2': { + fill: '#CCD39C', + }, + '& .st3': { + fill: '#FFB27D', + }, + '& .st4': { + fill: '#E9845C', + }, + '& .st5': { + fill: '#331832', + }, + '& .st6': { + fill: '#68A1BF', + }, + '& .st7': { + fill: '#EF8062', + }, + '& .st8': { + fill: '#5C305D', + }, + '& .st9': { + fill: '#080435', + }, + '& .st10': { + opacity: 0.5, + }, + '& .st11': { + fill: '#EC865C', + }, + '& .st12': { + fill: '#764678', + }, + '& .st13': { + opacity: 0.8, + }, + '& .st14': { + fill: '#ACC9C5', + }, + '& .st15': { + opacity: 0.6, + }, + '& .st16': { + fill: '#8BB2AC', + }, + '& .st17': { + opacity: 0.7, + }, + '& .st18': { + fill: '#E56E56', + }, + '& .st19': { + opacity: 0.4, + }, + '& .st20': { + fill: '#366F90', + }, + '& .st21': { + fill: '#73B3CE', + }, + '& .st22': { + fill: '#020202', + }, + '& .st23': { + opacity: 0.3, + }, + '& .st24': { + fill: '#444092', + }, +}) + +function SuccessGraphic() { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ) +} + +export default SuccessGraphic diff --git a/src/components/client/donation-flow/steps/Amount.tsx b/src/components/client/donation-flow/steps/Amount.tsx new file mode 100644 index 000000000..bdf9b6ed1 --- /dev/null +++ b/src/components/client/donation-flow/steps/Amount.tsx @@ -0,0 +1,139 @@ +import React, { useEffect } from 'react' +import * as yup from 'yup' +import { useTranslation } from 'next-i18next' +import { useMediaQuery, Collapse, Typography, Unstable_Grid2 as Grid2 } from '@mui/material' +import { useField, useFormikContext } from 'formik' + +import { CardRegion } from 'gql/donations.enums' +import theme from 'common/theme' +import { moneyPublic, toMoney } from 'common/util/money' +import RadioButtonGroup from 'components/common/form/RadioButtonGroup' + +import { stripeFeeCalculator, stripeIncludeFeeCalculator } from '../helpers/stripe-fee-calculator' +import { DonationFormData } from '../helpers/types' +import { useSession } from 'next-auth/react' +import { ids } from '../common/DonationFormSections' +import { DonationFormSectionErrorText } from '../common/DonationFormErrors' +import NumberInputField from 'components/common/form/NumberInputField' + +export const initialAmountFormValues = { + amountChosen: '', + finalAmount: 0, + otherAmount: 0, + cardIncludeFees: false, + cardRegion: CardRegion.EU, +} + +export const amountValidation = { + amountChosen: yup.string().when('payment', { + is: 'card', + then: yup.string().required(), + }), + finalAmount: yup.number().when('payment', { + is: 'card', + then: () => + yup.number().min(1, 'donation-flow:step.amount.field.final-amount.error').required(), + }), + otherAmount: yup.number().when('amountChosen', { + is: 'other', + then: yup.number().min(1, 'donation-flow:step.amount.field.final-amount.error').required(), + }), + cardIncludeFees: yup.boolean().when('payment', { + is: 'card', + then: yup.boolean().required(), + }), + cardRegion: yup + .string() + .oneOf(Object.values(CardRegion)) + .when('payment', { + is: 'card', + then: yup.string().oneOf(Object.values(CardRegion)).required(), + }) as yup.SchemaOf, +} + +type SelectDonationAmountProps = { + disabled?: boolean + sectionRef?: React.MutableRefObject + error: boolean +} +export default function Amount({ disabled, sectionRef, error }: SelectDonationAmountProps) { + const formik = useFormikContext() + const [{ value }] = useField('amountChosen') + const { t } = useTranslation('donation-flow') + const { status } = useSession() + // const { data: prices } = useSinglePriceList() + const prices = [1000, 2000, 5000, 10000, 50000, 100000] + const mobile = useMediaQuery('(max-width:600px)') + useEffect(() => { + const amountChosen = + value === 'other' + ? toMoney(Number(formik.values.otherAmount)) + : Number(formik.values.amountChosen) + + if (formik.values.cardIncludeFees) { + formik.setFieldValue('amountWithoutFees', amountChosen) + formik.setFieldValue( + 'finalAmount', + stripeIncludeFeeCalculator(amountChosen, formik.values.cardRegion as CardRegion), + ) + } else { + formik.setFieldValue( + 'amountWithoutFees', + amountChosen - stripeFeeCalculator(amountChosen, formik.values.cardRegion as CardRegion), + ) + formik.setFieldValue('finalAmount', amountChosen) + } + }, [ + formik.values.otherAmount, + formik.values.amountChosen, + formik.values.cardIncludeFees, + formik.values.cardRegion, + ]) + + return ( + + + {t('step.amount.title')}? + + + {error && } + Number(a) - Number(b)) + .map((v) => ({ + label: moneyPublic(Number(v)), + value: String(Number(v)), + })) + .concat({ label: t('step.amount.field.other-amount.label'), value: 'other' }) || [] + } + /> + + + + + + + ) +} diff --git a/src/components/client/donation-flow/steps/PaymentModeSelect.tsx b/src/components/client/donation-flow/steps/PaymentModeSelect.tsx new file mode 100644 index 000000000..97602295c --- /dev/null +++ b/src/components/client/donation-flow/steps/PaymentModeSelect.tsx @@ -0,0 +1,46 @@ +import { Typography, Unstable_Grid2 as Grid2 } from '@mui/material' +import RadioButtonGroup from 'components/common/form/RadioButtonGroup' + +import React, { useEffect } from 'react' +import { ids } from '../common/DonationFormSections' +import { DonationFormData, PaymentMode } from '../helpers/types' +import { useFormikContext } from 'formik' +import { useTranslation } from 'next-i18next' +import { DonationFormSectionErrorText } from '../common/DonationFormErrors' + +type PaymentModeOptions = { + label: string + value: PaymentMode +} + +type PaymentModeSelectProps = { + error: boolean +} +export default function PaymentModeSelect({ error }: PaymentModeSelectProps) { + const formik = useFormikContext() + const { t } = useTranslation('donation-flow') + const options: PaymentModeOptions[] = [ + { + label: t('donation-flow:step.payment-mode.fields.one-time'), + value: 'one-time', + }, + { + label: t('donation-flow:step.payment-mode.fields.monthly'), + value: 'subscription', + }, + ] + useEffect(() => { + if (formik.values.mode === 'subscription') { + formik.setFieldValue('payment', 'card') + } + }, [formik.values.mode]) + return ( + + {t('donation-flow:step.payment-mode.title')} + + {error && } + + + + ) +} diff --git a/src/components/client/donation-flow/steps/authentication/Authentication.tsx b/src/components/client/donation-flow/steps/authentication/Authentication.tsx new file mode 100644 index 000000000..079c9e5b4 --- /dev/null +++ b/src/components/client/donation-flow/steps/authentication/Authentication.tsx @@ -0,0 +1,135 @@ +import { useEffect, useState } from 'react' +import { useTranslation } from 'next-i18next' +import { Box, Typography, Alert, useMediaQuery, Unstable_Grid2 as Grid2 } from '@mui/material' +import { useFormikContext } from 'formik' +import { useSession } from 'next-auth/react' + +import { + DonationFormAuthState, + DonationFormData, +} from 'components/client/donation-flow/helpers/types' +import { + AuthenticateAlertContent, + NoRegisterContent, +} from 'components/client/donation-flow/alerts/AlertsContent' +import theme from 'common/theme' + +import RadioAccordionGroup from '../../common/RadioAccordionGroup' +import InlineLoginForm from './InlineLoginForm' +import InlineRegisterForm from './InlineRegisterForm' +import { ids } from '../../common/DonationFormSections' +import { DonationFormSectionErrorText } from '../../common/DonationFormErrors' + +export default function Authentication({ + sectionRef, + error, +}: { + sectionRef: React.MutableRefObject + error?: boolean +}) { + const { t } = useTranslation('donation-flow') + const { data: session } = useSession() + const { + values: { authentication, mode }, + setFieldValue, + } = useFormikContext() + + useEffect(() => { + if (session?.user) { + setFieldValue('authentication', DonationFormAuthState.AUTHENTICATED) + } + }, [session?.user]) + + const [showAuthAlert, setShowAuthAlert] = useState(true) + const [showNoRegisterAlert, setShowNoRegisterAlert] = useState(true) + + const isSmall = useMediaQuery(theme.breakpoints.down('md')) + + const options = [ + { + value: DonationFormAuthState.LOGIN, + label: t('step.authentication.login.label'), + disabled: Boolean(session?.user), + content: ( + + {isSmall && showAuthAlert ? ( + { + setShowAuthAlert(false) + }} + color="info" + icon={false} + sx={{ mx: -2 }}> + + + ) : null} + + + ), + }, + { + value: DonationFormAuthState.REGISTER, + label: t('step.authentication.register.label'), + disabled: Boolean(session?.user), + content: ( + + {isSmall && showAuthAlert ? ( + { + setShowAuthAlert(false) + }} + color="info" + icon={false} + sx={{ mx: -2 }}> + + + ) : null} + + + ), + }, + { + value: DonationFormAuthState.NOREGISTER, + label: t('step.authentication.noregister.label'), + disabled: Boolean(session?.user || mode === 'subscription'), + content: ( + + {showNoRegisterAlert && isSmall && ( + { + setShowNoRegisterAlert(false) + }} + color="info" + icon={false} + sx={{ mb: 1, mx: -2 }}> + + + )} + + ), + }, + ] + + return ( + + + {t('step.authentication.title')}? + + {authentication === DonationFormAuthState.AUTHENTICATED ? ( + + {t('step.authentication.logged-as')} {session?.user?.email} + + ) : ( + + {error && } + + + )} + + ) +} diff --git a/src/components/client/one-time-donation/LoginForm.tsx b/src/components/client/donation-flow/steps/authentication/InlineLoginForm.tsx similarity index 50% rename from src/components/client/one-time-donation/LoginForm.tsx rename to src/components/client/donation-flow/steps/authentication/InlineLoginForm.tsx index 34e7979c7..dd47a9e85 100644 --- a/src/components/client/one-time-donation/LoginForm.tsx +++ b/src/components/client/donation-flow/steps/authentication/InlineLoginForm.tsx @@ -1,90 +1,95 @@ -import React, { useContext, useState } from 'react' +import React, { useState } from 'react' +import * as yup from 'yup' import { useTranslation } from 'next-i18next' import { signIn } from 'next-auth/react' -import { OneTimeDonation } from 'gql/donations' import { useFormikContext } from 'formik' - -import { Box, Button, CircularProgress, Grid, Typography } from '@mui/material' +import { Box, Button, CircularProgress, Grid } from '@mui/material' import theme from 'common/theme' import Google from 'common/icons/Google' -import { routes } from 'common/routes' -import EmailField from 'components/common/form/EmailField' import PasswordField from 'components/common/form/PasswordField' -import LinkButton from 'components/common/LinkButton' -import { StepsContext } from './helpers/stepperContext' +import EmailField from 'components/common/form/EmailField' +import { useDonationFlow } from 'components/client/donation-flow/contexts/DonationFlowProvider' +import { + DonationFormAuthState, + DonationFormData, +} from 'components/client/donation-flow/helpers/types' import { AlertStore } from 'stores/AlertStore' -import { useCurrentPerson } from 'common/util/useCurrentPerson' +import { routes } from 'common/routes' +import { ids } from '../../common/DonationFormSections' -const onGoogleLogin = () => signIn('google') +export const initialLoginFormValues = { + loginEmail: '', + loginPassword: '', +} -function LoginForm() { - const { t } = useTranslation('one-time-donation') +export const loginValidation = { + loginEmail: yup.string().when('authentication', { + is: DonationFormAuthState.LOGIN, + then: yup.string().email('donation-flow:general.error.email').required(), + }), + loginPassword: yup.string().when('authentication', { + is: DonationFormAuthState.LOGIN, + then: yup.string().required(), + }), +} +function InlineLoginForm() { + const { t } = useTranslation('donation-flow') const [loading, setLoading] = useState(false) - const { setStep } = useContext(StepsContext) - const formik = useFormikContext() - const { refetch } = useCurrentPerson() + const { values, setFieldValue } = useFormikContext() + const { campaign } = useDonationFlow() + const onGoogleLogin = () => { + signIn('google', { callbackUrl: routes.campaigns.oneTimeDonation(campaign.slug) }) + } const onClick = async () => { try { setLoading(true) const resp = await signIn<'credentials'>('credentials', { - email: formik.values.loginEmail, - password: formik.values.loginPassword, + email: values.loginEmail, + password: values.loginPassword, redirect: false, }) if (resp?.error) { throw new Error(resp.error) } if (resp?.ok) { - refetch() setLoading(false) - formik.setFieldValue('isAnonymous', false) - setStep(2) + setFieldValue('isAnonymous', false) AlertStore.show(t('auth:alerts.welcome'), 'success') } } catch (error) { - console.error(error) setLoading(false) AlertStore.show(t('auth:alerts.invalid-login'), 'error') } } return ( - + - - {t('second-step.login')} - - - - + - - - {t('auth:account.forgotten-password')} - - - - - - {isSubmitting ? 'Потвърждение' : isLastStep() ? t('btns.end') : t('btns.next')} - - - - )} - - )} - - ) -} diff --git a/src/components/client/one-time-donation/LoggedUserDialog.tsx b/src/components/client/one-time-donation/LoggedUserDialog.tsx deleted file mode 100644 index afda80c86..000000000 --- a/src/components/client/one-time-donation/LoggedUserDialog.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import React from 'react' -import { useSession } from 'next-auth/react' -import { useTranslation } from 'next-i18next' -import { Grid, Typography } from '@mui/material' -import theme from 'common/theme' - -function LoggedUserDialog() { - const { t } = useTranslation('one-time-donation') - const { data: session } = useSession() - - return ( - - - - {t('second-step.logged-user')} - - - - {session && session.user ? ( - - {t('second-step.info-logged-user', { - fullName: session.user.name, - email: session.user.email, - })} - - ) : ( - '' - )} - - - ) -} - -export default LoggedUserDialog diff --git a/src/components/client/one-time-donation/OneTimeDonationPage/OneTimeDonation.styles.tsx b/src/components/client/one-time-donation/OneTimeDonationPage/OneTimeDonation.styles.tsx deleted file mode 100644 index 7a4e15b2a..000000000 --- a/src/components/client/one-time-donation/OneTimeDonationPage/OneTimeDonation.styles.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import Image from 'next/image' - -import { Grid } from '@mui/material' -import { styled } from '@mui/material/styles' - -import theme from 'common/theme' - -export const BeneficiaryAvatarWrapper = styled(Grid)(() => ({ - textAlign: 'center', - padding: theme.spacing(2, 0, 4, 0), - - [theme.breakpoints.up('md')]: { - paddingTop: theme.spacing(0), - }, -})) - -export const BeneficiaryAvatar = styled(Image)(() => ({ - borderRadius: '50%', -})) - -export const StepperWrapper = styled(Grid)(() => ({ - gap: theme.spacing(2), - display: 'grid', -})) diff --git a/src/components/client/one-time-donation/OneTimeDonationPage/OneTimeDonationPage.tsx b/src/components/client/one-time-donation/OneTimeDonationPage/OneTimeDonationPage.tsx deleted file mode 100644 index 253f45501..000000000 --- a/src/components/client/one-time-donation/OneTimeDonationPage/OneTimeDonationPage.tsx +++ /dev/null @@ -1,80 +0,0 @@ -import Link from 'next/link' - -import { Grid, Typography } from '@mui/material' - -import theme from 'common/theme' -import { routes } from 'common/routes' -import { beneficiaryCampaignPictureUrl } from 'common/util/campaignImageUrls' -import Layout from 'components/client/layout/Layout' -import { useViewCampaign } from 'common/hooks/campaigns' -import CenteredSpinner from 'components/common/CenteredSpinner' -import dynamic from 'next/dynamic' - -import { - BeneficiaryAvatar, - BeneficiaryAvatarWrapper, - StepperWrapper, -} from './OneTimeDonation.styles' - -// import RadioAccordionGroup, { testRadioOptions } from 'components/donation-flow/common/RadioAccordionGroup' -// import RadioCardGroup, { testRadioOptions } from 'components/donation-flow/common/RadioCardGroup' -// import PaymentDetailsStripeForm from 'components/admin/donations/stripe/PaymentDetailsStripeForm' - -const scrollWindow = () => { - window.scrollTo({ top: 200, behavior: 'smooth' }) -} - -const DonationStepper = dynamic(() => import('../Steps'), { ssr: false }) - -export default function OneTimeDonation({ slug }: { slug: string }) { - const { data, isLoading } = useViewCampaign(slug) - // const paymentIntentMutation = useCreatePaymentIntent({ - // amount: 100, - // currency: 'BGN', - // }) - // useEffect(() => { - // paymentIntentMutation.mutate() - // }, []) - if (isLoading || !data) return - - const { campaign } = data - - const beneficiaryAvatarSource = beneficiaryCampaignPictureUrl(campaign) - - return ( - - - - - - - - - {campaign.title} - - - {/* {paymentIntentMutation.isLoading ? ( - - ) : ( - - )} */} - - {/* */} - {/* */} - - - - ) -} diff --git a/src/components/client/one-time-donation/Steps.tsx b/src/components/client/one-time-donation/Steps.tsx deleted file mode 100644 index bdce586c1..000000000 --- a/src/components/client/one-time-donation/Steps.tsx +++ /dev/null @@ -1,187 +0,0 @@ -import * as React from 'react' -import { useTranslation } from 'next-i18next' -import { useRouter } from 'next/router' -import { useSession } from 'next-auth/react' -import { CircularProgress } from '@mui/material' -import { AxiosError } from 'axios' -import { FormikHelpers } from 'formik' - -import { CardRegion, DonationType, PaymentProvider } from 'gql/donations.enums' -import { OneTimeDonation, DonationStep as StepType } from 'gql/donations' -import { createDonationWish } from 'service/donationWish' -import { ApiErrors, isAxiosError, matchValidator } from 'service/apiErrors' -import { useCurrentPerson } from 'common/util/useCurrentPerson' -import CenteredSpinner from 'components/common/CenteredSpinner' -import { useDonationSession } from 'common/hooks/donation' -import { useViewCampaign } from 'common/hooks/campaigns' -import { baseUrl, routes } from 'common/routes' - -import FirstStep from './steps/FirstStep' -import SecondStep from './steps/SecondStep' -import ThirdStep from './steps/ThirdStep' -import Success from './steps/Success' -import Fail from './steps/Fail' -import { FormikStep, FormikStepper } from './FormikStepper' -import { validateFirst, validateSecond, validateThird } from './helpers/validation-schema' -import { StepsContext } from './helpers/stepperContext' -import { useDonationStepSession } from './helpers/donateSession' - -const initialValues: OneTimeDonation = { - type: DonationType.donation, - message: '', - isAnonymous: false, - amount: '', - amountWithFees: 0, - cardIncludeFees: false, - cardRegion: CardRegion.EU, - otherAmount: 0, - personsFirstName: '', - personsLastName: '', - personsEmail: '', - personsPhone: '', - payment: 'card', - loginEmail: '', - loginPassword: '', - registerEmail: '', - registerLastName: '', - registerFirstName: '', - registerPassword: '', - confirmPassword: '', - isRecurring: false, - terms: false, - gdpr: false, - newsletter: false, -} -interface DonationStepperProps { - onStepChange: () => void -} - -export default function DonationStepper({ onStepChange }: DonationStepperProps) { - const { t, i18n } = useTranslation('one-time-donation') - const router = useRouter() - const success = router.query.success === 'true' ? true : false - initialValues.amount = (router.query.price as string) || '' - const slug = String(router.query.slug) - const { data, isLoading } = useViewCampaign(slug) - const mutation = useDonationSession() - const { data: session } = useSession() - const { data: { user: person } = { user: null } } = useCurrentPerson() - const [donateSession, { updateDonationSession, clearDonationSession }] = - useDonationStepSession(slug) - if (isLoading || !data) return - const { campaign } = data - - initialValues.isRecurring = false - - const userEmail = session?.user?.email - const donate = React.useCallback( - async (amount?: number, values?: OneTimeDonation) => { - const { data } = await mutation.mutateAsync({ - type: person?.company ? DonationType.corporate : DonationType.donation, - mode: values?.isRecurring ? 'subscription' : 'payment', - amount, - campaignId: campaign.id, - personId: person ? person?.id : '', - firstName: values?.personsFirstName ? values.personsFirstName : 'Anonymous', - lastName: values?.personsLastName ? values.personsLastName : 'Donor', - personEmail: values?.personsEmail ? values.personsEmail : userEmail, - isAnonymous: values?.isAnonymous !== undefined ? values.isAnonymous : true, - phone: values?.personsPhone ? values.personsPhone : null, - successUrl: `${baseUrl}/${i18n?.language}/${routes.campaigns.oneTimeDonation( - campaign.slug, - )}?success=true`, - cancelUrl: `${baseUrl}/${i18n?.language}/${routes.campaigns.oneTimeDonation( - campaign.slug, - )}?success=false`, - message: values?.message, - }) - if (values?.payment === PaymentProvider.bank) { - // Do not redirect for bank payments - return - } - if (data.session.url) { - //send the user to payment provider - window.location.href = data.session.url - } - }, - [mutation, session, person], - ) - - const onSubmit = async ( - values: OneTimeDonation, - { setFieldError, resetForm }: FormikHelpers, - ) => { - try { - if (values?.payment === PaymentProvider.bank) { - if (values?.message) { - await createDonationWish({ - message: values.message, - campaignId: campaign.id, - personId: !values.isAnonymous && person?.id ? person.id : null, - }) - } - router.push(`${baseUrl}${routes.campaigns.oneTimeDonation(campaign.slug)}?success=true`) - return - } - - const data = { - currency: campaign.currency, - amount: Math.round(values.amountWithFees), - } - await donate(data.amount, values) - resetForm() - } catch (error) { - if (isAxiosError(error)) { - const { response } = error as AxiosError - response?.data.message.map(({ property, constraints }) => { - setFieldError(property, t(matchValidator(constraints))) - }) - } - } - } - const steps: StepType[] = [ - { - label: 'amount', - component: , - validate: validateFirst, - }, - { - label: 'personal-profile', - component: , - validate: validateSecond, - }, - { - label: 'wish', - component: , - validate: validateThird, - }, - { - label: 'payment', - component: success ? : , - validate: null, - }, - ] - - const [step, setStep] = React.useState(donateSession?.step ?? 0) - - React.useEffect(() => { - onStepChange() - }, [step]) - - return ( - - {isLoading ? ( - - ) : ( - - {steps.map(({ label, component, validate }) => ( - - {component} - - ))} - - )} - - ) -} diff --git a/src/components/client/one-time-donation/helpers/paypalDonationButton.tsx b/src/components/client/one-time-donation/helpers/paypalDonationButton.tsx deleted file mode 100644 index 063bd685d..000000000 --- a/src/components/client/one-time-donation/helpers/paypalDonationButton.tsx +++ /dev/null @@ -1,88 +0,0 @@ -import { useEffect } from 'react' - -import { PayPalButtons, usePayPalScriptReducer } from '@paypal/react-paypal-js' -import { Box } from '@mui/material' -import { AlertStore } from 'stores/AlertStore' -import { useTranslation } from 'next-i18next' - -export type PaypalDonationButtonOptions = { - campaignId: string - amount: number - currency: string -} - -// Custom component to wrap the PayPalButtons and handle amount¤cy changes -export default function PaypalDonationButton({ - campaignId, - amount, - currency, -}: PaypalDonationButtonOptions) { - // usePayPalScriptReducer can be used only inside children of PayPalScriptProviders - // This is the main reason to wrap the PayPalButtons in a new component - const [{ options, isPending }, dispatch] = usePayPalScriptReducer() - - const { t } = useTranslation('one-time-donation') - - useEffect(() => { - dispatch({ - type: 'resetOptions', - value: { - ...options, - currency: currency, - }, - }) - }, [amount, currency]) - - return ( - - {isPending ?
: null} - { - return actions.order.create({ - purchase_units: [ - { - amount: { - value: amount.toString(), - currency_code: currency, - breakdown: { - item_total: { - currency_code: currency, - value: amount.toString(), - }, - }, - }, - custom_id: campaignId, // Paypal will send this in the webhook too - description: 'donation for campaign: ' + campaignId, - items: [ - { - category: 'DONATION', - name: 'Име на кампания', - description: 'Дарение за кампания', - quantity: '1', - unit_amount: { - currency_code: currency, - value: amount.toString(), - }, - }, - ], - }, - ], - }) - }} - onApprove={(data, actions) => { - if (actions.order) { - return actions.order.capture().then(() => { - AlertStore.show(t('alerts.success'), 'success') - }) - } else { - return new Promise(() => { - AlertStore.show(t('alerts.error'), 'error') - }) - } - }} - /> - - ) -} diff --git a/src/components/client/one-time-donation/helpers/stepperContext.ts b/src/components/client/one-time-donation/helpers/stepperContext.ts deleted file mode 100644 index 7477c3ba3..000000000 --- a/src/components/client/one-time-donation/helpers/stepperContext.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { createContext } from 'react' -import { CampaignResponse } from 'gql/campaigns' -import { OneTimeDonation } from 'gql/donations' - -type Steps = { - step: number - setStep: React.Dispatch> - campaign: CampaignResponse - updateDonationSession: (value: OneTimeDonation, step: number) => void - clearDonationSession: () => void -} -export const StepsContext = createContext({} as Steps) diff --git a/src/components/client/one-time-donation/helpers/validation-schema.ts b/src/components/client/one-time-donation/helpers/validation-schema.ts deleted file mode 100644 index b2c20e1d8..000000000 --- a/src/components/client/one-time-donation/helpers/validation-schema.ts +++ /dev/null @@ -1,44 +0,0 @@ -import * as yup from 'yup' -import { name, phone, email, password } from 'common/form/validation' -import { FirstStep, SecondStep, ThirdStep } from 'gql/donations' - -export const validateFirst: yup.SchemaOf = yup - .object() - .defined() - .shape({ - payment: yup.string().required().oneOf(['card', 'bank']), - amount: yup.string().when('payment', { - is: 'card', - // Here we should fetch the possible payments to put into the oneOf, but it's not that important - then: yup.string().required(), - }), - otherAmount: yup - .number() - .integer() - .when('amount', { - is: 'other', - then: yup.number().min(1, 'one-time-donation:errors-fields.other-amount').required(), - }), - }) - -export const validateSecond: yup.SchemaOf = yup.object().defined().shape({ - isAnonymous: yup.boolean().required(), - personsEmail: email.notRequired(), - personsFirstName: name.notRequired(), - personsLastName: name.notRequired(), - personsPhone: phone.notRequired(), - loginEmail: email.notRequired(), - loginPassword: password.notRequired(), - registerEmail: email.notRequired(), - registerFirstName: yup.string().notRequired(), - registerLastName: yup.string().notRequired(), - registerPassword: password.notRequired(), - confirmPassword: yup.string().notRequired(), - terms: yup.boolean().notRequired(), - gdpr: yup.boolean().notRequired(), - newsletter: yup.boolean().notRequired(), -}) - -export const validateThird: yup.SchemaOf = yup.object().defined().shape({ - message: yup.string().notRequired(), -}) diff --git a/src/components/client/one-time-donation/steps/Fail.tsx b/src/components/client/one-time-donation/steps/Fail.tsx deleted file mode 100644 index 86e92d0ef..000000000 --- a/src/components/client/one-time-donation/steps/Fail.tsx +++ /dev/null @@ -1,62 +0,0 @@ -import { useContext, useEffect } from 'react' -import { useTranslation } from 'next-i18next' -import { useRouter } from 'next/router' -import { Grid, Typography, Button } from '@mui/material' -import ErrorOutlineIcon from '@mui/icons-material/ErrorOutline' -import theme from 'common/theme' -import { routes } from 'common/routes' -import LinkButton from 'components/common/LinkButton' -import { StepsContext } from '../helpers/stepperContext' - -type Props = { - campaignSlug: string -} -export default function Fail({ campaignSlug }: Props) { - const { t } = useTranslation('one-time-donation') - const { setStep } = useContext(StepsContext) - const router = useRouter() - // Clear query so that the first step renders instead of success or fail page - useEffect(() => { - router.push(`${router.asPath.split('?')[0]}`) - }, []) - return ( - - - - - - - - {t('fail.title')} - - - - - - - - - - {t('fail.btn-back-to-campaign')} - - - - - {t('fail.btn-connect')} - - - - - ) -} diff --git a/src/components/client/one-time-donation/steps/FirstStep.tsx b/src/components/client/one-time-donation/steps/FirstStep.tsx deleted file mode 100644 index 9ce9174b7..000000000 --- a/src/components/client/one-time-donation/steps/FirstStep.tsx +++ /dev/null @@ -1,365 +0,0 @@ -import React, { useContext, useEffect } from 'react' -import { useSession } from 'next-auth/react' -import { Trans, useTranslation } from 'next-i18next' -import getConfig from 'next/config' -import dynamic from 'next/dynamic' -import { useField, useFormikContext } from 'formik' -import { OneTimeDonation } from 'gql/donations' -import { CardRegion } from 'gql/donations.enums' - -import { PayPalScriptProvider } from '@paypal/react-paypal-js' -import { Box, Collapse, Divider, Fade, Grid, List, Typography } from '@mui/material' -import EventRepeatIcon from '@mui/icons-material/EventRepeat' -import { useMediaQuery } from '@mui/material' - -import theme from 'common/theme' -import { moneyPublic, moneyPublicDecimals2, toMoney } from 'common/util/money' -import { BIC, ibanNumber } from 'common/iban' -import { isAdmin } from 'common/util/roles' -import RadioButtonGroup from 'components/common/form/RadioButtonGroup' -import { CopyTextButton } from 'components/common/CopyTextButton' -import ExternalLink from 'components/common/ExternalLink' -import CheckboxField from 'components/common/form/CheckboxField' -import FormSelectField from 'components/common/form/FormSelectField' -import NumberInputField from 'components/common/form/NumberInputField' -import { StepsContext } from '../helpers/stepperContext' -import { stripeFeeCalculator, stripeIncludeFeeCalculator } from '../helpers/stripe-fee-calculator' - -import { BankDetailsLabel } from 'components/client/support-us-form/SupportUs.styled' -import Link from 'next/link' - -const PaypalDonationButton = dynamic(() => import('../helpers/paypalDonationButton'), { - ssr: false, -}) - -export default function FirstStep() { - const { data: session } = useSession() - const { t } = useTranslation('one-time-donation') - const mobile = useMediaQuery('(max-width:600px)') - const paymentOptions = [ - { value: 'card', label: t('third-step.card') }, - { value: 'paypal', label: 'PayPal', hidden: !isAdmin(session) }, - { value: 'bank', label: t('third-step.bank-payment') }, - ] - - const [paymentField] = useField('payment') - const [amount] = useField('amount') - const [amountWithFees] = useField('amountWithFees') - const [amountWithoutFees] = useField('amountWithoutFees') - - const formik = useFormikContext() - - //Stripe allows up to $1M for a single transaction. This is close enough - const STRIPE_LIMIT_BGN = 1500000 - - //In best case Paypal allows up to $25k per transaction - const PAYPAL_LIMIT_BGN = 40000 - - const { campaign } = useContext(StepsContext) - - const oneTimePrices = - campaign.slug === 'petar-v-cambridge' //needed specific prices for this campaign - ? [2000, 5000, 10000, 20000, 50000, 100000] //TODO: move this to camapign specific config in db - : [1000, 2000, 5000, 10000, 50000, 100000] //these are default values for all other campaigns - - const bankAccountInfo = { - owner: t('third-step.owner'), - bank: t('third-step.bank'), - iban: ibanNumber, - bic: BIC, - } - - useEffect(() => { - if ( - (amount.value == 'other' || paymentField.value === 'paypal') && - formik.values.otherAmount === 0 - ) { - formik.setFieldValue('otherAmount', 1) - formik.setFieldTouched('otherAmount', true) - return - } - - const chosenAmount = - amount.value === 'other' ? toMoney(formik.values.otherAmount) : Number(formik.values.amount) - - if (formik.values.cardIncludeFees) { - formik.setFieldValue('amountWithoutFees', chosenAmount) - formik.setFieldValue( - 'amountWithFees', - stripeIncludeFeeCalculator(chosenAmount, formik.values.cardRegion), - ) - } else { - formik.setFieldValue( - 'amountWithoutFees', - chosenAmount - stripeFeeCalculator(chosenAmount, formik.values.cardRegion), - ) - formik.setFieldValue('amountWithFees', chosenAmount) - } - }, [ - formik.values.otherAmount, - formik.values.amount, - formik.values.cardIncludeFees, - formik.values.cardRegion, - formik.values.isRecurring, - paymentField.value, - ]) - - return ( - - {t('third-step.title')} - - option.hidden != true).length} - options={paymentOptions} - /> - - - - - {t('third-step.bank-details')} - - - {t('third-step.bank-instructions1')} - - - {t('third-step.bank-instructions2')} - - - - - {t('third-step.owner_name')} - - - {t('third-step.owner_value')} - - - - - - {t('third-step.bank_name')} - - - {t('third-step.bank_value')} - - - - - - IBAN: - - - {ibanNumber} - - - - - - BIC: - - - {BIC} - - - - - - {t('third-step.reason-donation')} - - - - {campaign.paymentReference} - - - - - - - - {t('third-step.message-warning')} - - - - - {t('third-step.card-fees')} - - - - {t('first-step.amount')} - - - ({ - label: moneyPublic(Number(v), undefined, undefined, 0, 0), //show amounts as integer - value: String(Number(v)), - })) - .concat({ - label: t('first-step.other'), - value: 'other', - hidden: amount.value === 'other', - } as { label: string; value: string; hidden?: boolean }) || [] - } - /> - - - - - - {amount.value ? ( - - - - {t('third-step.card-include-fees')} - } - /> - - - - - - , - }} - /> - - - - {t('third-step.recurring-donation-title')} - - - - - - ), - }} - /> - } - /> - - - - ) : null} - - - - - - - - Note 1: This is a test Paypal implementation visible only to logged users with admin - rights. Using real cards will not charge any money. Note 2: Paypal transaction fee - is 3.4% + 0.35 euro cents. - - - - - - - - - - - - - - - ) -} diff --git a/src/components/client/one-time-donation/steps/SecondStep.tsx b/src/components/client/one-time-donation/steps/SecondStep.tsx deleted file mode 100644 index 6d7fcbeed..000000000 --- a/src/components/client/one-time-donation/steps/SecondStep.tsx +++ /dev/null @@ -1,70 +0,0 @@ -import { TabContext, TabList } from '@mui/lab' -import TabPanel from '@mui/lab/TabPanel' -import { Box, Tab, Typography, useMediaQuery } from '@mui/material' -import { OneTimeDonation } from 'gql/donations' -import { useSession } from 'next-auth/react' -import { useTranslation } from 'next-i18next' -import React, { useState } from 'react' -import AnonymousMenu from '../AnonymousForm' -import LoggedUserDialog from '../LoggedUserDialog' -import LoginForm from '../LoginForm' -import RegisterForm from '../RegisterDialog' -import { useFormikContext } from 'formik' - -enum Tabs { - Login = '1', - Register = '2', - Anonymous = '3', -} -export default function SecondStep() { - const { t } = useTranslation('one-time-donation') - const mobile = useMediaQuery('(max-width:575px)') - const { data: session } = useSession() - - const formik = useFormikContext() - const [value, setValue] = useState(formik.values.isAnonymous ? '3' : '1') - const handleChange = (event: React.SyntheticEvent, newTab: string) => { - if (newTab === Tabs.Anonymous) { - formik.setFieldValue('isAnonymous', true) - } else { - formik.setFieldValue('isAnonymous', false) - } - setValue(newTab) - } - - return ( - - {t('step-labels.personal-profile')} - {t('second-step.intro-text')} - - - - - {formik.values.isRecurring ? null : ( - - )} - - - - {session && session.accessToken ? : } - - - - - {formik.values.isRecurring ? null : ( - - - - )} - - ) -} diff --git a/src/components/client/one-time-donation/steps/Success.tsx b/src/components/client/one-time-donation/steps/Success.tsx deleted file mode 100644 index 3b15f6515..000000000 --- a/src/components/client/one-time-donation/steps/Success.tsx +++ /dev/null @@ -1,74 +0,0 @@ -import { useTranslation } from 'next-i18next' -import { Grid, Typography } from '@mui/material' -import CheckCircleOutlineIcon from '@mui/icons-material/CheckCircleOutline' -import { routes } from 'common/routes' -import LinkButton from 'components/common/LinkButton' -import ExternalLinkButton from 'components/common/ExternalLinkButton' -import { useField } from 'formik' -import { PaymentProvider } from 'gql/donations.enums' - -type Props = { - campaignSlug: string - donationId?: string -} -export default function Success({ campaignSlug, donationId }: Props) { - const { t } = useTranslation('one-time-donation') - const [field] = useField('payment') - return ( - - - - - - - - {(field.value === PaymentProvider.bank && t('success.title-bank')) || - t('success.title')} - - - - - {(field.value === PaymentProvider.bank && t('success.subtitle-bank')) || - t('success.subtitle')} - - - - {t('success.share-to')} - - - {t('success.say-to-us')} - - - - {donationId && ( - - - {t('success.btn-generate')} - - - )} - - - {t('success.btn-back-to-campaign')} - - - - - {t('success.btn-say-to-us')} - - - - - {t('success.btn-other-campaign')} - - - - - ) -} diff --git a/src/components/client/one-time-donation/steps/ThirdStep.tsx b/src/components/client/one-time-donation/steps/ThirdStep.tsx deleted file mode 100644 index 750890298..000000000 --- a/src/components/client/one-time-donation/steps/ThirdStep.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import { useTranslation } from 'next-i18next' -import { Grid, Typography } from '@mui/material' -import theme from 'common/theme' -import FormTextField from 'components/common/form/FormTextField' - -export default function ThirdStep() { - const { t } = useTranslation('one-time-donation') - return ( - - - - {t('first-step.wish')} - - - - - - - ) -} diff --git a/src/components/client/support-us-form/SupportUsForm.tsx b/src/components/client/support-us-form/SupportUsForm.tsx index b789bb30f..ec10ff96d 100644 --- a/src/components/client/support-us-form/SupportUsForm.tsx +++ b/src/components/client/support-us-form/SupportUsForm.tsx @@ -8,7 +8,7 @@ import { ibanNumber, BIC } from 'common/iban' import { BankDetailsLabel } from './SupportUs.styled' export default function SupportUsForm() { - const { t } = useTranslation('one-time-donation') + const { t } = useTranslation('donation-flow') const bankAccountInfo = { owner: t('third-step.owner_name'), @@ -67,7 +67,7 @@ export default function SupportUsForm() { {confirmButtonLabel} - diff --git a/src/components/common/LinkButton.tsx b/src/components/common/LinkButton.tsx index b55fca589..4ef53939a 100644 --- a/src/components/common/LinkButton.tsx +++ b/src/components/common/LinkButton.tsx @@ -19,7 +19,7 @@ const LinkButton = ( tabIndex={disabled ? -1 : 0} legacyBehavior={legacyBehavior} style={{ pointerEvents: disabled ? 'none' : 'all' }}> - + + + { + navigator.clipboard.writeText(url) + AlertStore.show('Campaign link copied to clipboard', 'success') + setAnchorEl(null) + }}> + {t('components.social-share.copy')} + + + + {t('components.social-share.share')} Facebook + + + + {t('components.social-share.share')} LinkedIn + + + + {t('components.social-share.share')} Twitter + + + + + + ) +} diff --git a/src/components/common/form/AcceptPrivacyPolicyField.tsx b/src/components/common/form/AcceptPrivacyPolicyField.tsx index 5448a1123..8da3a3bb1 100644 --- a/src/components/common/form/AcceptPrivacyPolicyField.tsx +++ b/src/components/common/form/AcceptPrivacyPolicyField.tsx @@ -7,13 +7,18 @@ import CheckboxField from 'components/common/form/CheckboxField' export type AcceptGDPRFieldProps = { name: string + showFieldError?: boolean } -export default function AcceptPrivacyPolicyField({ name }: AcceptGDPRFieldProps) { +export default function AcceptPrivacyPolicyField({ + name, + showFieldError = true, +}: AcceptGDPRFieldProps) { const { t } = useTranslation() return ( {t('validation:informed-agree-with')}{' '} diff --git a/src/components/common/form/CheckboxField.tsx b/src/components/common/form/CheckboxField.tsx index 1bc9e8978..f93589058 100644 --- a/src/components/common/form/CheckboxField.tsx +++ b/src/components/common/form/CheckboxField.tsx @@ -10,6 +10,7 @@ import { Tooltip, SxProps, Theme, + CheckboxProps, } from '@mui/material' import { TranslatableField, translateError } from 'common/form/validation' @@ -21,6 +22,8 @@ export type CheckboxFieldProps = { onChange?: (e: ChangeEvent) => void label: string | number | React.ReactElement disabledTooltip?: string + checkboxProps?: CheckboxProps + showFieldError?: boolean } export default function CheckboxField({ @@ -30,12 +33,18 @@ export default function CheckboxField({ onChange: handleChange, label, disabledTooltip, + checkboxProps, + showFieldError, }: CheckboxFieldProps) { const { t } = useTranslation() const [field, meta] = useField(name) const helperText = meta.touched ? translateError(meta.error as TranslatableField, t) : '' + const showError = + typeof showFieldError !== undefined + ? showFieldError + : Boolean(meta.error) && Boolean(meta.touched) return ( - + { field.onChange(e) if (handleChange) handleChange(e) @@ -54,10 +64,8 @@ export default function CheckboxField({ } /> - {Boolean(meta.error) && ( - - {helperText} - + {showFieldError === undefined && Boolean(meta.error) && ( + {helperText} )} ) diff --git a/src/components/common/form/CircleCheckboxField.tsx b/src/components/common/form/CircleCheckboxField.tsx index 68b9d5d36..4dc731e36 100644 --- a/src/components/common/form/CircleCheckboxField.tsx +++ b/src/components/common/form/CircleCheckboxField.tsx @@ -22,7 +22,7 @@ export type CircleCheckboxField = { } export default function CircleCheckboxField({ name, label, labelProps }: CircleCheckboxField) { - const { t } = useTranslation('one-time-donation') + const { t } = useTranslation() const [field, meta] = useField(name) const helperText = meta.touched ? translateError(meta.error as TranslatableField, t) : '' return ( diff --git a/src/components/common/form/NumberInputField.tsx b/src/components/common/form/NumberInputField.tsx index 47de14b6b..6e1efe72d 100644 --- a/src/components/common/form/NumberInputField.tsx +++ b/src/components/common/form/NumberInputField.tsx @@ -16,7 +16,7 @@ export default function NumberInputField({ name = 'otherAmount', limit = Number.MAX_SAFE_INTEGER, }: Props) { - const { t, i18n } = useTranslation('one-time-donation') + const { t, i18n } = useTranslation('donation-flow') const [, meta, { setValue, setError }] = useField(name) useEffect(() => { @@ -28,9 +28,12 @@ export default function NumberInputField({ name={name} type="number" value={meta.value === 1 ? '' : meta.value} - label={t('first-step.amount')} + label={t('step.amount.field.other-amount.label')} lang={i18n?.language} onKeyDown={(e) => { + if ((e.shiftKey && e.key === 'Tab') || e.key === 'Tab') { + return + } if (meta.error && e.key !== 'Backspace' && e.key !== 'Delete' && !isInteger(meta.value)) { e.preventDefault() return @@ -79,11 +82,15 @@ export default function NumberInputField({ onChange={(e) => { const amount = e.target.value if (isNaN(Number(amount))) { - setError(t('first-step.only-numbers')) + setError(t('step.amount.field.other-amount.only-numbers')) return } if (Number(amount) > limit) { - setError(t('first-step.transaction-limit', { limit: moneyPublic(limit, 'BGN', 1) })) + setError( + t('step.amount.field.other-amount.transaction-limit', { + limit: moneyPublic(limit, 'BGN', 1), + }), + ) return } else if (Number(amount) < 1) { setValue(1) @@ -97,11 +104,12 @@ export default function NumberInputField({ inputProps: { max: limit, inputMode: 'decimal', + tabIndex: 0, }, style: { padding: 5 }, endAdornment: ( - {t('first-step.BGN')} + {t('step.amount.field.other-amount.currency')} ), }} diff --git a/src/components/common/form/RadioButton.tsx b/src/components/common/form/RadioButton.tsx index c7fa3adbf..f630754dc 100644 --- a/src/components/common/form/RadioButton.tsx +++ b/src/components/common/form/RadioButton.tsx @@ -12,12 +12,19 @@ const classes = { circle: `${PREFIX}-circle`, checkedCircle: `${PREFIX}-checkedCircle`, label: `${PREFIX}-label`, + disabled: `${PREFIX}-disabled`, + error: `${PREFIX}-error`, + checkIcon: `${PREFIX}-checkIcon`, } -const StyledRadioButton = styled('div')(() => ({ +type StyledRadioWrapperProps = { + error?: boolean +} + +const StyledRadioButton = styled('div')(({ error }) => ({ [`& .${classes.radioWrapper}`]: { borderRadius: theme.borders.round, - border: `1px solid ${theme.borders.dark}`, + border: `1px solid ${error ? theme.palette.error.main : theme.borders.dark}`, padding: 0, width: '100%', margin: '0 auto', @@ -31,8 +38,17 @@ const StyledRadioButton = styled('div')(() => ({ [`& .${classes.circle}`]: { width: 30, height: 30, - border: `1px solid ${theme.palette.primary.dark}`, + border: `1px solid ${error ? theme.palette.error.main : theme.palette.primary.dark}`, + borderRadius: theme.borders.round, + }, + + [`& .${classes.checkIcon}`]: { + width: 30, + height: 30, + border: `1px solid ${error ? theme.palette.error.main : theme.palette.primary.main}`, + backgroundColor: theme.palette.primary.main, borderRadius: theme.borders.round, + color: theme.palette.common.white, }, [`& .${classes.label}`]: { @@ -48,12 +64,15 @@ type RadioButtonProps = { checked: boolean label: string value: string | number + disabled?: boolean + loading?: boolean muiRadioButtonProps?: Partial + error?: boolean } -function RadioButton({ checked, label, muiRadioButtonProps, value }: RadioButtonProps) { +function RadioButton({ checked, label, muiRadioButtonProps, value, error }: RadioButtonProps) { return ( - + } + icon={
} checkedIcon={ - + } {...muiRadioButtonProps} /> diff --git a/src/components/common/form/RadioButtonGroup.tsx b/src/components/common/form/RadioButtonGroup.tsx index 7c9ffb357..893554501 100644 --- a/src/components/common/form/RadioButtonGroup.tsx +++ b/src/components/common/form/RadioButtonGroup.tsx @@ -21,6 +21,8 @@ export type RadioButtonGroupOptions = { export type RadioButtonGroup = { name: string options: RadioButtonGroupOptions[] + disabled?: boolean + loading?: boolean columns?: number muiRadioGroupProps?: Partial /** @@ -31,24 +33,27 @@ export type RadioButtonGroup = { * */ muiRadioButtonGridProps?: Partial + ref?: React.RefObject + error?: boolean } export default function RadioButtonGroup({ name, options, + disabled, + loading, columns = 2, muiRadioGroupProps, muiRadioButtonGridProps, + error, }: RadioButtonGroup) { - const { t } = useTranslation('one-time-donation') + const { t } = useTranslation() const [field, meta, { setValue }] = useField(name) const helperText = meta.touched ? translateError(meta.error as TranslatableField, t) : '' + const showError = + typeof error !== undefined ? Boolean(error) : Boolean(meta.error) && Boolean(meta.touched) return ( - + { setValue(v) @@ -64,6 +69,9 @@ export default function RadioButtonGroup({ !hidden && ( - {Boolean(meta.error) && Boolean(meta.touched) && helperText && ( + {typeof error === undefined && showError && helperText && ( {t(helperText)} )} diff --git a/src/gql/donationWish.d.ts b/src/gql/donationWish.d.ts index 34646f4cb..3c30d144b 100644 --- a/src/gql/donationWish.d.ts +++ b/src/gql/donationWish.d.ts @@ -5,6 +5,7 @@ export type DonationWishInput = { campaignId: UUID personId?: UUID donationId?: UUID + paymentIntentId?: UUID } export type DonationWishResponse = { diff --git a/src/gql/donations.d.ts b/src/gql/donations.d.ts index 4bf58e80e..4f5b9236d 100644 --- a/src/gql/donations.d.ts +++ b/src/gql/donations.d.ts @@ -13,6 +13,44 @@ import { export type DonationPrice = Stripe.Price +export type StripePaymentInput = { + setupIntentId: Stripe.SetupIntent['id'] + firstName: string | null + lastName: string | null + phone: string | null + personEmail: string + isAnonymous: boolean + amount: number +} + +export type SubscriptionPaymentInput = { + type: DonationType + campaignId: string + amount: number + currency: Currency + email?: string +} + +export type UpdatePaymentIntentInput = { + id: string + payload: Stripe.PaymentIntentUpdateParams +} + +export type CancelSetupIntentInput = { + id: string +} + +export type UpdateSetupIntentInput = { + id: string + idempotencyKey: string + payload: Stripe.SetupIntentUpdateParams +} + +export type CancelPaymentIntentInput = { + id: string + payload: Stripe.PaymentIntentCancelParams +} + export type CheckoutSessionResponse = { session: Stripe.Checkout.Session } @@ -181,7 +219,7 @@ export type DonationStep = { } export type FirstStep = { - payment: string + payment?: string amount?: string } diff --git a/src/pages/_app.tsx b/src/pages/_app.tsx index 4b80d4f75..07385cedd 100644 --- a/src/pages/_app.tsx +++ b/src/pages/_app.tsx @@ -109,7 +109,7 @@ function CustomApp({ {/* CssBaseline kickstart an elegant, consistent, and simple baseline to build upon. */} - + diff --git a/src/pages/campaigns/donation/[slug]/index.tsx b/src/pages/campaigns/donation/[slug]/index.tsx new file mode 100644 index 000000000..e76de7f95 --- /dev/null +++ b/src/pages/campaigns/donation/[slug]/index.tsx @@ -0,0 +1,51 @@ +import { GetServerSideProps, GetServerSidePropsContext } from 'next' +import { serverSideTranslations } from 'next-i18next/serverSideTranslations' +import { QueryClient, dehydrate } from '@tanstack/react-query' +import Stripe from 'stripe' +import { AxiosResponse } from 'axios' + +import { apiClient } from 'service/apiClient' +import { endpoints } from 'service/apiEndpoints' +import { queryFnFactory } from 'service/restRequests' +import { CampaignResponse } from 'gql/campaigns' +import DonationFlowPage from 'components/client/donation-flow/DonationFlowPage' + +export const getServerSideProps: GetServerSideProps = async (ctx: GetServerSidePropsContext) => { + const { slug } = ctx.query + const client = new QueryClient() + await client.prefetchQuery( + [endpoints.campaign.viewCampaign(String(slug)).url], + queryFnFactory(), + ) + await client.prefetchQuery( + [endpoints.donation.singlePrices.url], + queryFnFactory(), + ) + + //Generate idempotencyKey to prevent duplicate creation of resources in stripe + const idempotencyKey = crypto.randomUUID() + + //create and prefetch the payment intent + const { data: setupIntent } = await apiClient.post< + Stripe.SetupIntentCreateParams, + AxiosResponse + >(endpoints.donation.createSetupIntent.url, idempotencyKey) + + return { + props: { + slug, + setupIntent, + idempotencyKey, + dehydratedState: dehydrate(client), + ...(await serverSideTranslations(ctx.locale ?? 'bg', [ + 'common', + 'auth', + 'validation', + 'campaigns', + 'donation-flow', + ])), + }, + } +} + +export default DonationFlowPage diff --git a/src/pages/campaigns/donation/[slug].tsx b/src/pages/campaigns/donation/[slug]/status.tsx similarity index 84% rename from src/pages/campaigns/donation/[slug].tsx rename to src/pages/campaigns/donation/[slug]/status.tsx index 32ccde250..14170e3c0 100644 --- a/src/pages/campaigns/donation/[slug].tsx +++ b/src/pages/campaigns/donation/[slug]/status.tsx @@ -1,11 +1,11 @@ -import { QueryClient, dehydrate } from '@tanstack/react-query' import { GetServerSideProps, GetServerSidePropsContext } from 'next' import { serverSideTranslations } from 'next-i18next/serverSideTranslations' +import { QueryClient, dehydrate } from '@tanstack/react-query' -import OneTimeDonation from 'components/client/one-time-donation/OneTimeDonationPage/OneTimeDonationPage' import { endpoints } from 'service/apiEndpoints' import { queryFnFactory } from 'service/restRequests' import { CampaignResponse } from 'gql/campaigns' +import DonationFlowStatusPage from 'components/client/donation-flow/DonationFlowStatusPage' export const getServerSideProps: GetServerSideProps = async (ctx: GetServerSidePropsContext) => { const { slug } = ctx.query @@ -14,6 +14,7 @@ export const getServerSideProps: GetServerSideProps = async (ctx: GetServerSideP [endpoints.campaign.viewCampaign(slug as string).url], queryFnFactory(), ) + return { props: { slug, @@ -23,10 +24,10 @@ export const getServerSideProps: GetServerSideProps = async (ctx: GetServerSideP 'auth', 'validation', 'campaigns', - 'one-time-donation', + 'donation-flow', ])), }, } } -export default OneTimeDonation +export default DonationFlowStatusPage diff --git a/src/pages/support_us.tsx b/src/pages/support_us.tsx index eaec24f59..20e4f27af 100644 --- a/src/pages/support_us.tsx +++ b/src/pages/support_us.tsx @@ -5,11 +5,7 @@ import SupportUsFormPage from 'components/client/support-us-form/SupportUsPage' export const getStaticProps: GetStaticProps = async ({ locale }) => ({ props: { - ...(await serverSideTranslations(locale ?? 'bg', [ - 'common', - 'support_us', - 'one-time-donation', - ])), + ...(await serverSideTranslations(locale ?? 'bg', ['common', 'support_us', 'donation-flow'])), }, }) diff --git a/src/service/apiEndpoints.ts b/src/service/apiEndpoints.ts index b4fad72e2..f578b03c6 100644 --- a/src/service/apiEndpoints.ts +++ b/src/service/apiEndpoints.ts @@ -146,11 +146,33 @@ export const endpoints = { }, }, donation: { - prices: { url: '/donation/prices', method: 'GET' }, - singlePrices: { url: '/donation/prices/single', method: 'GET' }, - recurringPrices: { url: '/donation/prices/recurring', method: 'GET' }, - createCheckoutSession: { url: '/donation/create-checkout-session', method: 'POST' }, - createPaymentIntent: { url: '/donation/create-payment-intent', method: 'POST' }, + prices: { url: '/stripe/prices', method: 'GET' }, + singlePrices: { url: '/stripe/prices/single', method: 'GET' }, + recurringPrices: { url: '/stripe/prices/recurring', method: 'GET' }, + createCheckoutSession: { url: '/stripe/create-checkout-session', method: 'POST' }, + createSubscriptionPayment: { url: '/stripe/create-subscription', method: 'POST' }, + createPaymentIntent: { url: '/stripe/payment-intent', method: 'POST' }, + createSetupIntent: { url: '/stripe/setup-intent', method: 'POST' }, + createPaymentIntentFromSetup: (id: string, idempotencyKey: string) => + { + url: `/stripe/setup-intent/${id}/payment-intent?idempotency-key=${idempotencyKey}`, + method: 'POST', + }, + createSubscriptionFromSetup: (id: string, idempotencyKey: string) => + { + url: `/stripe/setup-intent/${id}/subscription?idempotency-key=${idempotencyKey}`, + method: 'POST', + }, + updateSetupIntent: (id: string, idempotencyKey: string) => + { + url: `/stripe/setup-intent/${id}?idempotency-key=${idempotencyKey}`, + method: 'POST', + }, + cancelSetupIntent: (id: string) => + { + url: `/stripe/setup-intent/${id}/cancel`, + method: 'POST', + }, createBankDonation: { url: '/donation/create-bank-payment', method: 'POST' }, synchronizeWithPayment: (id: string) => { url: `/donation/${id}/sync-with-payment`, method: 'PATCH' }, @@ -159,6 +181,8 @@ export const endpoints = { invalidateStripePayment: (id: string) => { url: `/donation/${id}/invalidate`, method: 'PATCH' }, getDonation: (id: string) => { url: `/donation/${id}`, method: 'GET' }, + getDonationByPaymentIntent: (id: string) => + { url: `/donation/payment-intent?id=${id}`, method: 'GET' }, donationsList: ( paymentId?: string, campaignId?: string, diff --git a/src/service/donation.ts b/src/service/donation.ts index f1dba2b4f..dcb2c2431 100644 --- a/src/service/donation.ts +++ b/src/service/donation.ts @@ -1,5 +1,5 @@ import Stripe from 'stripe' -import { AxiosResponse } from 'axios' +import { AxiosError, AxiosResponse } from 'axios' import { useSession } from 'next-auth/react' import { @@ -9,7 +9,10 @@ import { DonationBankInput, DonationResponse, StripeRefundResponse, + SubscriptionPaymentInput, + UpdateSetupIntentInput, UserDonationInput, + CancelSetupIntentInput, } from 'gql/donations' import { apiClient } from 'service/apiClient' import { endpoints } from 'service/apiEndpoints' @@ -17,6 +20,9 @@ import { authConfig } from 'service/restRequests' import { UploadBankTransactionsFiles } from 'components/admin/bank-transactions-file/types' import { useMutation } from '@tanstack/react-query' import { FilterData } from 'gql/types' +import { PaymentMode } from 'components/client/donation-flow/helpers/types' +import { Session } from 'next-auth' +import { SetupIntent } from '@stripe/stripe-js' export const createCheckoutSession = async (data: CheckoutSessionInput) => { return await apiClient.post>( @@ -25,17 +31,72 @@ export const createCheckoutSession = async (data: CheckoutSessionInput) => { ) } -export function useCreatePaymentIntent(params: Stripe.PaymentIntentCreateParams) { +export function useCreatePaymentIntent() { + //Create payment intent useing the react-query mutation + return useMutation({ + mutationKey: [endpoints.donation.createPaymentIntent.url], + mutationFn: async (data: Stripe.PaymentIntentCreateParams) => { + return await apiClient.post< + Stripe.PaymentIntentCreateParams, + AxiosResponse + >(endpoints.donation.createPaymentIntent.url, data) + }, + }) +} + +export function useUpdateSetupIntent() { //Create payment intent useing the react-query mutation const { data: session } = useSession() - return useMutation(async () => { - return await apiClient.post< - Stripe.PaymentIntentCreateParams, - AxiosResponse - >(endpoints.donation.createPaymentIntent.url, params, authConfig(session?.accessToken)) + return useMutation({ + mutationFn: async ({ id, idempotencyKey, payload }: UpdateSetupIntentInput) => { + return await apiClient.post< + Stripe.SetupIntentUpdateParams, + AxiosResponse + >( + endpoints.donation.updateSetupIntent(id, idempotencyKey).url, + payload, + authConfig(session?.accessToken), + ) + }, }) } +export function useCancelSetupIntent() { + return useMutation, AxiosError, CancelSetupIntentInput>({ + mutationFn: async ({ id }) => { + return await apiClient.patch>( + endpoints.donation.cancelSetupIntent(id).url, + ) + }, + }) +} + +export function useCreateSubscriptionPayment() { + const { data: session } = useSession() + return useMutation(async (data: SubscriptionPaymentInput) => { + return await apiClient.post>( + endpoints.donation.createSubscriptionPayment.url, + data, + authConfig(session?.accessToken), + ) + }) +} + +export async function createIntentFromSetup( + setupIntentId: string, + idempotencyKey: string, + mode: PaymentMode, + session: Session | null, +): Promise> { + return await apiClient.post, AxiosError>( + mode === 'one-time' + ? endpoints.donation.createPaymentIntentFromSetup(setupIntentId, idempotencyKey).url + : endpoints.donation.createSubscriptionFromSetup(setupIntentId, idempotencyKey).url, + undefined, + authConfig(session?.accessToken), + ) +} + export function useCreateBankDonation() { // const { data: session } = useSession() return async (data: DonationBankInput) => { diff --git a/src/service/stripeClient.ts b/src/service/stripeClient.ts new file mode 100644 index 000000000..8ba7c964c --- /dev/null +++ b/src/service/stripeClient.ts @@ -0,0 +1,7 @@ +import getConfig from 'next/config' +import { loadStripe } from '@stripe/stripe-js' +const { + publicRuntimeConfig: { STRIPE_PUBLISHABLE_KEY }, +} = getConfig() + +export const stripe = await loadStripe(STRIPE_PUBLISHABLE_KEY) diff --git a/src/stores/AuthContext.tsx b/src/stores/AuthContext.tsx deleted file mode 100644 index 56d9ce5fe..000000000 --- a/src/stores/AuthContext.tsx +++ /dev/null @@ -1,11 +0,0 @@ -'use client' - -import { SessionProvider, SessionProviderProps } from 'next-auth/react' -import { PropsWithChildren } from 'react' - -export default function AuthContext({ - children, - ...props -}: PropsWithChildren) { - return {children} -} diff --git a/src/styles/global.scss b/src/styles/global.scss index cca0d355e..9ee9f4f6d 100644 --- a/src/styles/global.scss +++ b/src/styles/global.scss @@ -53,6 +53,13 @@ ul { } } +// Add outline to all MuiFocusVisible elements +// https://github.com/mui/material-ui/issues/35843 +/* stylelint-disable selector-class-pattern */ +.Mui-focusVisible { + outline: 2px solid $theme-primary-dark; +} + .sp-cm { pre { flex-shrink: 1; @@ -60,9 +67,9 @@ ul { white-space: break-spaces; word-break: break-word; } -} -// center the videos in campaign pages -.ql-bubble .ql-video { - margin-inline: auto; + // center the videos in campaign pages + .ql-bubble .ql-video { + margin-inline: auto; + } } diff --git a/src/test/helpers/stripe-fee-calculator.test.ts b/src/test/helpers/stripe-fee-calculator.test.ts index f149e3ccd..a4830abaf 100644 --- a/src/test/helpers/stripe-fee-calculator.test.ts +++ b/src/test/helpers/stripe-fee-calculator.test.ts @@ -2,7 +2,7 @@ import { CardRegion } from 'gql/donations.enums' import { stripeFeeCalculator, stripeIncludeFeeCalculator, -} from 'components/client/one-time-donation/helpers/stripe-fee-calculator' +} from 'components/client/donation-flow/helpers/stripe-fee-calculator' describe("Calculating total charged amount with Stripe's fee included", () => { //Total amount of donation in stotinki, BGN 1 = 100 stotinki diff --git a/yarn.lock b/yarn.lock index 39ae9d34b..a324a698b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -451,6 +451,15 @@ __metadata: languageName: node linkType: hard +"@babel/runtime@npm:^7.1.2": + version: 7.24.4 + resolution: "@babel/runtime@npm:7.24.4" + dependencies: + regenerator-runtime: ^0.14.0 + checksum: 2f27d4c0ffac7ae7999ac0385e1106f2a06992a8bdcbf3da06adcac7413863cd08c198c2e4e970041bbea849e17f02e1df18875539b6afba76c781b6b59a07c3 + languageName: node + linkType: hard + "@babel/runtime@npm:^7.10.5, @babel/runtime@npm:^7.12.5, @babel/runtime@npm:^7.18.3, @babel/runtime@npm:^7.18.9, @babel/runtime@npm:^7.20.13, @babel/runtime@npm:^7.20.7, @babel/runtime@npm:^7.21.0, @babel/runtime@npm:^7.22.5, @babel/runtime@npm:^7.3.1, @babel/runtime@npm:^7.5.5, @babel/runtime@npm:^7.7.6, @babel/runtime@npm:^7.8.3, @babel/runtime@npm:^7.8.7, @babel/runtime@npm:^7.9.2": version: 7.22.6 resolution: "@babel/runtime@npm:7.22.6" @@ -3786,23 +3795,23 @@ __metadata: languageName: node linkType: hard -"@stripe/react-stripe-js@npm:^1.16.1": - version: 1.16.5 - resolution: "@stripe/react-stripe-js@npm:1.16.5" +"@stripe/react-stripe-js@npm:^2.7.0": + version: 2.7.0 + resolution: "@stripe/react-stripe-js@npm:2.7.0" dependencies: prop-types: ^15.7.2 peerDependencies: - "@stripe/stripe-js": ^1.44.1 + "@stripe/stripe-js": ^1.44.1 || ^2.0.0 || ^3.0.0 react: ^16.8.0 || ^17.0.0 || ^18.0.0 react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 - checksum: fc43248af6fd1f9825463eeb23183b586635048e544c7fa076ee59e85439e7864afaca4adee2a62a9d1cf9a72ce7eaf405dbeffec9ff34e6e0464be0cc92c6a8 + checksum: 1915c48c99cbfd9823767bed37d9938d742ba0548b1ea91c8d9ed3dd1547e2aa4f709307d127565ab38354c95e37807d6305dd95c32cf4919f2ca1e982ed6663 languageName: node linkType: hard -"@stripe/stripe-js@npm:^1.46.0": - version: 1.54.1 - resolution: "@stripe/stripe-js@npm:1.54.1" - checksum: eb54054edea3d3f9a8c18e8442cc05b46f7afbe5bd85863121737f268875d30aab6de16ee84d467853ad9d983bf69f922f45daeee6f13134ca138f7e862c9eb4 +"@stripe/stripe-js@npm:^3.3.0": + version: 3.3.0 + resolution: "@stripe/stripe-js@npm:3.3.0" + checksum: 7d3c410136fd2df83a9278b90b920f048ed309d113c7d0e26f97e756d1ef490232243e2526940e5ea11c143acc4d70ed44fae973761665fbbab85790b3cc0446 languageName: node linkType: hard @@ -4074,6 +4083,13 @@ __metadata: languageName: node linkType: hard +"@types/js-cookie@npm:2.2.5": + version: 2.2.5 + resolution: "@types/js-cookie@npm:2.2.5" + checksum: 5b25183c91e4f5ddfa2ad231538baa49a7bbbef56eba2a80345fbeebdedf0964c252abdef51105e6bee620fd095cde4f271bc5114b85062479cbdc54fd466014 + languageName: node + linkType: hard + "@types/jsdom@npm:^20.0.0": version: 20.0.1 resolution: "@types/jsdom@npm:20.0.1" @@ -4598,6 +4614,13 @@ __metadata: languageName: node linkType: hard +"@xobotyi/scrollbar-width@npm:1.9.5": + version: 1.9.5 + resolution: "@xobotyi/scrollbar-width@npm:1.9.5" + checksum: e880c8696bd6c7eedaad4e89cc7bcfcd502c22dc6c061288ffa7f5a4fe5dab4aa2358bdd68e7357bf0334dc8b56724ed9bee05e010b60d83a3bb0d855f3d886f + languageName: node + linkType: hard + "abab@npm:^2.0.6": version: 2.0.6 resolution: "abab@npm:2.0.6" @@ -5851,6 +5874,15 @@ __metadata: languageName: node linkType: hard +"copy-to-clipboard@npm:^3.2.0": + version: 3.3.3 + resolution: "copy-to-clipboard@npm:3.3.3" + dependencies: + toggle-selection: ^1.0.6 + checksum: e0a325e39b7615108e6c1c8ac110ae7b829cdc4ee3278b1df6a0e4228c490442cc86444cd643e2da344fbc424b3aab8909e2fec82f8bc75e7e5b190b7c24eecf + languageName: node + linkType: hard + "core-js@npm:^3": version: 3.31.1 resolution: "core-js@npm:3.31.1" @@ -5931,6 +5963,25 @@ __metadata: languageName: node linkType: hard +"css-in-js-utils@npm:^3.1.0": + version: 3.1.0 + resolution: "css-in-js-utils@npm:3.1.0" + dependencies: + hyphenate-style-name: ^1.0.3 + checksum: 066318e918c04a5e5bce46b38fe81052ea6ac051bcc6d3c369a1d59ceb1546cb2b6086901ab5d22be084122ee3732169996a3dfb04d3406eaee205af77aec61b + languageName: node + linkType: hard + +"css-tree@npm:^1.1.2": + version: 1.1.3 + resolution: "css-tree@npm:1.1.3" + dependencies: + mdn-data: 2.0.14 + source-map: ^0.6.1 + checksum: 79f9b81803991b6977b7fcb1588799270438274d89066ce08f117f5cdb5e20019b446d766c61506dd772c839df84caa16042d6076f20c97187f5abe3b50e7d1f + languageName: node + linkType: hard + "css-tree@npm:^2.3.1": version: 2.3.1 resolution: "css-tree@npm:2.3.1" @@ -6640,6 +6691,15 @@ __metadata: languageName: node linkType: hard +"error-stack-parser@npm:^2.0.6": + version: 2.1.4 + resolution: "error-stack-parser@npm:2.1.4" + dependencies: + stackframe: ^1.3.4 + checksum: 3b916d2d14c6682f287c8bfa28e14672f47eafe832701080e420e7cdbaebb2c50293868256a95706ac2330fe078cf5664713158b49bc30d7a5f2ac229ded0e18 + languageName: node + linkType: hard + "es-abstract@npm:^1.19.0, es-abstract@npm:^1.20.4": version: 1.21.2 resolution: "es-abstract@npm:1.21.2" @@ -7348,6 +7408,20 @@ __metadata: languageName: node linkType: hard +"fast-loops@npm:^1.1.3": + version: 1.1.3 + resolution: "fast-loops@npm:1.1.3" + checksum: b674378ba2ed8364ca1a00768636e88b22201c8d010fa62a8588a4cace04f90bac46714c13cf638be82b03438d2fe813600da32291fb47297a1bd7fa6cef0cee + languageName: node + linkType: hard + +"fast-shallow-equal@npm:^1.0.0": + version: 1.0.0 + resolution: "fast-shallow-equal@npm:1.0.0" + checksum: ae89318ce43c0c46410d9511ac31520d59cfe675bad3d0b1cb5f900b2d635943d788b8370437178e91ae0d0412decc394229c03e69925ade929a8c02da241610 + languageName: node + linkType: hard + "fastest-levenshtein@npm:^1.0.16": version: 1.0.16 resolution: "fastest-levenshtein@npm:1.0.16" @@ -7355,6 +7429,13 @@ __metadata: languageName: node linkType: hard +"fastest-stable-stringify@npm:^2.0.2": + version: 2.0.2 + resolution: "fastest-stable-stringify@npm:2.0.2" + checksum: 5e2cb166c7bb6f16ac25a1e4be17f6b8d2923234c80739e12c9d21dea376b3128b2c63f90aa2aae7746cfec4dcf188d1d4eb6a964bb484ca133f17c8e9acfacc + languageName: node + linkType: hard + "fastq@npm:^1.6.0": version: 1.15.0 resolution: "fastq@npm:1.15.0" @@ -7517,6 +7598,19 @@ __metadata: languageName: node linkType: hard +"formik-persist-values@npm:^1.4.1": + version: 1.4.1 + resolution: "formik-persist-values@npm:1.4.1" + dependencies: + lodash.omit: ^4.5.0 + react-use: ^13.14.3 + peerDependencies: + formik: ">=2.0.0" + react: ">=16" + checksum: ad0dc3b8f923ac6f4728ef4a27a572e68c25fed86f3e228b5c6eb7ea86dc628e2d1e60be570c978a6cf8a0c30891da94e742540c515dc1fa062c3d40e48f756b + languageName: node + linkType: hard + "formik@npm:2.2.9": version: 2.2.9 resolution: "formik@npm:2.2.9" @@ -8519,6 +8613,16 @@ __metadata: languageName: node linkType: hard +"inline-style-prefixer@npm:^7.0.0": + version: 7.0.0 + resolution: "inline-style-prefixer@npm:7.0.0" + dependencies: + css-in-js-utils: ^3.1.0 + fast-loops: ^1.1.3 + checksum: 89fd73eb06e7392e24032ea33b8b33ae7f9a24298f2d9ebbf7b31a3a3934247270047f4f49a454a363aace14e25c3a20fd97465405b0399cc888e5a2bc04ec05 + languageName: node + linkType: hard + "inquirer@npm:^7.3.3": version: 7.3.3 resolution: "inquirer@npm:7.3.3" @@ -9994,6 +10098,13 @@ __metadata: languageName: node linkType: hard +"lodash.omit@npm:^4.5.0": + version: 4.5.0 + resolution: "lodash.omit@npm:4.5.0" + checksum: 434645e49fe84ab315719bd5a9a3a585a0f624aa4160bc09157dd041a414bcc287c15840365c1379476a3f3eda41fbe838976c3f7bdecbbf4c5478e86c471a30 + languageName: node + linkType: hard + "lodash.truncate@npm:^4.4.2": version: 4.4.2 resolution: "lodash.truncate@npm:4.4.2" @@ -10355,6 +10466,13 @@ __metadata: languageName: node linkType: hard +"mdn-data@npm:2.0.14": + version: 2.0.14 + resolution: "mdn-data@npm:2.0.14" + checksum: 9d0128ed425a89f4cba8f787dca27ad9408b5cb1b220af2d938e2a0629d17d879a34d2cb19318bdb26c3f14c77dd5dfbae67211f5caaf07b61b1f2c5c8c7dc16 + languageName: node + linkType: hard + "mdn-data@npm:2.0.30": version: 2.0.30 resolution: "mdn-data@npm:2.0.30" @@ -11121,6 +11239,25 @@ __metadata: languageName: node linkType: hard +"nano-css@npm:^5.2.1": + version: 5.6.1 + resolution: "nano-css@npm:5.6.1" + dependencies: + "@jridgewell/sourcemap-codec": ^1.4.15 + css-tree: ^1.1.2 + csstype: ^3.1.2 + fastest-stable-stringify: ^2.0.2 + inline-style-prefixer: ^7.0.0 + rtl-css-js: ^1.16.1 + stacktrace-js: ^2.0.2 + stylis: ^4.3.0 + peerDependencies: + react: "*" + react-dom: "*" + checksum: 735f02c030a9416bb6060503d24f18f2b2c9f43e4893c2d8714508d00f9d114b8a134df3623e94e376b0b1d794b0cacac6a48f8e5fb2b7fa8996071bcad590b8 + languageName: node + linkType: hard + "nanoclone@npm:^0.2.1": version: 0.2.1 resolution: "nanoclone@npm:0.2.1" @@ -11908,8 +12045,8 @@ __metadata: "@ramonak/react-progress-bar": ^5.0.3 "@react-pdf/renderer": ^3.1.3 "@sentry/nextjs": ^7.80.0 - "@stripe/react-stripe-js": ^1.16.1 - "@stripe/stripe-js": ^1.46.0 + "@stripe/react-stripe-js": ^2.7.0 + "@stripe/stripe-js": ^3.3.0 "@tanstack/react-query": ^4.16.1 "@testing-library/jest-dom": ^5.16.5 "@testing-library/react": ^13.4.0 @@ -11946,6 +12083,7 @@ __metadata: eslint-plugin-react: ^7.32.2 eslint-plugin-react-hooks: ^4.6.0 formik: 2.2.9 + formik-persist-values: ^1.4.1 husky: 7.0.1 i18next: ^23.5.1 jest: ^29.6.1 @@ -12654,6 +12792,30 @@ __metadata: languageName: node linkType: hard +"react-use@npm:^13.14.3": + version: 13.27.1 + resolution: "react-use@npm:13.27.1" + dependencies: + "@types/js-cookie": 2.2.5 + "@xobotyi/scrollbar-width": 1.9.5 + copy-to-clipboard: ^3.2.0 + fast-deep-equal: ^3.1.1 + fast-shallow-equal: ^1.0.0 + js-cookie: ^2.2.1 + nano-css: ^5.2.1 + resize-observer-polyfill: ^1.5.1 + screenfull: ^5.0.0 + set-harmonic-interval: ^1.0.1 + throttle-debounce: ^2.1.0 + ts-easing: ^0.2.0 + tslib: ^1.10.0 + peerDependencies: + react: ^16.8.0 + react-dom: ^16.8.0 + checksum: affba168777e7adebd8381a0f33d51fe2a3b0be48c6be7dd8055745d77e4d0a7921f6d995aea3562433df23673fb6571f704391866b7d6e2910b67d1fe97289a + languageName: node + linkType: hard + "react@npm:18.2.0": version: 18.2.0 resolution: "react@npm:18.2.0" @@ -12917,7 +13079,7 @@ __metadata: languageName: node linkType: hard -"resize-observer-polyfill@npm:^1.5.0": +"resize-observer-polyfill@npm:^1.5.0, resize-observer-polyfill@npm:^1.5.1": version: 1.5.1 resolution: "resize-observer-polyfill@npm:1.5.1" checksum: 57e7f79489867b00ba43c9c051524a5c8f162a61d5547e99333549afc23e15c44fd43f2f318ea0261ea98c0eb3158cca261e6f48d66e1ed1cd1f340a43977094 @@ -13102,6 +13264,15 @@ __metadata: languageName: node linkType: hard +"rtl-css-js@npm:^1.16.1": + version: 1.16.1 + resolution: "rtl-css-js@npm:1.16.1" + dependencies: + "@babel/runtime": ^7.1.2 + checksum: 7d9ab942098eee565784ccf957f6b7dfa78ea1eec7c6bffedc6641575d274189e90752537c7bdba1f43ae6534648144f467fd6d581527455ba626a4300e62c7a + languageName: node + linkType: hard + "run-applescript@npm:^5.0.0": version: 5.0.0 resolution: "run-applescript@npm:5.0.0" @@ -13241,6 +13412,13 @@ __metadata: languageName: node linkType: hard +"screenfull@npm:^5.0.0": + version: 5.2.0 + resolution: "screenfull@npm:5.2.0" + checksum: 21eae33b780eb4679ea0ea2d14734b11168cf35049c45a2bf24ddeb39c67a788e7a8fb46d8b61ca6d8367fd67ce9dd4fc8bfe476489249c7189c2a79cf83f51a + languageName: node + linkType: hard + "scss-parser@npm:^1.0.4": version: 1.0.6 resolution: "scss-parser@npm:1.0.6" @@ -13292,6 +13470,13 @@ __metadata: languageName: node linkType: hard +"set-harmonic-interval@npm:^1.0.1": + version: 1.0.1 + resolution: "set-harmonic-interval@npm:1.0.1" + checksum: c122b831c2e0b1fb812e5e9d065094b9d174bd0576f9a779ab7a7d8881c8f6dd7d5fcab9a2553da15eea670eb598f9dd4d5162b626d45cc9c529706aa1444a84 + languageName: node + linkType: hard + "shallow-equal@npm:^3.1.0": version: 3.1.0 resolution: "shallow-equal@npm:3.1.0" @@ -13546,6 +13731,13 @@ __metadata: languageName: node linkType: hard +"source-map@npm:0.5.6": + version: 0.5.6 + resolution: "source-map@npm:0.5.6" + checksum: 390b3f5165c9631a74fb6fb55ba61e62a7f9b7d4026ae0e2bfc2899c241d71c1bccb8731c496dc7f7cb79a5f523406eb03d8c5bebe8448ee3fc38168e2d209c8 + languageName: node + linkType: hard + "source-map@npm:^0.5.7": version: 0.5.7 resolution: "source-map@npm:0.5.7" @@ -13617,6 +13809,15 @@ __metadata: languageName: node linkType: hard +"stack-generator@npm:^2.0.5": + version: 2.0.10 + resolution: "stack-generator@npm:2.0.10" + dependencies: + stackframe: ^1.3.4 + checksum: 4fc3978a934424218a0aa9f398034e1f78153d5ff4f4ff9c62478c672debb47dd58de05b09fc3900530cbb526d72c93a6e6c9353bacc698e3b1c00ca3dda0c47 + languageName: node + linkType: hard + "stack-utils@npm:^2.0.3": version: 2.0.6 resolution: "stack-utils@npm:2.0.6" @@ -13626,6 +13827,34 @@ __metadata: languageName: node linkType: hard +"stackframe@npm:^1.3.4": + version: 1.3.4 + resolution: "stackframe@npm:1.3.4" + checksum: bae1596873595c4610993fa84f86a3387d67586401c1816ea048c0196800c0646c4d2da98c2ee80557fd9eff05877efe33b91ba6cd052658ed96ddc85d19067d + languageName: node + linkType: hard + +"stacktrace-gps@npm:^3.0.4": + version: 3.1.2 + resolution: "stacktrace-gps@npm:3.1.2" + dependencies: + source-map: 0.5.6 + stackframe: ^1.3.4 + checksum: 85daa232d138239b6ae0f4bcdd87d15d302a045d93625db17614030945b5314e204b5fbcf9bee5b6f4f9e6af5fca05f65c27fe910894b861ef6853b99470aa1c + languageName: node + linkType: hard + +"stacktrace-js@npm:^2.0.2": + version: 2.0.2 + resolution: "stacktrace-js@npm:2.0.2" + dependencies: + error-stack-parser: ^2.0.6 + stack-generator: ^2.0.5 + stacktrace-gps: ^3.0.4 + checksum: 081e786d56188ac04ac6604c09cd863b3ca2b4300ec061366cf68c3e4ad9edaa34fb40deea03cc23a05f442aa341e9171f47313f19bd588f9bec6c505a396286 + languageName: node + linkType: hard + "stacktrace-parser@npm:^0.1.10": version: 0.1.10 resolution: "stacktrace-parser@npm:0.1.10" @@ -14046,6 +14275,13 @@ __metadata: languageName: node linkType: hard +"stylis@npm:^4.3.0": + version: 4.3.1 + resolution: "stylis@npm:4.3.1" + checksum: d365f1b008677b2147e8391e9cf20094a4202a5f9789562e7d9d0a3bd6f0b3067d39e8fd17cce5323903a56f6c45388e3d839e9c0bb5a738c91726992b14966d + languageName: node + linkType: hard + "supports-color@npm:^5.3.0": version: 5.5.0 resolution: "supports-color@npm:5.5.0" @@ -14220,6 +14456,13 @@ __metadata: languageName: node linkType: hard +"throttle-debounce@npm:^2.1.0": + version: 2.3.0 + resolution: "throttle-debounce@npm:2.3.0" + checksum: 6d90aa2ddb294f8dad13d854a1cfcd88fdb757469669a096a7da10f515ee466857ac1e750649cb9da931165c6f36feb448318e7cb92570f0a3679d20e860a925 + languageName: node + linkType: hard + "through2@npm:~0.4.1": version: 0.4.2 resolution: "through2@npm:0.4.2" @@ -14290,6 +14533,13 @@ __metadata: languageName: node linkType: hard +"toggle-selection@npm:^1.0.6": + version: 1.0.6 + resolution: "toggle-selection@npm:1.0.6" + checksum: a90dc80ed1e7b18db8f4e16e86a5574f87632dc729cfc07d9ea3ced50021ad42bb4e08f22c0913e0b98e3837b0b717e0a51613c65f30418e21eb99da6556a74c + languageName: node + linkType: hard + "toposort@npm:^2.0.2": version: 2.0.2 resolution: "toposort@npm:2.0.2" @@ -14353,6 +14603,13 @@ __metadata: languageName: node linkType: hard +"ts-easing@npm:^0.2.0": + version: 0.2.0 + resolution: "ts-easing@npm:0.2.0" + checksum: e67ee862acca3b2e2718e736f31999adcef862d0df76d76a0e138588728d8a87dfec9978556044640bd0e90203590ad88ac2fe8746d0e9959b8d399132315150 + languageName: node + linkType: hard + "tsconfig-paths@npm:^3.14.1": version: 3.14.2 resolution: "tsconfig-paths@npm:3.14.2"