diff --git a/.all-contributorsrc b/.all-contributorsrc index c5e256409..eab6c0a6a 100644 --- a/.all-contributorsrc +++ b/.all-contributorsrc @@ -886,6 +886,24 @@ "code", "test" ] + }, + { + "login": "viiktorstefanov", + "name": "Viktor Stefanov", + "avatar_url": "https://avatars.githubusercontent.com/u/122864734?v=4", + "profile": "https://github.com/viiktorstefanov", + "contributions": [ + "code" + ] + }, + { + "login": "velnachev", + "name": "velnachev", + "avatar_url": "https://avatars.githubusercontent.com/u/60844919?v=4", + "profile": "https://github.com/velnachev", + "contributions": [ + "code" + ] } ], "contributorsPerLine": 10, diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 59f575d88..316f05593 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -1,25 +1,23 @@ // For format details, see https://aka.ms/devcontainer.json. For config options, see the // README at: https://github.com/devcontainers/templates/tree/main/src/alpine { - "name": "NodeJS", - "image": "ghcr.io/podkrepi-bg/nodejs-devcontainer:v1.2.0", - "forwardPorts": [], // Forward ports - "containerEnv": { - "DATABASE_URL": "postgres://postgres:postgrespass@host.docker.internal:5432/postgres?schema=api" // Custom env vars - }, - "postStartCommand": "yarn", // Install dependencies - "customizations":{ - "vscode": { - "extensions": [ - "ms-azuretools.vscode-docker", - "nrwl.angular-console", - "esbenp.prettier-vscode", - "firsttris.vscode-jest-runner", - "dbaeumer.vscode-eslint" - ] - } - }, - "mounts": [ - "source=/var/run/docker.sock,target=/var/run/docker.sock,type=bind" - ] -} \ No newline at end of file + "name": "NodeJS", + "image": "ghcr.io/podkrepi-bg/nodejs-devcontainer:v1.2.0", + "forwardPorts": [], // Forward ports + "containerEnv": { + "DATABASE_URL": "postgres://postgres:postgrespass@host.docker.internal:5432/postgres?schema=api" // Custom env vars + }, + "postStartCommand": "yarn", // Install dependencies + "customizations": { + "vscode": { + "extensions": [ + "ms-azuretools.vscode-docker", + "nrwl.angular-console", + "esbenp.prettier-vscode", + "firsttris.vscode-jest-runner", + "dbaeumer.vscode-eslint" + ] + } + }, + "mounts": ["source=/var/run/docker.sock,target=/var/run/docker.sock,type=bind"] +} diff --git a/.env.local.example b/.env.local.example index 8ec1a117f..ed1dc968b 100644 --- a/.env.local.example +++ b/.env.local.example @@ -32,7 +32,7 @@ GOOGLE_SECRET= ## Stripe ## ############## -STRIPE_PUBLIC_KEY= +STRIPE_PUBLISHABLE_KEY= ## Paypal ## ############## @@ -43,3 +43,5 @@ PAYPAL_CLIENT_ID=sb ########### GHOST_API_URL=https://blog.podkrepi.bg GHOST_CONTENT_KEY=86ec17c4b9660acd66b6034682 +PODKREPI_EMAIL=admin@podkrepi.bg +PODKREPI_PASSWORD=$ecurePa33 diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml index 0673eccbc..b80f5a889 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: @@ -36,9 +38,9 @@ jobs: restore-keys: | ${{ runner.os }}-yarn- - - name: Run db + - name: Run db and keycloak working-directory: ./api - run: docker compose up -d pg-db + run: docker compose up -d pg-db keycloak - name: Run stripe webhook working-directory: ./api @@ -69,7 +71,7 @@ jobs: - name: Install Frontend Dependencies working-directory: ./frontend run: yarn install --immutable - + - name: Install e2e Dependencies working-directory: ./frontend/e2e run: yarn install --immutable @@ -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 @@ -116,7 +120,7 @@ jobs: name: playwright-report path: ./frontend/e2e/test-results/ retention-days: 14 - + - uses: actions/upload-artifact@v4 if: always() with: diff --git a/README.md b/README.md index 425532187..67bd26945 100644 --- a/README.md +++ b/README.md @@ -100,9 +100,7 @@ Watch releases of this repository to be notified about future updates: ## Contributors ✨ - -[![All Contributors](https://img.shields.io/badge/all_contributors-82-orange.svg?style=flat-square)](#contributors-) - +[![All Contributors](https://img.shields.io/badge/all_contributors-84-orange.svg?style=flat-square)](#contributors-) Please check [contributors guide](https://github.com/podkrepi-bg/frontend/blob/master/CONTRIBUTING.md) for: @@ -226,6 +224,8 @@ Thanks goes to these wonderful people: Teodora Zhelyazkova
Teodora Zhelyazkova

💻 Martin Kovachki
Martin Kovachki

💻 ⚠️ + Viktor Stefanov
Viktor Stefanov

💻 + velnachev
velnachev

💻 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..24ac2f413 100644 --- a/e2e/pages/web-pages/campaigns/campaigns.page.ts +++ b/e2e/pages/web-pages/campaigns/campaigns.page.ts @@ -29,10 +29,13 @@ 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( - new RegExp(urlRegExpAsString || `^(.*?)/campaigns/${SLUG_REGEX}`), + new RegExp(urlRegExpAsString || `^(.*?)/(en/)?campaigns/${SLUG_REGEX}`), { timeout: timeoutParam, }, 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-application/campaign-application-admin.spec.ts b/e2e/tests/regression/campaign-application/campaign-application-admin.spec.ts new file mode 100644 index 000000000..8a16e66b3 --- /dev/null +++ b/e2e/tests/regression/campaign-application/campaign-application-admin.spec.ts @@ -0,0 +1,170 @@ +import { + CampaignApplicationResponse, + CampaignApplicationExisting, + CampaignApplicationAdminResponse, +} from '../../../../src/gql/campaign-applications' +import { Page } from 'playwright/test' +import { expect, adminTest as test } from '../../../utils/fixtures' +import { textLocalized } from '../../../utils/texts-localized' + +test.describe('Campaign application admin', () => { + test('should see list of applications', async ({ page, baseURL }) => { + // arrange + const { paginationFooter } = await setup(page) + .withCampaignApplications([ + { id: '1', state: 'review' }, + { id: '2', state: 'approved' }, + { id: '3', state: 'denied' }, + { id: '4', state: 'forCommitteeReview' }, + { id: '5' }, + ]) + .build() + + // act + await page.goto(`${baseURL}/admin/campaign-applications`) + + // assert + const t = await textLocalized().campaign.bg() + await expect(page.getByRole('heading')).toHaveText(t.admin.title) + await expect(page.getByRole('row')).toHaveCount(6) // title + 5 campaigns + await expect(page.getByRole('row').nth(1)).toContainText(t.status.review) + await expect(page.getByRole('row').nth(2)).toContainText(t.status.approved) + await expect(page.getByRole('row').nth(3)).toContainText(t.status.denied) + await expect(page.getByRole('row').nth(4)).toContainText(t.status.forCommitteeReview) + await expect(page.getByRole('row').nth(5)).toContainText(t.status.requestInfo) + await expect(paginationFooter(page)).toHaveText('Rows per page:1001–5 of 5') + }) + + test('should open a campaign application for edit', async ({ page, baseURL }) => { + // arrange + await setup(page).withEditCampaignApplication({}).build() + + // act + await page.goto(`${baseURL}/admin/campaign-applications/edit/1234`) + + // assert + const t = await textLocalized().campaign.bg() + await expect(page.getByRole('heading').first()).toHaveText(t.admin.title) + await expect(page.getByRole('heading').nth(1)).toHaveText(t.steps.admin.title) + }) + + test('should update status of campaign application to approved, archive it, and set the external link', async ({ + page, + baseURL, + }) => { + // arrange + await setup(page).withEditCampaignApplication({ id: '1234', state: 'review' }).build() + await page.goto(`${baseURL}/admin/campaign-applications/edit/1234`) + const t = await textLocalized().campaign.bg() + + // act + await page.getByLabel(t.steps.admin.status).click() + await page.getByText(t.status.approved).click() + + const [req] = await Promise.all([ + page.waitForRequest(/campaign-application\/1234/), + page.getByRole('button', { name: t.result.editButton }).click(), + ]) + + // assert + const postData = req.postDataJSON() + expect(postData.state).toEqual('approved') + expect(page.getByText(t.result.edited)).toBeInViewport() + }) +}) + +function setup(page: Page) { + const promises: Promise[] = [] + + const builder = { + withCampaignApplications(cams: Array>) { + promises.push( + page.route('*/**/api/v1/campaign-application/list', (route, req) => { + return route.fulfill({ + json: cams.map((c) => ({ ...defaultCampaignApplication(), ...c })), + }) + }), + ) + return builder + }, + + withEditCampaignApplication(c: Partial) { + promises.push( + page.route('*/**/api/v1/campaign-application/byId/*', (route, req) => { + return route.fulfill({ + json: { ...camAppForEdit(), ...c }, + }) + }), + page.route(`*/**/api/v1/campaign-application/${c.id}`, (r) => { + return r.fulfill({ json: { ...camAppForEdit(), ...c } }) + }), + ) + return builder + }, + + async build() { + await promises + + const selectors = { + paginationFooter: (p: Page) => p.locator('.MuiDataGrid-footerContainer'), + } + + return selectors + }, + } + + return builder +} + +function defaultCampaignApplication(): CampaignApplicationAdminResponse { + return { + id: 'eb4347a2-c8b4-47f1-83e5-67457b20909c', + createdAt: '2024-09-13T09:26:50.909Z', + updatedAt: '2024-09-28T20:56:13.728Z', + organizerName: 'Giver Dev', + organizerEmail: 'giver@podkrepi.bg', + organizerPhone: '+35928700500', + beneficiary: 'Bene', + organizerBeneficiaryRel: 'бене', + campaignName: 'Camp name', + goal: 'Целта на кампанията', + history: '', + amount: '1455', + description: '', + state: 'requestInfo', + campaignTypeId: 'c6ef0a79-11cf-4175-9f66-3cec940c9259', + ticketURL: 'https://trello.com/linkforthiscamapp', + archived: false, + campaignEnd: 'date', + campaignEndDate: '2025-09-30T00:00:00.000Z', + acceptTermsAndConditions: true, + transparencyTermsAccepted: true, + personalInformationProcessingAccepted: true, + } +} + +function camAppForEdit(): CampaignApplicationExisting { + return { + id: 'eb4347a2-c8b4-47f1-83e5-67457b20909c', + organizerName: 'Giver Dev', + organizerEmail: 'giver@podkrepi.bg', + organizerPhone: '+35928700500', + beneficiary: 'Bene', + organizerBeneficiaryRel: 'бене', + campaignName: 'Camp name', + goal: 'Целта на кампанията', + history: '', + amount: '1455', + description: '', + state: 'requestInfo', + campaignTypeId: 'c6ef0a79-11cf-4175-9f66-3cec940c9259', + ticketURL: 'https://trello.com/linkforthiscamapp', + archived: false, + campaignEnd: 'date', + campaignEndDate: '2025-09-30T00:00:00.000Z', + acceptTermsAndConditions: true, + transparencyTermsAccepted: true, + personalInformationProcessingAccepted: true, + documents: [], + } +} diff --git a/e2e/tests/regression/campaign-application/campaign-application-giver.spec.ts b/e2e/tests/regression/campaign-application/campaign-application-giver.spec.ts new file mode 100644 index 000000000..1a916af60 --- /dev/null +++ b/e2e/tests/regression/campaign-application/campaign-application-giver.spec.ts @@ -0,0 +1,234 @@ +import { + CampaignApplicationResponse, + CampaignApplicationExisting, + CampaignApplicationAdminResponse, +} from '../../../../src/gql/campaign-applications' +import { Page } from 'playwright/test' +import { expect, giverTest as test } from '../../../utils/fixtures' +import { textLocalized } from '../../../utils/texts-localized' + +test.describe('Campaign application giver', () => { + test('should see the first step - organizer - of create campaign application wizard and after accepting the terms to be able to go to step 2', async ({ + page, + baseURL, + }) => { + // arrange + // act + await page.goto(`${baseURL}/campaigns/application`) + + // assert + const t = await textLocalized().campaign.bg() + await expect(page.getByRole('heading')).toHaveText(t.steps.organizer.title) + + await page.getByRole('checkbox').first().click() + await page.getByRole('checkbox').nth(1).click() + await page.getByRole('checkbox').nth(2).click() + + await page.getByRole('button', { name: t.cta.next }).click() + + // assert + await expect(page.getByRole('heading')).toHaveText(t.steps.application.title) + }) + + test('should see the second step -application - of create campaign application wizard and after filling in the beneficiary, relations, title, type and funds go to step 3', async ({ + page, + baseURL, + }) => { + // arrange + await page.goto(`${baseURL}/campaigns/application`) + const t = await textLocalized().campaign.bg() + + // step 1 + await page.getByRole('checkbox').first().click() + await page.getByRole('checkbox').nth(1).click() + await page.getByRole('checkbox').nth(2).click() + await page.getByRole('button', { name: t.cta.next }).click() + + // act + await page.getByLabel(t.steps.application.beneficiary).fill('beneficiary') + await page.getByLabel(t.steps.application.beneficiaryRelationship).fill('rel') + await page.getByLabel(t.steps.application.campaignTitle).fill('title') + + // select type of campaign app by opening the dropdown and arrow down and enter to select + await page.locator('[name="applicationBasic.campaignType"]').click({ force: true }) // this is the underlying input and it's hidden - hence the force + await page.keyboard.down('ArrowDown') + await page.keyboard.down('Enter') + + await page.getByLabel(t.steps.application.funds).fill('12345') + + // go next + await page.getByRole('button', { name: t.cta.next }).click() + + // assert + await expect(page.getByRole('heading')).toHaveText(t.steps.details.title) + }) + + test('should see the third step - details - of create campaign application wizard and after filling the title, description, history and 2 files be able to create a new campaign application', async ({ + page, + baseURL, + }) => { + // arrange + await setupMeAndCampaignTypes(page) + await page.goto(`${baseURL}/campaigns/application`) + const t = await textLocalized().campaign.bg() + + // step 1 + await page.getByRole('checkbox').first().click() + await page.getByRole('checkbox').nth(1).click() + await page.getByRole('checkbox').nth(2).click() + await page.getByRole('button', { name: t.cta.next }).click() + // step 2 + await page.getByLabel(t.steps.application.beneficiary).fill('beneficiary') + await page.getByLabel(t.steps.application.beneficiaryRelationship).fill('rel') + await page.getByLabel(t.steps.application.campaignTitle).fill('title') + + // select type of campaign app by opening the dropdown and arrow down and enter to select + await page.locator('[name="applicationBasic.campaignType"]').click({ force: true }) // this is the underlying input and it's hidden - hence the force + await page.keyboard.down('ArrowDown') + await page.keyboard.down('Enter') + + await page.getByLabel(t.steps.application.funds).fill('12345') + + await page.getByRole('button', { name: t.cta.next }).click() + + // act + await page.getByLabel(t.steps.details.cause).fill('goal') + await page.getByLabel(t.steps.details.description).fill('description') + await page.getByLabel(t.steps.details['current-status'].label).fill('history') + + await page.getByLabel(t.steps.details.documents).setInputFiles([ + { + name: 'file.txt', + mimeType: 'text/plain', + buffer: Buffer.from('this is test'), + }, + { + name: 'file1.txt', + mimeType: 'text/plain', + buffer: Buffer.from('this is test'), + }, + ]) + + // ensure we intercept the create and not let it go to the server... + page.route('*/**/api/v1/campaign-application/create', (route, req) => { + return route.fulfill({ + json: defaultCampaignApplication(), + }) + }) + // and the upload file as well + page.route('*/**/api/v1/campaign-application/uploadFile/*', (route, req) => { + return route.fulfill({ + json: { id: '1' }, + }) + }) + + const [createApplication, uploadFile1, uploadFile2] = await Promise.all([ + page.waitForRequest(/\/api\/v1\/campaign-application\/create/), + page.waitForRequest(/\/api\/v1\/campaign-application\/uploadFile.*/), + page.waitForRequest(/\/api\/v1\/campaign-application\/uploadFile.*/), + page.getByRole('button', { name: t.cta.submit }).click(), + ]) + + // assert + await expect(createApplication.postDataJSON()).toEqual({ + acceptTermsAndConditions: true, + amount: '12345', + archived: false, + beneficiary: 'beneficiary', + campaignEnd: 'funds', + campaignName: 'title', + campaignTypeId: '34b501f0-b3c3-43d9-9be0-7f7258eeb247', + description: 'description', + goal: 'goal', + history: 'history', + organizerBeneficiaryRel: 'rel', + organizerEmail: 'giver@podkrepi.bg', + organizerName: 'Giver Dev', + organizerPhone: '+35928700500', + personalInformationProcessingAccepted: true, + state: 'review', + ticketURL: '', + transparencyTermsAccepted: true, + }) + + expect(uploadFile1.method()).toEqual('POST') + expect(uploadFile1.url()).toMatch('api/v1/campaign-application/uploadFile/created') + expect(uploadFile2.method()).toEqual('POST') + expect(uploadFile2.url()).toMatch('api/v1/campaign-application/uploadFile/created') + + await expect(page.getByRole('heading')).toHaveText(t.result.created) + await expect(page.getByText('file.txt')).toBeVisible() + await expect(page.getByText('file1.txt')).toBeVisible() + await expect(page.getByText('giver@podkrepi.bg')).toBeVisible() + await expect(page.getByText('Giver Dev')).toBeVisible() + await expect(page.getByText('+35928700500')).toBeVisible() + await expect(page.getByText('beneficiary')).toBeVisible() + await expect(page.getByText('rel')).toBeVisible() + await expect(page.getByText('title')).toBeVisible() + await expect(page.getByText('12345')).toBeVisible() + await expect(page.getByText(t.steps.application['campaign-end'].options.funds)).toBeVisible() + await expect(page.getByText('goal')).toBeVisible() + }) +}) + +function defaultCampaignApplication() { + return { + id: 'created', + acceptTermsAndConditions: true, + personalInformationProcessingAccepted: true, + transparencyTermsAccepted: true, + organizerName: 'Giver Dev', + organizerEmail: 'giver@podkrepi.bg', + organizerPhone: '+35928700500', + beneficiary: 'beneficiary', + campaignName: 'title', + amount: '12345', + goal: 'goal', + description: '', + organizerBeneficiaryRel: 'rel', + history: '', + campaignEnd: 'funds', + campaignTypeId: 'b9043466-a3c1-4ced-b951-6282ca3e6a7b', + archived: false, + state: 'review', + ticketURL: '', + } +} + +async function setupMeAndCampaignTypes(page: Page) { + await page.route('*/**/api/v1/account/me', (req) => + req.fulfill({ + json: { + user: { + id: '99c18c81-54bc-4f32-ab50-3ac5c383f44b', + firstName: 'Giver', + lastName: 'Dev', + email: 'giver@podkrepi.bg', + phone: '+35928700500', + }, + }, + }), + ) + await page.route('*/**/api/v1/campaign-types/', (req) => + req.fulfill({ + json: [ + { + id: '0c80a28c-f09e-4e82-b2ec-6682ae559cab', + name: 'Transplantation', + slug: 'transplantation', + description: 'Ullam exercitationem optio tempora ullam.', + parentId: 'b9043466-a3c1-4ced-b951-6282ca3e6a7b', + category: 'medical', + }, + { + id: '34b501f0-b3c3-43d9-9be0-7f7258eeb247', + name: 'Membership', + slug: 'membership', + description: 'Membership Campaigns', + parentId: null, + category: 'others', + }, + ], + }), + ) +} diff --git a/e2e/tests/regression/campaign-flow/campaign-view.spec.ts b/e2e/tests/regression/campaign-flow/campaign-view.spec.ts index f9f2048e9..c608ae0d6 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 () => { @@ -36,7 +36,7 @@ test.describe.serial( }) test('Particular campaign can be opened through the Campaign page', async () => { - await headerPage.clickDonateHeaderNavButton(LanguagesEnum.EN) + await headerPage.clickDonateHeaderNavButton(LanguagesEnum.BG) await campaignsPage.clickCampaignCardByIndex(0) expect( 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..1d1b8647a 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() @@ -45,7 +49,7 @@ test.describe.serial( }) test('Particular campaign can be opened through the Campaign page', async () => { - await headerPage.clickDonateHeaderNavButton() + await headerPage.clickDonateHeaderNavButton(LanguagesEnum.BG) 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} @@ -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 7aa77e06b..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() @@ -46,8 +52,6 @@ test.describe.serial( 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/tsconfig.json b/e2e/tsconfig.json new file mode 100644 index 000000000..0bc1c6ac7 --- /dev/null +++ b/e2e/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "rootDir": "..", + "paths": { + "@src/*": ["./src/*"] + } + }, + "include": ["./e2e", "./src"], + "exclude": ["./node_modules"] +} diff --git a/e2e/utils/fixtures.ts b/e2e/utils/fixtures.ts new file mode 100644 index 000000000..ea13fef30 --- /dev/null +++ b/e2e/utils/fixtures.ts @@ -0,0 +1,45 @@ +import { test, test as base } from '@playwright/test' +import dotenv from 'dotenv' +import fs from 'fs' +import path from 'path' + +dotenv.config({ path: '../.env.local' }) +dotenv.config({ path: '../.env' }) + +const adminEmail = process.env.PODKREPI_EMAIL! +const password = process.env.PODKREPI_PASSWORD! + +const testExtendFn = (useThisEmail: string = adminEmail) => + base.extend({ + storageState: async ({ browser, baseURL }, use) => { + const id = useThisEmail.replace(/\W/, '') + const fileName = path.resolve(test.info().project.outputDir, `.auth/${id}.json`) + + if (fs.existsSync(fileName)) { + // Reuse existing authentication state if any. + await use(fileName) + return + } + const page = await browser.newPage() + await page.goto(`${baseURL}/login`) + + await page.locator('[name=email]').fill(useThisEmail) + await page.locator('[name=password]').fill(password) + + await page.locator('[type=submit]').click() + await page.waitForURL((url) => !url.pathname.includes('login')) + + await page.context().storageState({ path: fileName }) + + await page.close() + + await use(fileName) + }, + }) + +export const adminTest = testExtendFn(adminEmail) + +export const giverTest = testExtendFn('giver@podkrepi.bg') + +/** export the expect for consistency i.e. to be able to do `import { test, expect } from '../utils/fixtures'` */ +export { expect } from 'playwright/test' 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/e2e/utils/texts-localized.ts b/e2e/utils/texts-localized.ts new file mode 100644 index 000000000..1a6ce2911 --- /dev/null +++ b/e2e/utils/texts-localized.ts @@ -0,0 +1,9 @@ +export function textLocalized() { + const campaign = { + bg: async () => await import(`../../public/locales/bg/campaign-application.json`), + en: async () => await import(`../../public/locales/bg/campaign-application.json`), + } + return { + campaign, + } +} diff --git a/manifests/overlays/development/manual/deployment.patch.yaml b/manifests/overlays/development/manual/deployment.patch.yaml index fd5aff954..03e26b725 100644 --- a/manifests/overlays/development/manual/deployment.patch.yaml +++ b/manifests/overlays/development/manual/deployment.patch.yaml @@ -20,3 +20,5 @@ spec: env: - name: FEATURE_CAMPAIGN value: 'true' + - name: STRIPE_PUBLISHABLE_KEY + value: pk_test_51IRdsUKApGjVGa9tYn5oVQwt3rybeIwNHAyWR8nAYd6JaKyTyB4X0g3EgzIEGfqwMYo3ZSlt9gBExTFnVomFTNEO008bjoNXSx diff --git a/next-env.d.ts b/next-env.d.ts index 4f11a03dc..a4a7b3f5c 100644 --- a/next-env.d.ts +++ b/next-env.d.ts @@ -2,4 +2,4 @@ /// // NOTE: This file should not be edited -// see https://nextjs.org/docs/basic-features/typescript for more information. +// see https://nextjs.org/docs/pages/building-your-application/configuring/typescript for more information. 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..90b7753e9 100644 --- a/package.json +++ b/package.json @@ -26,14 +26,15 @@ "sitemap": "next-sitemap" }, "dependencies": { - "@emotion/cache": "^11.7.1", - "@emotion/react": "^11.11.4", + "@emotion/cache": "^11.13.1", + "@emotion/react": "^11.13.3", "@emotion/server": "^11.4.0", - "@emotion/styled": "^11.11.5", + "@emotion/styled": "^11.13.0", "@mdxeditor/editor": "^0.14.2", - "@mui/icons-material": "^5.15.19", + "@mui/icons-material": "^6.1.1", "@mui/lab": "^5.0.0-alpha.170", - "@mui/material": "^5.15.19", + "@mui/material": "^6.1.1", + "@mui/material-nextjs": "^6.1.1", "@mui/styles": "^5.15.19", "@mui/x-data-grid": "^6.16.1", "@mui/x-date-pickers": "^6.16.1", @@ -41,24 +42,26 @@ "@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", + "axios": "^1.7.4", "axios-hooks": "2.7.0", "chart.js": "^4.4.0", "chartjs-plugin-annotation": "^3.0.1", "chartjs-plugin-datalabels": "^2.2.0", "date-fns": "2.24.0", - "dompurify": "^3.0.3", + "dompurify": "^3.1.3", "formik": "2.2.9", + "formik-persist-values": "^1.4.1", "i18next": "^23.5.1", "jwt-decode": "^3.1.2", + "latest": "^0.2.0", "lodash": "^4.17.21", "mobx": "6.3.2", "mobx-react": "7.2.0", - "next": "14.1.1", + "next": "^14.2.13", "next-auth": "^4.24.5", "next-i18next": "^14.0.3", "nookies": "^2.5.2", @@ -121,6 +124,7 @@ "jest": "^29.6.1", "jest-environment-jsdom": "^29.6.1", "lint-staged": "11.0.0", + "msw": "1", "next-sitemap": "^3.1.52", "prettier": "2.3.0", "shx": "^0.3.3", diff --git a/public/finance-reports/Podkrepi.bg_Financial_Report_2023.pdf b/public/finance-reports/Podkrepi.bg_Financial_Report_2023.pdf index d447fdc79..c688f2cf9 100644 Binary files a/public/finance-reports/Podkrepi.bg_Financial_Report_2023.pdf and b/public/finance-reports/Podkrepi.bg_Financial_Report_2023.pdf differ diff --git a/public/finance-reports/Podkrepi.bg_Financial_Semiannual_Report_2022.pdf b/public/finance-reports/Podkrepi.bg_Financial_Semiannual_Report_2022.pdf index e0b5ac297..953d61881 100644 Binary files a/public/finance-reports/Podkrepi.bg_Financial_Semiannual_Report_2022.pdf and b/public/finance-reports/Podkrepi.bg_Financial_Semiannual_Report_2022.pdf differ diff --git a/public/finance-reports/Podkrepi.bg_Financial_Semiannual_Report_2024.pdf b/public/finance-reports/Podkrepi.bg_Financial_Semiannual_Report_2024.pdf index a10bec108..05f872e12 100644 Binary files a/public/finance-reports/Podkrepi.bg_Financial_Semiannual_Report_2024.pdf and b/public/finance-reports/Podkrepi.bg_Financial_Semiannual_Report_2024.pdf differ 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/img/team-photos/ZhivkoNedyalkov.png b/public/img/team-photos/ZhivkoNedyalkov.png new file mode 100644 index 000000000..791f49ab3 Binary files /dev/null and b/public/img/team-photos/ZhivkoNedyalkov.png differ diff --git a/public/locales/bg/about.json b/public/locales/bg/about.json index 5599c5a2f..0aacf585d 100644 --- a/public/locales/bg/about.json +++ b/public/locales/bg/about.json @@ -1,13 +1,14 @@ { "about": { "title": "Кои сме ние?", - "management-board-members": "Членове на управителен съвет", - "supervisory-board-members": "Членове на надзорен съвет", + "management-board-members": "Членове на Управителен съвет", + "supervisory-board-members": "Членове на Надзорен съвет", "active-team-members": "Активни членове на екипа", "association-members": "Членове на сдружението", "linkedIn": "LinkedIn", "about-the-team": "За екипа", "team-description": "Ние сме група доброволци от IT средите, юристи, счетоводители, маркетолози, медици, НПО представители. Обединява ни желанието да създадем подобрена дарителска среда в България, създавайки максимално прозрачна платформа за дарения. Целта ни е платформата да се издържа от членски внос и дарения към нея, а не от процент комисиона от кампаниите, които тя обслужва.", + "avatar-alt-text": "Аватар на член на екипа", "see-less": "Вижте по-малко", "see-more": "Вижте повече" } diff --git a/public/locales/bg/campaign-application.json b/public/locales/bg/campaign-application.json index 022dcd7d8..9d8178c6e 100644 --- a/public/locales/bg/campaign-application.json +++ b/public/locales/bg/campaign-application.json @@ -40,12 +40,15 @@ } }, "photos": "Снимков/видео материал", - "documents": "Документи" + "documents": "Документи", + "disclaimer": "Приемат се до 10 документа. Всеки от документите може да бъде до 30МБ" }, "admin": { "title": "Администраторска редакция", "status": "Статус", - "external-url": "Външен линк" + "external-url": "Външен линк", + "archived": "Архивиран", + "organizer-edit-link": "Организатор може да редактира на" } }, "cta": { @@ -60,5 +63,31 @@ "terms": "Общи условия ", "faq": "Често задавани въпроси" } + }, + "alerts": { + "successfully-created": "Успешно създадена заявка за кампания. " + }, + "result": { + "created": "Успешно създадена заявка за кампания.", + "edited": "Успешно редактирана заявка за кампания.", + "editButton": "Редакция на заявка за кампания", + "uploadOk": "Добавени файлове", + "deleteOk": "Премахнати файлове", + "uploadFailed": "Неуспешно добавени файлове", + "deleteFailed": "Неуспешно премахнати файлове", + "uploadFailedWhat": "Какво мога да направя?", + "uploadFailedDirection": "Моля посетете страницата за редакция на заявката за кампания от бутона по-долу и опитайте отново да добавите/премахнете файловете" + }, + "status": { + "selectorLabel": "Статус", + "review": "Нова за ревю", + "requestInfo": "За още информация", + "forCommitteeReview": "За ревю от комисия", + "approved": "Одобрена", + "denied": "Отказана", + "abandoned": "Изоставена" + }, + "admin": { + "title": "Кандидат Кампании" } } diff --git a/public/locales/bg/campaigns.json b/public/locales/bg/campaigns.json index 824643a5a..729a2fcdb 100644 --- a/public/locales/bg/campaigns.json +++ b/public/locales/bg/campaigns.json @@ -208,7 +208,7 @@ "amount-collected": "Събрана сума", "available": "Налични", "guaranteed": "Гарантирани", - "translated": "Преведени", + "transferred": "Преведени", "accounted": "Отчетени" } } diff --git a/public/locales/bg/common.json b/public/locales/bg/common.json index ae80523fc..88e9f7dc3 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": { @@ -130,6 +135,12 @@ "agree-with-newsletter": "Съгласявам се да получавам известия.", "agree-with-newsletter-campaign": "Съгласявам се да получавам новини за тази кампания и известия от Подкрепи.бг." }, + "files": { + "attached-files": "Прикачени файлове", + "download": "Изтегляне", + "errorDeletingFile": "Грешка при изтриване на файл", + "deletedFile": "Успешно изтрит файл" + }, "cookieConsent": "Подкрепи.бг не използва бисквитки, освен тези от трети страни, нужни за аналитичните компоненти Google Analytics и HotJar. Приемането на бисквитките ще ни помогне да подобрим вашето потребителско преживяване.", "cookieConsentButton": "Приемам", "cookieRejectButton": "Отхвърлям", 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/index.json b/public/locales/bg/index.json index b57fa9166..df25a6e15 100644 --- a/public/locales/bg/index.json +++ b/public/locales/bg/index.json @@ -4,7 +4,8 @@ "team-section": { "heading": "Кой стои зад Подкрепи.бг?", "content": "Подкрепи.бг представлява общност от специалисти в областта на програмирането, правото, маркетинга, дизайна, медицината, финансите, социалното предприемачество и др. Обединени сме от целта да създадем устойчива и прозрачна платформа за дарения, която подкрепя каузи и хора в нужда, като заедно с това популяризира и връща доверието към дарителството в България.", - "meet-our-team": "Запознайте се с екипа ни" + "meet-our-team": "Запознайте се с екипа ни", + "team-image-alt-text": "Изображение на екипа" }, "subscription-section": { "heading": "Искате да сте в час с бъдещите ни постижения?", 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/about-project.json b/public/locales/en/about-project.json index 11aafe80f..dc6456c61 100644 --- a/public/locales/en/about-project.json +++ b/public/locales/en/about-project.json @@ -3,7 +3,7 @@ "aboutPlatformTitle": "What is the platform Podkrepi.bg", "aboutPlatformDescription": "We want to create an open-source charity platform, in which the transparency removes the chances of misuse of the donated funds, both by campaign organizers and by the internal team of the donation platform.", "whatIsDoneTitle": "What has been done already", - "members": "members of the association", + "members": "members of the Association", "meetingsIcon": "meetings held", "investedHoursIcon": "hours invested", "architecture": "Assembled platform architecture", diff --git a/public/locales/en/about.json b/public/locales/en/about.json index fbcbd41d4..670bb24b7 100644 --- a/public/locales/en/about.json +++ b/public/locales/en/about.json @@ -2,12 +2,13 @@ "about": { "title": "Who are we?", "management-board-members": "Management Board members", - "supervisory-board-members": "Supervisory Board Members", + "supervisory-board-members": "Supervisory Board members", "active-team-members": "Active team members", - "association-members": "Members of the association", + "association-members": "Members of the Association", "linkedIn": "LinkedIn", "about-the-team": "About the team", "team-description": "We are volunteers from the IT community, lawyers, accountants, marketers, medics, and NGO representatives. We are united by the desire to create an improved fundraising environment in Bulgaria by developing the most transparent platform for donations. The platform will support itself by membership fees and grants and not by charging a percentage of the funds raised for the campaigns that go through it.", + "avatar-alt-text": "Team member avatar", "see-less": "See less", "see-more": "See more" } diff --git a/public/locales/en/campaign-application.json b/public/locales/en/campaign-application.json index c8d3170ba..175d24561 100644 --- a/public/locales/en/campaign-application.json +++ b/public/locales/en/campaign-application.json @@ -40,12 +40,15 @@ } }, "photos": "Photo/Video material", - "documents": "Documents" + "documents": "Documents", + "disclaimer": "Up to 10 documents accepted. Each of the documents can be up to 30MB is size" }, "admin": { "title": "Admin edit", "status": "Status", - "external-url": "External URL" + "external-url": "External URL", + "archived": "Archived", + "organizer-edit-link": "Organizer can edit at" } }, "cta": { @@ -60,5 +63,31 @@ "terms": "Terms and Conditions ", "faq": "Frequently Asked Questions" } + }, + "alerts": { + "successfully-created": "Campaign application successfully created." + }, + "result": { + "created": "Successfully created campaign application", + "edited": "Successfully edited campaign application", + "editButton": "Edit campaign application", + "uploadOk": "Fails added", + "deleteOk": "Files removed", + "uploadFailed": "Failed file add", + "deleteFailed": "Failed to remove files", + "uploadFailedWhat": "What can I do?", + "uploadFailedDirection": "Please visit the campaign application edit page linked below and retry adding/removing the files." + }, + "status": { + "selectorLabel": "Status", + "review": "New - for review", + "requestInfo": "Request for more info", + "forCommitteeReview": "For committee review", + "approved": "Approved", + "denied": "Denied", + "abandoned": "Abandoned" + }, + "admin": { + "title": "Campaign Applications" } } diff --git a/public/locales/en/campaigns.json b/public/locales/en/campaigns.json index 426417d3c..98aa64097 100644 --- a/public/locales/en/campaigns.json +++ b/public/locales/en/campaigns.json @@ -208,7 +208,7 @@ "amount-collected": "Amount Collected", "available": "Аvailable", "guaranteed": "Guaranteed", - "translated": "Translated", + "transferred": "Transferred", "accounted": "Accounted" } } diff --git a/public/locales/en/common.json b/public/locales/en/common.json index d0af58071..dfc66738d 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": { @@ -130,6 +135,12 @@ "agree-with-newsletter": "I agree to receive news.", "agree-with-newsletter-campaign": "I agree to receive news about this campaign and news by Podkrepi.bg." }, + "files": { + "attached-files": "Attached files", + "download": "Download", + "errorDeletingFile": "Failure deleting file", + "deletedFile": "Successfully deleted file" + }, "cookieConsent": "Podkrepi.bg doesn't use cookies, except the third-party cookies required for the analytics components Google Analytics and HotJar. Accepting the cookies will help us improve your user experience.", "cookieConsentButton": "Accept", 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/index.json b/public/locales/en/index.json index 279115fe7..9ff413cf1 100644 --- a/public/locales/en/index.json +++ b/public/locales/en/index.json @@ -4,7 +4,8 @@ "team-section": { "heading": "Who is behind Podkrepi.bg?", "content": "Podkrepi.bg is a community of specialists in the field of programming, law, marketing, design, medicine, finance, social entrepreneurship and others. We are united by the goal of creating a sustainable and transparent donation platform that supports causes and people in need, while promoting and restoring trust in donations in Bulgaria.", - "meet-our-team": "Meet our team" + "meet-our-team": "Meet our team", + "team-image-alt-text": "Team image" }, "subscription-section": { "heading": "You want to know about our future achievements?", 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/createEmotionCache.ts b/src/common/createEmotionCache.ts deleted file mode 100644 index 89fc4f66d..000000000 --- a/src/common/createEmotionCache.ts +++ /dev/null @@ -1,5 +0,0 @@ -import createCache from '@emotion/cache' - -export default function createEmotionCache() { - return createCache({ key: 'css', prepend: true }) -} 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 6f03310d0..fd08d4bf3 100644 --- a/src/common/routes.ts +++ b/src/common/routes.ts @@ -92,9 +92,12 @@ export const routes = { index: '/campaigns', create: '/campaigns/create', application: 'campaigns/application', + applicationEdit: (id: string) => `/campaigns/application/${id}`, 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..de17324e4 100644 --- a/src/common/theme.ts +++ b/src/common/theme.ts @@ -37,6 +37,9 @@ const colors = { white: { main: '#ffffff', }, + red: { + error: '#ED1D1D', + }, } const borders = { @@ -67,6 +70,9 @@ export const themeOptions: ThemeOptions = { light: colors.blue.mainDark, dark: darken(colors.blue.dark, 0.2), }, + error: { + main: colors.red.error, + }, }, shape: { borderRadius: 3, diff --git a/src/components/admin/campaign-applications/CampaignApplicationAdminPropsEdit.tsx b/src/components/admin/campaign-applications/CampaignApplicationAdminPropsEdit.tsx index b1a49e689..0bfaeec3a 100644 --- a/src/components/admin/campaign-applications/CampaignApplicationAdminPropsEdit.tsx +++ b/src/components/admin/campaign-applications/CampaignApplicationAdminPropsEdit.tsx @@ -1,33 +1,56 @@ import { useTranslation } from 'next-i18next' import { Grid } from '@mui/material' +import { StatusSelector } from 'components/client/campaign-application/helpers/campaign-application-status' import { StyledFormTextField, StyledStepHeading, } from 'components/client/campaign-application/helpers/campaignApplication.styled' +import { CamAppDetail } from 'components/client/campaign-application/steps/CampaignApplicationSummary' +import CheckboxField from 'components/common/form/CheckboxField' +import OrganizerCanEditAt from './CampaignApplicationOrganizerCanEditAt' +import { UploadedFile } from 'components/common/file-upload/UploadedFilesList' +import UploadedCampaignApplicationFiles from './UploadedCampaignApplicationFiles' -export default function CampaignApplicationAdminPropsEdit() { +export default function CampaignApplicationAdminPropsEdit({ + id, + files, +}: { + id: string + files: UploadedFile[] +}) { const { t } = useTranslation('campaign-application') - return ( {t('steps.admin.title')} - - + + + + + - + + + } + /> + + + + ) } diff --git a/src/components/admin/campaign-applications/CampaignApplicationOrganizerCanEditAt.tsx b/src/components/admin/campaign-applications/CampaignApplicationOrganizerCanEditAt.tsx new file mode 100644 index 000000000..71efd59d5 --- /dev/null +++ b/src/components/admin/campaign-applications/CampaignApplicationOrganizerCanEditAt.tsx @@ -0,0 +1,23 @@ +import { routes } from 'common/routes' +import { CopyTextButton } from 'components/common/CopyTextButton' +import getConfig from 'next/config' +import Copy from '@mui/icons-material/CopyAll' +import { Typography } from '@mui/material' +export type Props = { + id: string +} +const OrganizerCanEditAt = ({ id }: Props) => { + const { publicRuntimeConfig } = getConfig() + const url = `${publicRuntimeConfig?.APP_URL}${routes.campaigns.applicationEdit(id)}` + + return ( + <> + + {url} + + } text={url} title={`Copy ${url}`} /> + + ) +} + +export default OrganizerCanEditAt diff --git a/src/components/admin/campaign-applications/CampaignApplications.tsx b/src/components/admin/campaign-applications/CampaignApplications.tsx index b808da677..e946a97c4 100644 --- a/src/components/admin/campaign-applications/CampaignApplications.tsx +++ b/src/components/admin/campaign-applications/CampaignApplications.tsx @@ -5,11 +5,11 @@ import AdminLayout from 'components/common/navigation/AdminLayout' import CampaignApplicationsGrid from './CampaignApplicationsGrid' export default function CampaignApplicationsPage() { - const { t } = useTranslation('campaigns') + const { t } = useTranslation('campaign-application') return ( - + diff --git a/src/components/admin/campaign-applications/CampaignApplicationsGrid.tsx b/src/components/admin/campaign-applications/CampaignApplicationsGrid.tsx index a4e1e3473..4d230a824 100644 --- a/src/components/admin/campaign-applications/CampaignApplicationsGrid.tsx +++ b/src/components/admin/campaign-applications/CampaignApplicationsGrid.tsx @@ -1,12 +1,18 @@ import { useTranslation } from 'next-i18next' import { DataGrid, GridColDef, GridRenderCellParams } from '@mui/x-data-grid' +import { useQuery } from '@tanstack/react-query' import { routes } from 'common/routes' import theme from 'common/theme' +import { CampaignApplicationAdminResponse } from 'gql/campaign-applications' +import { useSession } from 'next-auth/react' import Link from 'next/link' +import { endpoints } from 'service/apiEndpoints' +import { authQueryFnFactory } from 'service/restRequests' export default function CampaignApplicationsGrid() { - const { t, i18n } = useTranslation() + const { t } = useTranslation('campaign-application') + const { list } = useCampaignsList() const commonProps: Partial = { align: 'left', @@ -16,33 +22,34 @@ export default function CampaignApplicationsGrid() { const columns: GridColDef[] = [ { - field: 'status', + field: 'state', headerName: t('campaigns:status'), ...commonProps, align: 'left', width: 220, + renderCell: (cellValues: GridRenderCellParams) => t(`status.${cellValues.row.state}`), }, { - field: 'title', + field: 'campaignName', headerName: t('campaigns:title'), ...commonProps, align: 'left', width: 250, renderCell: (cellValues: GridRenderCellParams) => ( - {cellValues.row.title} + {cellValues.row.campaignName} ), }, { - field: 'essence', + field: 'goal', headerName: t('campaigns:essence'), ...commonProps, align: 'left', width: 250, }, { - field: 'organizer', + field: 'organizerName', headerName: t('campaigns:organizer'), ...commonProps, align: 'left', @@ -71,65 +78,12 @@ export default function CampaignApplicationsGrid() { }, ] - const data = [ - { - updatedAt: 'date', - createdAt: '2024-5-5', - beneficiary: 'beneficiary', - organizer: 'organizer', - essence: 'essence', - title: 'title', - id: '1', - status: 'нова', - }, - { - updatedAt: 'yesterday', - createdAt: '10 days ago', - beneficiary: 'beneficiary', - organizer: 'organizer', - essence: 'essence', - title: 'title', - id: '2', - status: 'очаква документи', - }, - { - updatedAt: '', - createdAt: '', - beneficiary: 'beneficiary', - organizer: 'organizer', - essence: 'essence', - title: 'title', - id: '3', - status: 'очаква решение на комисия', - }, - - { - updatedAt: '', - createdAt: '', - beneficiary: 'beneficiary', - organizer: 'organizer', - essence: 'essence', - title: 'title', - id: '4', - status: 'одобрена', - }, - { - updatedAt: '', - createdAt: '', - beneficiary: 'beneficiary', - organizer: 'organizer', - essence: 'essence', - title: 'title', - id: '4', - status: 'отказана', - }, - ] return ( ) } + +function fetchMutation() { + const { data } = useSession() + return useQuery( + [endpoints.campaignApplication.listAllForAdmin.url], + authQueryFnFactory(data?.accessToken), + { + cacheTime: 10 * 60 * 1000, + staleTime: 10 * 60 * 1000, + }, + ) +} + +export const useCampaignsList = () => { + const { data, isLoading } = fetchMutation() + + return { + list: data?.sort((a, b) => b?.updatedAt?.localeCompare(a?.updatedAt ?? '') ?? 0), + isLoading, + } +} diff --git a/src/components/admin/campaign-applications/EditPage.tsx b/src/components/admin/campaign-applications/EditPage.tsx index 19cb1fe0b..76aa23936 100644 --- a/src/components/admin/campaign-applications/EditPage.tsx +++ b/src/components/admin/campaign-applications/EditPage.tsx @@ -1,41 +1,121 @@ -import { Box } from '@mui/material' -import CampaignApplication from 'components/client/campaign-application/steps/CampaignApplication' +import { Box, CircularProgress, Grid, Typography } from '@mui/material' +import { red } from '@mui/material/colors' +import { CampaignApplicationFormData } from 'components/client/campaign-application/helpers/campaignApplication.types' +import { ActionSubmitButton } from 'components/client/campaign-application/helpers/campaignApplicationFormActions.styled' +import { campaignApplicationAdminValidationSchema } from 'components/client/campaign-application/helpers/validation-schema' +import CampaignApplicationBasic from 'components/client/campaign-application/steps/CampaignApplicationBasic' import CampaignApplicationDetails from 'components/client/campaign-application/steps/CampaignApplicationDetails' import CampaignApplicationOrganizer from 'components/client/campaign-application/steps/CampaignApplicationOrganizer' +import CampaignApplicationSummary, { + CamAppDetail, +} from 'components/client/campaign-application/steps/CampaignApplicationSummary' import GenericForm from 'components/common/form/GenericForm' import AdminContainer from 'components/common/navigation/AdminContainer' import AdminLayout from 'components/common/navigation/AdminLayout' +import { CampaignApplicationExisting } from 'gql/campaign-applications' +import { useTranslation } from 'next-i18next' +import NotFoundPage from 'pages/404' +import { + mapCreateOrEditInput, + useCreateOrEditApplication, + useViewCampaignApplicationCached, +} from 'service/campaign-application' import CampaignApplicationAdminPropsEdit from './CampaignApplicationAdminPropsEdit' -import { CampaignApplicationAdminEdit } from './campaignApplicationAdmin.types' +import OrganizerCanEditAt from './CampaignApplicationOrganizerCanEditAt' -export default function EditPage() { - const initialValues = { - organizer: { - name: 'Some organizer', - phone: '+35999999', - email: 'aReal@Email.com', - }, - - status: 'review', - ticketUrl: 'https://trello.com/this-campaign-application', - } as CampaignApplicationAdminEdit +export type Props = { + id: string +} +export function EditLoadedCampaign({ campaign }: { campaign: CampaignApplicationExisting }) { + const { createOrUpdateApplication, ...c } = useCreateOrEditApplication({ + isEdit: true, + campaignApplication: campaign, + }) + const { t } = useTranslation('campaign-application') return ( - - onSubmit={(v) => console.log(v)} - initialValues={initialValues}> - -
.
- -
.
- -
.
- - - + {c.createOrUpdateSuccessful ? ( + + + + + Admin props / Административни подробности + + + } + /> + + + + + + } + /> + ) : ( + + onSubmit={async (v) => { + const request = mapCreateOrEditInput(v) + await createOrUpdateApplication(request) + }} + initialValues={c.initialValues} + validationSchema={campaignApplicationAdminValidationSchema.defined()}> + + + + + + {c.error && + c.error.map((e, i) => ( + + {e} + + ))} + + )} +
) } + +export default function EditPage({ id }: Props) { + const { data, isLoading, isError } = useViewCampaignApplicationCached(id, 60 * 1000) + + if (isLoading) { + return ( + + + + ) + } + + if (isError) { + return + } + + return +} diff --git a/src/components/admin/campaign-applications/UploadedCampaignApplicationFiles.tsx b/src/components/admin/campaign-applications/UploadedCampaignApplicationFiles.tsx new file mode 100644 index 000000000..457dcbf7b --- /dev/null +++ b/src/components/admin/campaign-applications/UploadedCampaignApplicationFiles.tsx @@ -0,0 +1,43 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { AxiosError } from 'axios' +import { useTranslation } from 'next-i18next' + +import { endpoints } from 'service/apiEndpoints' +import { ApiErrors } from 'service/apiErrors' + +import { UploadedFile, UploadedFilesList } from 'components/common/file-upload/UploadedFilesList' + +import { useSession } from 'next-auth/react' +import { + fetchCampaignApplicationFile, + useDeleteCampaignApplicationFile, +} from 'service/campaign-application' +import { AlertStore } from 'stores/AlertStore' + +type Props = { + campaignApplicationId: string + files: UploadedFile[] +} + +export default function UploadedCampaignApplicationFiles({ files, campaignApplicationId }: Props) { + const { t } = useTranslation(['common', 'campaign-applications']) + const queryClient = useQueryClient() + const { data: session } = useSession() + + const del = useMutation, string>({ + mutationFn: (fileId) => useDeleteCampaignApplicationFile(session)(fileId), + onError: () => AlertStore.show(t('common:alerts.errorDeletingFile'), 'error'), + onSuccess: () => { + AlertStore.show(t('common:files.deletedFile'), 'success') + queryClient.invalidateQueries([endpoints.campaignApplication.view(campaignApplicationId).url]) + }, + }) + + return ( + fetchCampaignApplicationFile(f.id, session).then((r) => r.data)} + deleteMutation={(f) => del.mutateAsync(f.id)} + /> + ) +} diff --git a/src/components/admin/campaign-applications/campaignApplicationAdmin.types.ts b/src/components/admin/campaign-applications/campaignApplicationAdmin.types.ts deleted file mode 100644 index 66722ada4..000000000 --- a/src/components/admin/campaign-applications/campaignApplicationAdmin.types.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { CampaignApplicationFormData } from 'components/client/campaign-application/helpers/campaignApplication.types' - -export type CampaignApplicationAdminEdit = CampaignApplicationFormData & { - status: string - ticketUrl?: string -} diff --git a/src/components/admin/donations/grid/Grid.tsx b/src/components/admin/donations/grid/Grid.tsx index ead4a5fe5..dd40b655d 100644 --- a/src/components/admin/donations/grid/Grid.tsx +++ b/src/components/admin/donations/grid/Grid.tsx @@ -181,7 +181,6 @@ export default observer(function Grid() { }, { field: 'paymentId', - //TODO:Ttranslate headerName: 'Плащане номер', width: 150, renderCell: (params: GridRenderCellParams) => { @@ -192,7 +191,6 @@ export default observer(function Grid() { }, { field: 'payment.status', - //TODO:Ttranslate headerName: 'Статус на плащане', renderCell(params) { return params.row.payment?.status @@ -200,7 +198,6 @@ export default observer(function Grid() { }, { field: 'payment.provider', - //TODO:Ttranslate headerName: 'Разплащателна система', renderCell(params) { return params.row.payment?.provider @@ -215,7 +212,6 @@ export default observer(function Grid() { }, { field: 'payment.billingName', - //TODO:Ttranslate headerName: 'billingName', width: 250, renderCell(params) { @@ -224,7 +220,6 @@ export default observer(function Grid() { }, { field: 'payment.billingEmail', - //TODO:Ttranslate headerName: 'billingEmail', width: 300, renderCell(params) { diff --git a/src/components/admin/partners/helpers/guarantsData.ts b/src/components/admin/partners/helpers/guarantsData.ts index 60ab09692..b6aeaec40 100644 --- a/src/components/admin/partners/helpers/guarantsData.ts +++ b/src/components/admin/partners/helpers/guarantsData.ts @@ -38,6 +38,6 @@ export const data: TeamData[] = [ { img: '/img/partners/guarants/IvayloMarinov.png', name: 'Ивайло Маринов', - description: 'Кмет на община Аспарухово', + description: 'Кмет на район Аспарухово', }, ] diff --git a/src/components/admin/partners/helpers/mediaAboutUsData.ts b/src/components/admin/partners/helpers/mediaAboutUsData.ts index 63517cefb..65190d79e 100644 --- a/src/components/admin/partners/helpers/mediaAboutUsData.ts +++ b/src/components/admin/partners/helpers/mediaAboutUsData.ts @@ -38,7 +38,7 @@ export const articles: ArticleData[] = [ subtitle: 'Нова дигитална платформа стимулира дарителската култура в България', description: '“От началото на месец август стартира нова българска дарителска платформа, подкрепяща нуждаещите се и организациите, които им помагат. Тя се нарича Подкрепи.бг и...”', - url: 'https://darik.bg/darik.bg/nova-digitalna-platforma-stimulira-daritelskata-kultura-v-balgaria', + url: 'https://darik.bg/nova-digitalna-platforma-stimulira-daritelskata-kultura-v-balgaria', }, { img: '/img/partners/mediaAboutUs/mediapool.svg', diff --git a/src/components/client/about/helpers/activeMembersData.tsx b/src/components/client/about/helpers/activeMembersData.tsx index 3ff10290d..b3c521ab7 100644 --- a/src/components/client/about/helpers/activeMembersData.tsx +++ b/src/components/client/about/helpers/activeMembersData.tsx @@ -66,12 +66,6 @@ export const data: TeamData[] = [ description: 'Маркетинг', linkedInProfile: 'https://www.linkedin.com/in/neli-tancheva-703193169/', }, - { - img: '/img/team-photos/SlavchoIvanov.png', - name: 'Славчо Иванов', - description: 'Софтуерна разработка', - linkedInProfile: 'https://www.linkedin.com/in/slavchoivanov/', - }, { img: '/img/team-photos/HristiyanHristov.png', name: 'Християн Христов', @@ -120,12 +114,6 @@ export const data: TeamData[] = [ description: 'Маркетинг', linkedInProfile: 'https://www.linkedin.com/in/dessislava-pencheva-emba-24369311/', }, - { - img: '/img/team-photos/ZdravkaGornachka.png', - name: 'Здравка Горначка', - description: 'Кампании', - linkedInProfile: 'https://www.linkedin.com/in/zdravka-gornachka-3a0b3510/', - }, { img: '/img/team-photos/PetyaDimitrova.png', name: 'Петя Димитрова', @@ -168,4 +156,10 @@ export const data: TeamData[] = [ description: 'Продуктов дизайн', linkedInProfile: 'https://www.linkedin.com/in/digitallymarked', }, + { + img: '/img/team-photos/ZhivkoNedyalkov.png', + name: 'Живко Недялков', + description: 'Софтуерно тестване', + linkedInProfile: 'https://www.linkedin.com/in/zhivko-nedyalkov-b44a93111/', + }, ] diff --git a/src/components/client/about/helpers/associationMembersData.tsx b/src/components/client/about/helpers/associationMembersData.tsx index f372d59c7..435d51168 100644 --- a/src/components/client/about/helpers/associationMembersData.tsx +++ b/src/components/client/about/helpers/associationMembersData.tsx @@ -52,7 +52,6 @@ export const data: TeamData[] = [ name: 'Атанас Сарафов', linkedInProfile: 'https://www.linkedin.com/in/atanas-sarafov-192a6889/', }, - { img: '/img/team-photos/PreslavGerchev.png', name: 'Преслав Герчев', diff --git a/src/components/client/about/helpers/managementBoardData.tsx b/src/components/client/about/helpers/managementBoardData.tsx index 470a90351..a7fc1de50 100644 --- a/src/components/client/about/helpers/managementBoardData.tsx +++ b/src/components/client/about/helpers/managementBoardData.tsx @@ -7,51 +7,51 @@ export type TeamData = { export const data: TeamData[] = [ { - img: '/img/team-photos/StankaCherkezova.jpg', - name: 'Станка Черкезова-Калайджиева', + img: '/img/team-photos/RadoslavBozhinov.jpg', + name: 'Радослав Божинов', description: - 'Ръководител Правен Eкип. Близо 15 години подпомагам бизнеси и отделни физически лица с консултации и процесуално представителство в сферата на търговското и гражданското право, включително в казуси с международен елемент. Имам богат юридически опит както в България, така и в чужбина, с работа като външен консултант по различни проекти, свързани с национално и европейско право.', - linkedInProfile: 'https://www.linkedin.com/in/stanka-cherkezova-b2b5845/', + 'Дизайн Лийд в British Telecom (BT), отговорен за стратегията и дизайн екипа към BT - индиректен партньорски онлайн портал. Работил съм в БНТ, маркетинг агенции, няколко английски студиа и компании преди BT. Занимавам се с дизайн от 2007 насам. Участвал съм в набирането на средства за земетресението в Турция, BBC, деца в нужда.', + linkedInProfile: 'https://www.linkedin.com/in/radoslavbozhinov/', }, { - img: '/img/team-photos/AnaNikolova.png', - name: 'Ана Николова', + img: '/img/team-photos/DianaDobreva.jpg', + name: 'Диана Добрева', description: - 'Доктор по Управление на международни проекти. 12 години опит в неправителствения сектор и развитието на младежко лидерство и предприемачество. Помагам на стартиращи и малки бизнеси със силен фокус към иновациите да развият своите идеи в бизнес начинания, чрез развитие на концепции, проектно планиране и управление и достъп до финансиране. Опитът ми в планирането и управлението на проекти ме доведе също до това да стана и външен експерт-оценител по програми на Европейската комисия и да правя обучения по тази и други теми, свързани с развитие на бизнеса.', - linkedInProfile: 'https://www.linkedin.com/in/anatnikolova/', + 'Над 20 год. опит в организация и управление на финансови потоци, вкл. развитие на 3 новостартиращи компании в БГ със съсредоточен голям финансов ресурс. В последните две години подпомагам стартиращи дружества, които получават инвестиции, за да оптимизират финансовите потоци, така че да прескочат 87%-ния риск от неуспех вследствие на недобро планиране и разпределение на ресурсите. Обичаща природата и водеща йогийски начин на живот, в който основно място заема дарителството и помощта към другите.', + linkedInProfile: 'https://www.linkedin.com/in/diana-dobreva-acca-5749b520/', }, { img: '/img/team-photos/MarianaKaroleva.jpg', name: 'Марияна Каролева', description: - 'От години се занимавам с доброволчество, като подкрепям каузи на различни организации, и в качеството си на частно лице помагам със средства, време и внимание на хора в нужда. Като ръководител на Екип Кампании участвам в процеса по структуриране и управление на кампаниите и платформата, в изграждането на различните експертни съвети и набирането на членовете им и в избора и обработката на първите кампании.', + 'От години се занимавам с доброволчество, като подкрепям каузи на различни организации, и в качеството си на частно лице помагам със средства, време и внимание на хора в нужда. Като ръководител на eкип Кампании участвам в процеса по структуриране и управление на кампаниите и платформата, в изграждането на различните експертни съвети и набирането на членовете им и в избора и обработката на първите кампании.', }, { - img: '/img/team-photos/JulianKalderon.png', - name: 'Юлиян Калдерон', + img: '/img/team-photos/GeorgiIvanov.jpg', + name: 'Георги Иванов', description: - "Над 20 години опит като предприемач. Издател на Gamers' Workshop и основател на БГСервиз, където вече 16 години развиваме бизнес софтуерния продукт nZoom. С него помагаме на фирмите да бъдат по-успешни, чрез дигитализация и ефективно използване на информационните технологии. Имам натрупан значителен опит в развитието на софтуерни продукти, който, надявам се, ще бъде от полза в организационен и чисто оперативен план за Подкрепи.бг.", - linkedInProfile: 'https://www.linkedin.com/in/jucalderon/', + 'Занимавам се с предприемачество от над 12 години. Основател на Noble Hire, Conf.ai, Club-Mate Bulgaria, а преди това и на Enhancv. По образование съм геолог. През последните близо 4 години Подкрепи.бг е неизменна част от живота ми.', + linkedInProfile: 'https://www.linkedin.com/in/joroivanoff/', }, { - img: '/img/team-photos/DianaDobreva.jpg', - name: 'Диана Добрева', + img: '/img/team-photos/ZdravkaGornachka.png', + name: 'Здравка Горначка', description: - 'Над 20 год. опит в организация и управление на финансови потоци, вкл. развитие на 3 новостартиращи в БГ компании със съсредоточен голям финансов ресурс. В последните две години подпомагам стартиращи дружества, които получават инвестиции за да оптимизират финансовите потоци така, че да прескочат 87%-ния риск от неуспех вследствие на недобро планиране и разпределение на ресурсите. Обичаща природата и водеща йогийски начин на живот, в който основно място заема дарителството и помощта към другите.', - linkedInProfile: 'https://www.linkedin.com/in/diana-dobreva-acca-5749b520/', + 'Занимавам се с обезпечаването на непрекъсваемостта на критични бизнес приложения от 2007 година, като през последните седем години съм изцяло фокусирана в сферата на прецизната и индустриална климатизация. Съосновател съм на Кулинг Пауър Сълюшънс. По образование съм PR и MBA, с голям интерес към динамиката в малките екипи, ефективното управление и скалиране на процеси. Изключително любопитна съм към новите възможности за колаборации и самоуправление, които предлага дигиталната ера. Вярвам в ненасилствената комуникация, обичам планината, запален скиор съм и доброволец в екип Кампании.', + linkedInProfile: 'https://www.linkedin.com/in/zdravka-gornachka-3a0b3510/', }, { - img: '/img/team-photos/RadoslavBozhinov.jpg', - name: 'Радослав Божинов', + img: '/img/team-photos/IvanMilchev.jpg', + name: 'Иван Милчев', description: - 'Дизайн Лийд в British Telecom (BT), отговорен за стратегията и дизайн екипа към BT индиректен партньорски онлайн портал. Работил съм в БНТ, маркетинг агенции, няколко Английски студиа и компании преди BT. Занимавам се с дизайн-а от 2007 насам. Участвал съм в набирането на средства за земетресението в Турция, BBC деца в нужда.', - linkedInProfile: 'https://www.linkedin.com/in/radoslavbozhinov/', + 'Програмист с над 10 години опит в различни софтуерни направления. По-голямата част от опита ми е придобит в Нидерландия, където съм работил за една от най-проспериращите компании в сферата на производството. От 2 години съм част от Mondoo, където разработваме софтуер за кибер сигурност. От скоро се завърнах в България и мисля да продължа развитието си тук.', + linkedInProfile: 'https://www.linkedin.com/in/ivanmilchev/', }, { - img: '/img/team-photos/IvanGoychev.jpg', - name: 'Иван Гойчев', + img: '/img/team-photos/SlavchoIvanov.png', + name: 'Славчо Иванов', description: - 'Технологичен мениджър с над 15 години опит в създаване на успешни стартъпи и реализиране на големи софтуерни продукти. Като технически директор съм водил развитието на 2 успешни стартъпа във финансовата и самолетната индустрия. Натрупах сериозен опит в огромни компании като Amazon AWS, а сега водя софтуерния отдел на Кобилдър, където дигитализираме строителната индустрия.', - linkedInProfile: 'https://www.linkedin.com/in/igoychev/', + 'Програмист, който се е борил с бъгове в много стартъпи в продължение на повече от 20 години. В момента CTO на индийска IT компания и начинаещ доброволец.', + linkedInProfile: 'https://www.linkedin.com/in/slavchoivanov/', }, ] diff --git a/src/components/client/about/helpers/supervisoryBoardData.tsx b/src/components/client/about/helpers/supervisoryBoardData.tsx index 19b118066..c982d6161 100644 --- a/src/components/client/about/helpers/supervisoryBoardData.tsx +++ b/src/components/client/about/helpers/supervisoryBoardData.tsx @@ -7,24 +7,38 @@ export type TeamData = { export const data: TeamData[] = [ { - img: '/img/team-photos/GeorgiMalchev.jpg', - name: 'Георги Малчев', - linkedInProfile: 'https://www.linkedin.com/in/georgimalchev/', + img: '/img/team-photos/IvanGoychev.jpg', + name: 'Иван Гойчев', + linkedInProfile: 'https://www.linkedin.com/in/igoychev/', description: - 'Преподавател, Съдружник в агенция Xplora.bg. Член на управителния съвет на IAB Bulgaria. Маркетингът е моето призвание. Гордея се, че за почти 6 години от основаването си агенцията за интегриран дигитален маркетинг Xplora, в която съм управляващ съдружник, е сред водещите дигитални агенции в България. Имаме над 90 текущи клиенти - български и мултинационални компании, които са сред топ 3 в сектора си в областта на финансите, IT и технологичните решения, хранително-вкусовата промишленост и търговията.', + 'Технологичен мениджър с над 15 години опит в създаване на успешни стартъпи и реализиране на големи софтуерни продукти. Като технически директор съм водил развитието на 2 успешни стартъпа във финансовата и самолетната индустрия. Натрупах сериозен опит в огромни компании като Amazon AWS, а сега водя софтуерния отдел на Кобилдър, където дигитализираме строителната индустрия.', }, { - img: '/img/team-photos/MartinKovachev.jpg', - name: 'Мартин Ковачев', - linkedInProfile: 'https://www.linkedin.com/in/martin-kovachev-56984012/', + img: '/img/team-photos/AnaNikolova.png', + name: 'Ана Николова', + linkedInProfile: 'https://www.linkedin.com/in/anatnikolova/', description: - 'Кариерата си започнах пред вече далечната 2001г. От 2007г. стартирах собствена компания за софтуерни разработки и оттогава имаме опит в разнородни проекти с дългосрочни клиенти, основно от чужбина (Испания, САЩ, Германия и др). Всичко разработваме самостоятелно с inhouse екип и много рядко наемаме външни лица. Основната ни насоченост е разработване на мобилни приложения и intranet web платформи.', + 'Доктор по Управление на международни проекти. 12 години опит в неправителствения сектор и развитието на младежко лидерство и предприемачество. Помагам на стартиращи и малки бизнеси със силен фокус към иновациите да развият своите идеи в бизнес начинания чрез развитие на концепции, проектно планиране и управление и достъп до финансиране. Опитът ми в планирането и управлението на проекти ме доведе също до това да стана и външен експерт-оценител по програми на Европейската комисия и да правя обучения по тази и други теми, свързани с развитие на бизнеса.', + }, + { + img: '/img/team-photos/JulianKalderon.png', + name: 'Юлиян Калдерон', + linkedInProfile: 'https://www.linkedin.com/in/jucalderon/', + description: + "Над 20 години опит като предприемач. Издател на Gamers' Workshop и основател на БГСервиз, където вече 16 години развиваме бизнес софтуерния продукт nZoom. С него помагаме на фирмите да бъдат по-успешни, чрез дигитализация и ефективно използване на информационните технологии. Имам натрупан значителен опит в развитието на софтуерни продукти, които, надявам се, ще бъде от полза в организационен и чисто оперативен план за Подкрепи.бг.", }, { img: '/img/team-photos/IvayloIvanov.jpg', name: 'Ивайло Иванов', - linkedInProfile: 'https://www.linkedin.com/in/idivanov/', description: 'Занимавам се с дигитална трансформация на бизнес процеси от 1998г. с клиенти на международния пазар. Основател и CEO на SoftConsultGroup, създал и участвал в редица стартъп компании.', + linkedInProfile: 'https://www.linkedin.com/in/idivanov/', + }, + { + img: '/img/team-photos/StankaCherkezova.jpg', + name: 'Станка Черкезова-Калайджиева', + linkedInProfile: 'https://www.linkedin.com/in/stanka-cherkezova-b2b5845/', + description: + 'Ръководител Правен Eкип. Близо 15 години подпомагам бизнеси и отделни физически лица с консултации и процесуално представителство в сферата на търговското и гражданското право, включително в казуси с международен елемент. Имам богат юридически опит както в България, така и в чужбина, с работа като външен консултант по различни проекти, свързани с национално и европейско право.', }, ] diff --git a/src/components/client/about/sections/AboutTheTeamSection/AboutTheTeamSection.tsx b/src/components/client/about/sections/AboutTheTeamSection/AboutTheTeamSection.tsx index 2950aab75..70defd19c 100644 --- a/src/components/client/about/sections/AboutTheTeamSection/AboutTheTeamSection.tsx +++ b/src/components/client/about/sections/AboutTheTeamSection/AboutTheTeamSection.tsx @@ -1,9 +1,7 @@ -import Image from 'next/image' import { useTranslation } from 'next-i18next' import { Grid, Typography } from '@mui/material' -import { DiscordTeamImage } from './AboutTheTeamSection.styled' import { AboutHeading } from 'components/client/about/AboutPage.styled' export default function AboutTheTeamSection() { diff --git a/src/components/client/about/sections/ActiveMembersSection/ActiveMembersSection.tsx b/src/components/client/about/sections/ActiveMembersSection/ActiveMembersSection.tsx index 9a52bd736..3bf680df5 100644 --- a/src/components/client/about/sections/ActiveMembersSection/ActiveMembersSection.tsx +++ b/src/components/client/about/sections/ActiveMembersSection/ActiveMembersSection.tsx @@ -24,7 +24,12 @@ export default function ActiveMembersSection() { {data.map((teamMember) => ( <ТeamMemberWrapper key={teamMember.name}> - + {teamMember.name} {teamMember.description} diff --git a/src/components/client/about/sections/AssociationMembersSection/AssociationMembersSection.tsx b/src/components/client/about/sections/AssociationMembersSection/AssociationMembersSection.tsx index 562275ec3..b41239425 100644 --- a/src/components/client/about/sections/AssociationMembersSection/AssociationMembersSection.tsx +++ b/src/components/client/about/sections/AssociationMembersSection/AssociationMembersSection.tsx @@ -24,7 +24,12 @@ export default function AssociationMembersSection() { {data.map((teamMember) => ( - + {teamMember.name} {teamMember.linkedInProfile ? ( diff --git a/src/components/client/about/sections/ManagementBoardSection/ManagementBoardSection.tsx b/src/components/client/about/sections/ManagementBoardSection/ManagementBoardSection.tsx index 9adbd23de..fd5e93149 100644 --- a/src/components/client/about/sections/ManagementBoardSection/ManagementBoardSection.tsx +++ b/src/components/client/about/sections/ManagementBoardSection/ManagementBoardSection.tsx @@ -29,7 +29,12 @@ export default function ManagementBoardSection() { {data.map((teamMember) => ( <ТeamMemberWrapper key={teamMember.name}> - + {teamMember.name} diff --git a/src/components/client/about/sections/ManagementBoardSection/ManagementBoardsection.styled.tsx b/src/components/client/about/sections/ManagementBoardSection/ManagementBoardsection.styled.tsx index 32f6e09a4..6741e0c80 100644 --- a/src/components/client/about/sections/ManagementBoardSection/ManagementBoardsection.styled.tsx +++ b/src/components/client/about/sections/ManagementBoardSection/ManagementBoardsection.styled.tsx @@ -21,6 +21,7 @@ export const ТeamMemberWrapper = styled(Grid)(() => ({ }, [theme.breakpoints.up('md')]: { flex: '1 0 30%', + maxWidth: '30%', }, [theme.breakpoints.up('lg')]: { flex: '1 0 10%', @@ -56,6 +57,6 @@ export const ShowMoreButton = styled(Button)(() => ({ export const Description = styled(Typography)(() => ({ textAlign: 'initial', - marginBottom: theme.spacing(3), + marginBottom: theme.spacing(2), overflow: 'hidden', })) diff --git a/src/components/client/about/sections/ManagementBoardSection/TeamMemberDescription.tsx b/src/components/client/about/sections/ManagementBoardSection/TeamMemberDescription.tsx index 60dd9c9c2..0a4cedcd1 100644 --- a/src/components/client/about/sections/ManagementBoardSection/TeamMemberDescription.tsx +++ b/src/components/client/about/sections/ManagementBoardSection/TeamMemberDescription.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react' +import { useState, useEffect, useRef } from 'react' import { useTranslation } from 'next-i18next' @@ -9,19 +9,37 @@ import { ShowMoreButton, Description } from './ManagementBoardsection.styled' type Props = { description: string } + export function TeamMemberDescription({ description }: Props) { const { t } = useTranslation('about') - const [show, setShow] = useState(false) + const [isOverflowing, setIsOverflowing] = useState(false) + + const descriptionRef = useRef(null) + + // Check if the content overflows the specified height + useEffect(() => { + if (descriptionRef.current) { + const elementHeight = descriptionRef.current.scrollHeight + const maxHeight = parseFloat(theme.spacing(23).toString().replace('px', '')) + setIsOverflowing(elementHeight >= maxHeight) + } + }, [description]) return ( <> - + {description} - setShow(!show)}> - {show ? t('about.see-less') : t('about.see-more')} - + + {isOverflowing && ( + setShow(!show)}> + {show ? t('about.see-less') : t('about.see-more')} + + )} ) } diff --git a/src/components/client/about/sections/SupervisoryBoardSection/SupervisoryBoardSection.tsx b/src/components/client/about/sections/SupervisoryBoardSection/SupervisoryBoardSection.tsx index 30f2a1e2f..e34316c5f 100644 --- a/src/components/client/about/sections/SupervisoryBoardSection/SupervisoryBoardSection.tsx +++ b/src/components/client/about/sections/SupervisoryBoardSection/SupervisoryBoardSection.tsx @@ -30,7 +30,12 @@ export default function SupervisoryBoardSection() { {data.map((teamMember) => ( - + {teamMember.name}
  • {t('profile:disableModal.deactivateEmails')} - {t('profile:disableModal.link')}. + {t('profile:disableModal.link')}.
  • {t('profile:disableModal.writeUs')} - {t('profile:disableModal.link')}. + {t('profile:disableModal.link')}.

  • diff --git a/src/components/client/auth/profile/UpdateBirthdateModal.tsx b/src/components/client/auth/profile/UpdateBirthdateModal.tsx index 77415531c..f43047a58 100644 --- a/src/components/client/auth/profile/UpdateBirthdateModal.tsx +++ b/src/components/client/auth/profile/UpdateBirthdateModal.tsx @@ -53,17 +53,6 @@ const parseDateString = (value: string, originalValue: string) => { const maxDate = new Date(new Date().setFullYear(new Date().getFullYear() - 18)) -const validationSchema: yup.SchemaOf> = yup - .object() - .defined() - .shape({ - birthday: yup - .date() - .transform(parseDateString) - .max(maxDate, 'profile:birthdateModal.ageInvalid') - .required(), - }) - function UpdateBirthdateModal({ isOpen, handleClose, @@ -76,6 +65,17 @@ function UpdateBirthdateModal({ const { t } = useTranslation() const [loading, setLoading] = useState(false) + const validationSchema: yup.SchemaOf> = yup + .object() + .defined() + .shape({ + birthday: yup + .date() + .transform(parseDateString) + .max(maxDate, t('profile:birthdateModal.ageInvalid')) + .required(), + }) + const dateBefore18Years = new Date(new Date().setFullYear(new Date().getFullYear() - 18)) const initialValues: Pick = { @@ -119,7 +119,11 @@ function UpdateBirthdateModal({ validationSchema={validationSchema}> - + diff --git a/src/components/client/blog/BlogIndexPage.tsx b/src/components/client/blog/BlogIndexPage.tsx index a0bdd10c2..236eb7a94 100644 --- a/src/components/client/blog/BlogIndexPage.tsx +++ b/src/components/client/blog/BlogIndexPage.tsx @@ -2,13 +2,7 @@ import React from 'react' import NextLink from 'next/link' import { useTranslation } from 'next-i18next' import { PostsOrPages, Pagination as GhostPagination } from '@tryghost/content-api' -import { - Container, - Stack, - Typography, - Unstable_Grid2 as Grid2, - PaginationItem, -} from '@mui/material' +import { Container, Stack, Typography, Grid2, PaginationItem } from '@mui/material' import Pagination from '@mui/material/Pagination' import theme from 'common/theme' @@ -38,9 +32,9 @@ export default function BlogIndexPage({ posts, pagination }: Props) { {posts.map((post) => ( - + - + - + ))} - + {pages > 1 && ( - + - + {page.title} - + - + - + - + diff --git a/src/components/client/blog/BlogPostPage.tsx b/src/components/client/blog/BlogPostPage.tsx index 28834a7dd..880c1e7d2 100644 --- a/src/components/client/blog/BlogPostPage.tsx +++ b/src/components/client/blog/BlogPostPage.tsx @@ -1,6 +1,6 @@ import React from 'react' import { PostOrPage } from '@tryghost/content-api' -import { Container, Typography, Unstable_Grid2 as Grid2 } from '@mui/material' +import { Container, Typography, Grid2 } from '@mui/material' import { baseUrl, routes } from 'common/routes' import Layout from 'components/client/layout/Layout' @@ -24,19 +24,19 @@ export default function BlogPostPage({ post, referer }: Props) { ogImage={post.og_image ?? undefined}> - + - + {post.title} - + - + - + - + diff --git a/src/components/client/campaign-application/CampaignApplicationForm.tsx b/src/components/client/campaign-application/CampaignApplicationForm.tsx index fdbbcc6bc..42e7e6c54 100644 --- a/src/components/client/campaign-application/CampaignApplicationForm.tsx +++ b/src/components/client/campaign-application/CampaignApplicationForm.tsx @@ -1,6 +1,6 @@ -import { useCallback, useState } from 'react' import { Grid, StepLabel } from '@mui/material' import { Person } from 'gql/person' +import { useCallback, useEffect, useState } from 'react' import { CampaignApplicationFormData, @@ -8,110 +8,98 @@ import { Steps, } from './helpers/campaignApplication.types' +import ArrowBackIosIcon from '@mui/icons-material/ArrowBackIos' +import ArrowForwardIosIcon from '@mui/icons-material/ArrowForwardIos' import GenericForm from 'components/common/form/GenericForm' -import CampaignApplicationStepperIcon from './CampaignApplicationStepperIcon' -import CampaignApplicationOrganizer from './steps/CampaignApplicationOrganizer' +import CampaignApplicationBasic from './steps/CampaignApplicationBasic' import CampaignApplicationDetails from './steps/CampaignApplicationDetails' -import CampaignApplication from './steps/CampaignApplication' -import CampaignApplicationFormActions from './CampaignApplicationFormActions' -import CampaignApplicationRemark from './CampaignApplicationRemark' -import stepsHandler from './helpers/stepsHandler' +import CampaignApplicationOrganizer from './steps/CampaignApplicationOrganizer' +import CampaignApplicationRemark from './steps/CampaignApplicationRemark' +import CampaignApplicationStepperIcon from './steps/CampaignApplicationStepperIcon' import { validationSchema } from './helpers/validation-schema' +import { routes } from 'common/routes' +import { FormikHelpers } from 'formik' +import { CampaignApplicationExisting } from 'gql/campaign-applications' +import { useTranslation } from 'next-i18next' +import { useRouter } from 'next/router' +import { mapCreateOrEditInput, useCreateOrEditApplication } from 'service/campaign-application' +import { AlertStore } from 'stores/AlertStore' import { StyledCampaignApplicationStep, StyledCampaignApplicationStepper, StyledStepConnector, } from './helpers/campaignApplication.styled' -import { useMutation } from '@tanstack/react-query' import { - CreateCampaignApplicationInput, - CreateCampaignApplicationResponse, -} from 'gql/campaign-applications' -import { AxiosError, AxiosResponse, isAxiosError } from 'axios' -import { ApiErrors, matchValidator } from 'service/apiErrors' -import { useCreateCampaignApplication } from 'service/campaign-application' -import { AlertStore } from 'stores/AlertStore' -import { t } from 'i18next' -import { CampaignTypeCategory } from 'components/common/campaign-types/categories' -import { FormikHelpers } from 'formik' -import { useCampaignTypesList } from 'service/campaignTypes' -import { CampaignTypesResponse } from 'gql/campaign-types' + ActionButton, + ActionLinkButton, + ActionSubmitButton, + Root, +} from './helpers/campaignApplicationFormActions.styled' +import CampaignApplicationSummary from './steps/CampaignApplicationSummary' const steps: StepType[] = [ { title: 'campaign-application:steps.organizer.title', - component: , }, { title: 'campaign-application:steps.campaign-application.title', - component: , }, { title: 'campaign-application:steps.campaign-application-details.title', - component: , }, ] type Props = { person?: Person + isEdit?: boolean + campaignApplication?: CampaignApplicationExisting } -export default function CampaignApplicationForm({ person }: Props) { - const [activeStep, setActiveStep] = useState(Steps.ORGANIZER) - const isLast = activeStep === Steps.CAMPAIGN_DETAILS - - const initialValues: CampaignApplicationFormData = { - organizer: { - name: `${person?.firstName} ${person?.lastName}` ?? '', - phone: person?.phone ?? '', - email: person?.email ?? '', - acceptTermsAndConditions: false, - transparencyTermsAccepted: false, - personalInformationProcessingAccepted: false, - }, - application: { - title: '', - beneficiaryNames: '', - campaignType: '', - funds: 0, - campaignEnd: '', - }, - details: { - campaignGuarantee: '', - cause: '', - currentStatus: '', - description: '', - documents: [], - links: [], - organizerBeneficiaryRelationship: '-', - otherFinancialSources: '', - }, - } +export default function CampaignApplicationForm({ + person, + isEdit, + campaignApplication: existing, +}: Props) { + const { t } = useTranslation('campaign-application') + const router = useRouter() + + const { + createOrUpdateApplication, + createOrUpdateSuccessful: applicationCreated, + submitting, + uploadedFiles, + error: createCampaignError, + campaignApplicationResult: camApp, + files, + setFiles, + initialValues, + deletedFiles, + } = useCreateOrEditApplication({ + person, + isEdit, + campaignApplication: existing, + }) - const { data } = useCampaignTypesList() - const { mutation } = useCreateApplication() const handleSubmit = async ( formData: CampaignApplicationFormData, - { setFieldError, resetForm }: FormikHelpers, + { resetForm }: FormikHelpers, ) => { - if (isLast) { - try { - await mutation.mutateAsync(mapCreateInput(formData, data ?? [])) - + if (activeStep === Steps.CREATED_DETAILS && camApp?.id != null) { + router.push(routes.campaigns.applicationEdit(camApp?.id)) // go to the edit page + if (isEdit) { + router.reload() // in case we are re-editing refresh the whole page to reset all the things + } + } else if (shouldSubmit) { + const createOrEdit = mapCreateOrEditInput(formData) + await createOrUpdateApplication(createOrEdit) + if (applicationCreated) { resetForm() - } catch (error) { - console.error(error) - if (isAxiosError(error)) { - const { response } = error as AxiosError - response?.data.message.map(({ property, constraints }) => { - setFieldError(property, t(matchValidator(constraints))) - }) - } + AlertStore.show(t('alerts.successfully-created'), 'success') } } else { - stepsHandler({ activeStep, setActiveStep }) + setActiveStep((prevActiveStep) => prevActiveStep + 1) } } @@ -119,6 +107,16 @@ export default function CampaignApplicationForm({ person }: Props) { setActiveStep((prevActiveStep) => prevActiveStep - 1) }, []) + const [activeStep, setActiveStep] = useState(Steps.ORGANIZER) + const shouldSubmit = activeStep === Steps.CAMPAIGN_DETAILS + + // move to last step after campaign application created successfully + useEffect(() => { + if (applicationCreated && camApp?.id) { + setActiveStep(Steps.CREATED_DETAILS) + } + }, [applicationCreated]) + return ( <> @@ -136,69 +134,80 @@ export default function CampaignApplicationForm({ person }: Props) { - {activeStep < steps.length && steps[activeStep].component} + {activeStep === Steps.ORGANIZER && } + {activeStep === Steps.CAMPAIGN_BASIC && } + {activeStep === Steps.CAMPAIGN_DETAILS && ( + + )} + {activeStep === Steps.CREATED_DETAILS && ( + + )} - + + + {activeStep === Steps.ORGANIZER ? ( + }> + {t('cta.back')} + + ) : ( + } + disabled={ + applicationCreated /**after campaign application is created disable going back and editing */ + }> + {t('cta.back')} + + )} + + + } + disabled={submitting} + /> + + + {(activeStep === Steps.ORGANIZER || activeStep === Steps.CAMPAIGN_BASIC) && ( + + )} + {/* campaign errors */} + {createCampaignError && ( + <> + Errors: + {createCampaignError?.map((e, i) => ( +

    {e}

    + ))} + + )} - {(activeStep === Steps.ORGANIZER || activeStep === Steps.CAMPAIGN) && ( - - )} ) } - -const useCreateApplication = () => { - const mutation = useMutation< - AxiosResponse, - AxiosError, - CreateCampaignApplicationInput - >({ - mutationFn: useCreateCampaignApplication(), - onError: () => AlertStore.show(t('common:alerts.error'), 'error'), - onSuccess: () => AlertStore.show(t('common:alerts.message-sent'), 'success'), - }) - - // const fileUploadMutation = useMutation< - // AxiosResponse, - // AxiosError, - // UploadCampaignFiles - // >({ - // mutationFn: useUploadCampaignFiles(), - // }) - - return { mutation } -} - -function mapCreateInput( - i: CampaignApplicationFormData, - types: CampaignTypesResponse[], -): CreateCampaignApplicationInput { - return { - acceptTermsAndConditions: i.organizer.acceptTermsAndConditions, - personalInformationProcessingAccepted: i.organizer.personalInformationProcessingAccepted, - transparencyTermsAccepted: i.organizer.transparencyTermsAccepted, - - organizerName: i.organizer.name, - organizerEmail: i.organizer.email, - organizerPhone: i.organizer.phone, - - beneficiary: i.application.beneficiaryNames, - - campaignName: i.application.title, - amount: i.application.funds?.toString() ?? '', - goal: i.details.cause, - category: types.find((c) => c.id === i.application.campaignType)?.category, - description: i.details.description, - organizerBeneficiaryRel: i.details.organizerBeneficiaryRelationship ?? '-', - campaignGuarantee: i.details.campaignGuarantee, - history: i.details.currentStatus, - otherFinanceSources: i.details.otherFinancialSources, - } -} diff --git a/src/components/client/campaign-application/CampaignApplicationFormActions.tsx b/src/components/client/campaign-application/CampaignApplicationFormActions.tsx deleted file mode 100644 index 3b2bc6cae..000000000 --- a/src/components/client/campaign-application/CampaignApplicationFormActions.tsx +++ /dev/null @@ -1,58 +0,0 @@ -import { MouseEvent } from 'react' - -import { useTranslation } from 'next-i18next' - -import { Grid } from '@mui/material' -import ArrowBackIosIcon from '@mui/icons-material/ArrowBackIos' -import ArrowForwardIosIcon from '@mui/icons-material/ArrowForwardIos' - -import { - ActionButton, - ActionLinkButton, - ActionSubmitButton, - Root, -} from './helpers/campaignApplicationFormActions.styled' - -type CampaignApplicationFormActionsProps = { - activeStep: number - onBack?: (event: MouseEvent) => void - isLast: boolean -} - -export default function CampaignApplicationFormActions({ - onBack, - activeStep, - isLast, -}: CampaignApplicationFormActionsProps) { - const { t } = useTranslation('campaign-application') - - return ( - - - {activeStep === 0 ? ( - }> - {t('cta.back')} - - ) : ( - }> - {t('cta.back')} - - )} - - - } - /> - - - ) -} diff --git a/src/components/client/campaign-application/EditCampaignApplicationPage.tsx b/src/components/client/campaign-application/EditCampaignApplicationPage.tsx new file mode 100644 index 000000000..2326a4845 --- /dev/null +++ b/src/components/client/campaign-application/EditCampaignApplicationPage.tsx @@ -0,0 +1,31 @@ +import { CircularProgress, Grid } from '@mui/material' +import NotFoundPage from 'pages/404' +import { useViewCampaignApplicationCached } from 'service/campaign-application' +import Layout from '../layout/Layout' +import CampaignApplicationForm from './CampaignApplicationForm' + +interface EditProps { + id: string +} + +export default function EditCampaignApplicationPage({ id }: EditProps) { + const { data, isLoading, isError } = useViewCampaignApplicationCached(id) + + if (isLoading) { + return ( + + + + ) + } + + if (isError) { + return + } + + return ( + + + + ) +} diff --git a/src/components/client/campaign-application/helpers/campaign-application-status.tsx b/src/components/client/campaign-application/helpers/campaign-application-status.tsx new file mode 100644 index 000000000..31d440d26 --- /dev/null +++ b/src/components/client/campaign-application/helpers/campaign-application-status.tsx @@ -0,0 +1,27 @@ +import { FormControl, FormControlProps, InputLabel, MenuItem, Select } from '@mui/material' +import { useField } from 'formik' +import { useTranslation } from 'next-i18next' +import { allStates } from './campaignApplication.types' + +export type Props = { name: string } & FormControlProps + +export const StatusSelector = ({ name, ...control }: Props) => { + const [field] = useField(name) + const { t } = useTranslation('campaign-application') + return ( + + {t('status.selectorLabel')} + + + ) +} diff --git a/src/components/client/campaign-application/helpers/campaignApplication.types.ts b/src/components/client/campaign-application/helpers/campaignApplication.types.ts index 50e3a5990..cba46e6d5 100644 --- a/src/components/client/campaign-application/helpers/campaignApplication.types.ts +++ b/src/components/client/campaign-application/helpers/campaignApplication.types.ts @@ -1,13 +1,13 @@ export type Step = { title: string - component: JSX.Element } export enum Steps { NONE = -1, ORGANIZER = 0, - CAMPAIGN = 1, + CAMPAIGN_BASIC = 1, CAMPAIGN_DETAILS = 2, + CREATED_DETAILS = 3, } export type CampaignApplicationOrganizer = { @@ -19,27 +19,51 @@ export type CampaignApplicationOrganizer = { personalInformationProcessingAccepted: boolean } -export type CampaignApplication = { +export type CampaignApplicationBasic = { beneficiaryNames: string title: string campaignType: string funds: number campaignEnd: string + campaignEndDate?: string +} + +export type CampaignApplicationDetails = { + cause: string + organizerBeneficiaryRelationship?: string + description?: string + currentStatus?: string +} + +// keep in sync with api repo/podkrepi.dbml -> Enum CampaignApplicationState +export type CampaignApplicationState = + | 'review' + | 'requestInfo' + | 'forCommitteeReview' + | 'approved' + | 'denied' + | 'abandoned' + +export const allStates: CampaignApplicationState[] = [ + 'review', + 'requestInfo', + 'forCommitteeReview', + 'approved', + 'denied', + 'abandoned', +] + +export type CampaignApplicationAdmin = { + state: CampaignApplicationState + ticketURL?: string + archived?: boolean } export type CampaignApplicationFormData = { organizer: CampaignApplicationOrganizer - application: CampaignApplication - details: { - organizerBeneficiaryRelationship: string - campaignGuarantee: string | undefined - otherFinancialSources: string | undefined - description: string - currentStatus: string - cause: string - links: string[] - documents: string[] - } + applicationBasic: CampaignApplicationBasic + applicationDetails: CampaignApplicationDetails + admin?: CampaignApplicationAdmin } export type CampaignApplicationFormDataSteps = { diff --git a/src/components/client/campaign-application/helpers/stepsHandler.ts b/src/components/client/campaign-application/helpers/stepsHandler.ts deleted file mode 100644 index f7c2cbedb..000000000 --- a/src/components/client/campaign-application/helpers/stepsHandler.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { SetStateAction } from 'react' - -import { Steps } from './campaignApplication.types' - -interface stepsHandlerProps { - activeStep: Steps - setActiveStep: (value: SetStateAction) => void -} - -export default function stepsHandler({ activeStep, setActiveStep }: stepsHandlerProps) { - switch (activeStep) { - case Steps.ORGANIZER: - { - setActiveStep((prevActiveStep) => prevActiveStep + 1) - } - break - case Steps.CAMPAIGN: - { - setActiveStep((prevActiveStep) => prevActiveStep + 1) - } - break - case Steps.CAMPAIGN_DETAILS: - { - setActiveStep((prevActiveStep) => prevActiveStep + 1) - } - break - default: - return 'Unknown step' - } -} diff --git a/src/components/client/campaign-application/helpers/validation-schema.ts b/src/components/client/campaign-application/helpers/validation-schema.ts index 12b46930e..58f1d7598 100644 --- a/src/components/client/campaign-application/helpers/validation-schema.ts +++ b/src/components/client/campaign-application/helpers/validation-schema.ts @@ -1,29 +1,82 @@ import * as yup from 'yup' -import { name, phone, email } from 'common/form/validation' +import { email, name, phone } from 'common/form/validation' import { - CampaignApplicationFormDataSteps, + CampaignApplicationAdmin, + CampaignApplicationBasic, + CampaignApplicationDetails, + CampaignApplicationFormData, CampaignApplicationOrganizer, + CampaignApplicationState, Steps, } from './campaignApplication.types' -const organizer: yup.SchemaOf = yup - .object() - .shape({ - name: name.required(), - phone: phone.required(), - email: email.required(), - }) - .defined() +const organizerSchema: yup.SchemaOf = yup.object().shape({ + name: name.required(), + phone: phone.required(), + email: email.required(), + acceptTermsAndConditions: yup.bool().oneOf([true], 'validation:terms-of-use').required(), + transparencyTermsAccepted: yup.bool().oneOf([true], 'validation:required').required(), + personalInformationProcessingAccepted: yup + .bool() + .oneOf([true], 'validation:terms-of-service') + .required(), +}) + +const basicSchema: yup.SchemaOf = yup.object().shape({ + beneficiaryNames: yup.string().required(), + campaignEnd: yup.string().required(), + campaignType: yup.string().required(), + funds: yup.number().required(), + title: yup.string().required(), + campaignEndDate: yup.string().optional(), +}) + +const detailsSchema: yup.SchemaOf = yup.object().shape({ + cause: yup.string().required(), + campaignGuarantee: yup.string().optional(), + currentStatus: yup.string().optional(), + description: yup.string().optional(), + documents: yup.array().optional(), + links: yup.array().optional(), + organizerBeneficiaryRelationship: yup.string().optional(), + otherFinancialSources: yup.string().optional(), +}) + +const adminPropsSchema: yup.SchemaOf = yup.object().shape({ + state: yup + .mixed() + .oneOf(['review', 'requestInfo', 'forCommitteeReview', 'approved', 'denied', 'abandoned']) + .required(), + ticketURL: yup.string().optional(), + archived: yup.bool().optional(), +}) export const validationSchema: { - [key in Steps]?: - | yup.SchemaOf - | yup.SchemaOf + [Steps.NONE]: undefined + [Steps.ORGANIZER]: yup.SchemaOf> + [Steps.CAMPAIGN_BASIC]: yup.SchemaOf> + [Steps.CAMPAIGN_DETAILS]: yup.SchemaOf> + [Steps.CREATED_DETAILS]: undefined } = { [Steps.NONE]: undefined, + [Steps.CREATED_DETAILS]: undefined, [Steps.ORGANIZER]: yup.object().shape({ - organizer: organizer.required(), + organizer: organizerSchema.defined(), + }), + [Steps.CAMPAIGN_BASIC]: yup.object().shape({ + applicationBasic: basicSchema.defined(), + }), + [Steps.CAMPAIGN_DETAILS]: yup.object().shape({ + applicationDetails: detailsSchema.defined(), }), } + +export const campaignApplicationAdminValidationSchema: yup.SchemaOf = + yup.object().shape({ + organizer: organizerSchema.defined(), + applicationBasic: basicSchema.defined(), + applicationDetails: detailsSchema.defined(), + admin: adminPropsSchema.nullable().defined(), + }) diff --git a/src/components/client/campaign-application/steps/CampaignApplication.tsx b/src/components/client/campaign-application/steps/CampaignApplicationBasic.tsx similarity index 63% rename from src/components/client/campaign-application/steps/CampaignApplication.tsx rename to src/components/client/campaign-application/steps/CampaignApplicationBasic.tsx index b39b482fc..864dcd8f9 100644 --- a/src/components/client/campaign-application/steps/CampaignApplication.tsx +++ b/src/components/client/campaign-application/steps/CampaignApplicationBasic.tsx @@ -1,17 +1,33 @@ import { FormControl, Grid, Typography } from '@mui/material' -import { Field, useField } from 'formik' +import { Field, useFormikContext } from 'formik' import { useTranslation } from 'next-i18next' -import { StyledFormTextField, StyledStepHeading } from '../helpers/campaignApplication.styled' -import { CampaignEndTypes } from '../helpers/campaignApplication.types' import CampaignTypeSelect from 'components/client/campaigns/CampaignTypeSelect' import FormDatePicker from 'components/common/form/FormDatePicker' +import { StyledFormTextField, StyledStepHeading } from '../helpers/campaignApplication.styled' +import { CampaignApplicationFormData, CampaignEndTypes } from '../helpers/campaignApplication.types' import theme from 'common/theme' +import { useEffect, useState } from 'react' -export default function CampaignApplication() { +export default function CampaignApplicationBasic() { const { t } = useTranslation('campaign-application') - const [campaignEnd] = useField('application.campaignEnd') + const { values, setFieldValue } = useFormikContext() + // if user selects the date we'll fill in the previously selected (or new Date()) or remove that in case they chose another option + const [selectedDate, setSelectedDate] = useState( + values?.applicationBasic?.campaignEndDate ?? new Date().toString(), + ) + useEffect(() => { + const endDate = values.applicationBasic?.campaignEndDate + if (endDate != null && endDate != selectedDate) { + setSelectedDate(endDate) + } + setFieldValue( + 'applicationBasic.campaignEndDate', + values?.applicationBasic?.campaignEnd === CampaignEndTypes.DATE ? selectedDate : undefined, + false, + ) + }, [values?.applicationBasic?.campaignEnd]) return ( @@ -23,7 +39,7 @@ export default function CampaignApplication() { @@ -31,24 +47,24 @@ export default function CampaignApplication() {
    - + @@ -65,7 +81,7 @@ export default function CampaignApplication() { {t('steps.application.campaign-end.options.funds')} @@ -76,7 +92,7 @@ export default function CampaignApplication() { {t('steps.application.campaign-end.options.ongoing')} @@ -87,18 +103,19 @@ export default function CampaignApplication() { {t('steps.application.campaign-end.options.date')}
    - {campaignEnd.value === CampaignEndTypes.DATE && ( - - - - )} + {values?.applicationBasic?.campaignEnd === CampaignEndTypes.DATE && + values?.applicationBasic?.campaignEndDate != null && ( + + + + )}
    diff --git a/src/components/client/campaign-application/steps/CampaignApplicationDetails.tsx b/src/components/client/campaign-application/steps/CampaignApplicationDetails.tsx index 2353b9eea..6fa0945f6 100644 --- a/src/components/client/campaign-application/steps/CampaignApplicationDetails.tsx +++ b/src/components/client/campaign-application/steps/CampaignApplicationDetails.tsx @@ -1,13 +1,19 @@ -import { useTranslation } from 'next-i18next' import { Grid, Typography } from '@mui/material' +import { useTranslation } from 'next-i18next' -import { StyledStepHeading } from '../helpers/campaignApplication.styled' import FormTextField from 'components/common/form/FormTextField' +import { StyledStepHeading } from '../helpers/campaignApplication.styled' -import theme from 'common/theme' +import FileList from 'components/common/file-upload/FileList' import FileUpload from 'components/common/file-upload/FileUpload' +import { Dispatch, SetStateAction } from 'react' -export default function CampaignApplicationDetails() { +export type Props = { + files: File[] + setFiles: Dispatch> +} + +export default function CampaignApplicationDetails({ files, setFiles }: Props) { const { t } = useTranslation('campaign-application') return ( @@ -19,8 +25,8 @@ export default function CampaignApplicationDetails() { @@ -28,75 +34,42 @@ export default function CampaignApplicationDetails() { - - - {t('steps.details.links.label')} - - - - - - - - - - - - - - - - { - return + buttonLabel={t('steps.details.documents')} + onUpload={(newFiles) => { + setFiles((prevFiles) => [...prevFiles, ...newFiles]) }} + accept="text/plain,application/json,application/pdf,image/png,image/jpeg,application/xml,text/xml,application/msword,application/vnd.ms-excel,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" /> - - - { - return + {t('steps.details.disclaimer')} + + setFiles((prevFiles) => prevFiles.filter((file) => file.name !== deletedFile.name)) + } + rolesList={{}} + onSetFileRole={() => { + // we have no roles for the campaign application - it's all a document + return undefined }} + filesRole={[]} /> diff --git a/src/components/client/campaign-application/steps/CampaignApplicationOrganizer.tsx b/src/components/client/campaign-application/steps/CampaignApplicationOrganizer.tsx index 5a975dc94..a96556b3d 100644 --- a/src/components/client/campaign-application/steps/CampaignApplicationOrganizer.tsx +++ b/src/components/client/campaign-application/steps/CampaignApplicationOrganizer.tsx @@ -8,7 +8,11 @@ import AcceptPrivacyPolicyField from 'components/common/form/AcceptPrivacyPolicy import { StyledStepHeading, StyledFormTextField } from '../helpers/campaignApplication.styled' -export default function CampaignApplicationOrganizer() { +type Props = { + isAdmin?: boolean +} + +export default function CampaignApplicationOrganizer({ isAdmin }: Props) { const { t } = useTranslation('campaign-application') return ( @@ -46,7 +50,7 @@ export default function CampaignApplicationOrganizer() { - + @@ -55,11 +59,15 @@ export default function CampaignApplicationOrganizer() { label={ {t('steps.organizer.transparencyTerms')} } + disabled={isAdmin} /> - + diff --git a/src/components/client/campaign-application/CampaignApplicationRemark.tsx b/src/components/client/campaign-application/steps/CampaignApplicationRemark.tsx similarity index 100% rename from src/components/client/campaign-application/CampaignApplicationRemark.tsx rename to src/components/client/campaign-application/steps/CampaignApplicationRemark.tsx diff --git a/src/components/client/campaign-application/CampaignApplicationStepperIcon.tsx b/src/components/client/campaign-application/steps/CampaignApplicationStepperIcon.tsx similarity index 81% rename from src/components/client/campaign-application/CampaignApplicationStepperIcon.tsx rename to src/components/client/campaign-application/steps/CampaignApplicationStepperIcon.tsx index 930065e59..4b86a72ca 100644 --- a/src/components/client/campaign-application/CampaignApplicationStepperIcon.tsx +++ b/src/components/client/campaign-application/steps/CampaignApplicationStepperIcon.tsx @@ -1,5 +1,5 @@ import { StepIconProps } from '@mui/material/StepIcon' -import { StyledCampaignApplicationStepperIcon } from './helpers/campaignApplication.styled' +import { StyledCampaignApplicationStepperIcon } from '../helpers/campaignApplication.styled' export default function CampaignApplicationStepperIcon(props: StepIconProps) { const icons: { [index: string]: React.ReactElement } = { diff --git a/src/components/client/campaign-application/steps/CampaignApplicationSummary.tsx b/src/components/client/campaign-application/steps/CampaignApplicationSummary.tsx new file mode 100644 index 000000000..f320c102b --- /dev/null +++ b/src/components/client/campaign-application/steps/CampaignApplicationSummary.tsx @@ -0,0 +1,145 @@ +import { Grid, Typography } from '@mui/material' +import { green, orange, red } from '@mui/material/colors' +import { CampaignApplicationResponse } from 'gql/campaign-applications' +import { useTranslation } from 'next-i18next' +import { CampaignEndTypes } from '../helpers/campaignApplication.types' + +export interface SummaryProps { + uploadedFiles: Record + camApp?: CampaignApplicationResponse + deletedFiles?: Record + isEdit?: boolean + prependChildren?: JSX.Element +} + +function FilesDetail({ + label, + files, + type, +}: { + label: string + files?: string[] + type?: 'success' | 'failure' | 'successful-delete' +}) { + return ( + Number(files?.length) > 0 && ( + <> + + {label} + + + {files?.map((f) => ( + + {f} + + ))} + + + ) + ) +} + +export function CamAppDetail({ label, value }: { label: string; value?: string | JSX.Element }) { + const normalized = + typeof value === 'string' && value.trim() != '' ? value : value != null ? value : '-' + return ( + <> + + {label} + + + {normalized} + + + ) +} + +export default function CampaignApplicationSummary({ + uploadedFiles, + camApp, + deletedFiles, + isEdit, + prependChildren, +}: SummaryProps) { + const { t } = useTranslation('campaign-application') + + return ( + <> + {t(isEdit ? 'result.edited' : 'result.created')} + + + + {prependChildren} + + + {uploadedFiles.failed.length > 0 && ( + <> + +

    {t('result.uploadFailedDirection')}

    + + )} + {deletedFiles && deletedFiles.failed.length > 0 && ( + <> + +

    {t('result.uploadFailedDirection')}

    + + )} + + + + + + + + + + + +
    +
    +
    + + ) +} diff --git a/src/components/client/campaigns/CampaignDetails.tsx b/src/components/client/campaigns/CampaignDetails.tsx index b7f2a6d5e..142c4ad78 100644 --- a/src/components/client/campaigns/CampaignDetails.tsx +++ b/src/components/client/campaigns/CampaignDetails.tsx @@ -267,7 +267,7 @@ const CampaignFinanceSummary = ({ campaign, expenses }: CampaignFinanceProps) => - {t('campaigns:campaign-details-report.translated')}: {moneyPublic(transferred)} + {t('campaigns:campaign-details-report.transferred')}: {moneyPublic(transferred)} diff --git a/src/components/client/campaigns/DonationWishes.tsx b/src/components/client/campaigns/DonationWishes.tsx index 8607a6124..4905b8477 100644 --- a/src/components/client/campaigns/DonationWishes.tsx +++ b/src/components/client/campaigns/DonationWishes.tsx @@ -2,15 +2,7 @@ import React, { useMemo, useRef, useState } from 'react' import { useTranslation } from 'next-i18next' -import { - Unstable_Grid2 as Grid2, - Stack, - Typography, - Grid, - Button, - TextField, - InputAdornment, -} from '@mui/material' +import { Grid2, Stack, Typography, Grid, Button, TextField, InputAdornment } from '@mui/material' import SearchIcon from '@mui/icons-material/Search' import KeyboardArrowUpIcon from '@mui/icons-material/KeyboardArrowUp' import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown' @@ -156,7 +148,7 @@ export default function DonationWishes({ campaignId, pageSize = 5 }: Props) { key={id} direction="row" sx={{ p: 2, bgcolor: 'grey.100', borderRadius: theme.spacing(2) }}> - + ))} - + {data?.items?.length === 0 && searchValue !== '' && ( {t('campaign.sort.noResults')} )} - + {numOfPages > 1 && ( , + 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..0300958c2 --- /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, 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..e823c7f53 --- /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, + 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..8bfba674a --- /dev/null +++ b/src/components/client/donation-flow/alerts/AlertsColumn.tsx @@ -0,0 +1,69 @@ +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 } 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: , + 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..6d94f7405 --- /dev/null +++ b/src/components/client/donation-flow/alerts/AlertsContent.tsx @@ -0,0 +1,40 @@ +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: '0.875rem', + }, + p: 0, + } + + return ( + + {t('step.authentication.alert.authenticate.title')}: + + + + + + + + + + + + + + + + ) +} 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..149ebeeee --- /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..c07a8f4f0 --- /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' + +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..c183fa03b 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,64 @@ import { RadioGroup, RadioGroupProps, Stack, - Unstable_Grid2 as Grid2, + Grid, + Grid2, + 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 +79,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 +151,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..f5280e756 --- /dev/null +++ b/src/components/client/donation-flow/steps/Amount.tsx @@ -0,0 +1,138 @@ +import React, { useEffect } from 'react' +import * as yup from 'yup' +import { useTranslation } from 'next-i18next' +import { useMediaQuery, Collapse, Typography, 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..7e83887e7 --- /dev/null +++ b/src/components/client/donation-flow/steps/PaymentModeSelect.tsx @@ -0,0 +1,46 @@ +import { Typography, 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..9ed0b9cfe --- /dev/null +++ b/src/components/client/donation-flow/steps/authentication/Authentication.tsx @@ -0,0 +1,132 @@ +import { useEffect, useState } from 'react' +import { useTranslation } from 'next-i18next' +import { Box, Typography, Alert, useMediaQuery, 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 } 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 && ( + { + setShowNoRegisterAlert(false) + }} + color="info" + icon={false} + sx={{ mb: 1, mx: -2 }}> + {t('step.authentication.noregister.description')} + + )} + + ), + }, + ] + + 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() { - {title} + + {title} + + + + {content} @@ -39,7 +49,7 @@ const ConfirmationDialog = ({ - 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/file-upload/FileList.tsx b/src/components/common/file-upload/FileList.tsx index 63aa84e0b..26d9bd9c6 100644 --- a/src/components/common/file-upload/FileList.tsx +++ b/src/components/common/file-upload/FileList.tsx @@ -49,24 +49,26 @@ function FileList({ rolesList, files, onDelete, onSetFileRole, filesRole = [] }: - - {'Избери роля'} - - id="choose-type" - size="small" - label="Избери роля" - labelId="choose-type-label" - value={ - filesRole.find((f) => f.file === file.name)?.role ?? CampaignFileRole.background - } - onChange={setFileRole(file)}> - {Object.values(rolesList).map((role) => ( - - {role} - - ))} - - + {Array.isArray(filesRole) && filesRole.length > 0 && ( + + {'Избери роля'} + + id="choose-type" + size="small" + label="Избери роля" + labelId="choose-type-label" + value={ + filesRole.find((f) => f.file === file.name)?.role ?? CampaignFileRole.background + } + onChange={setFileRole(file)}> + {Object.values(rolesList).map((role) => ( + + {role} + + ))} + + + )} ))} diff --git a/src/components/common/file-upload/FileUpload.tsx b/src/components/common/file-upload/FileUpload.tsx index db3b59444..8ad2af498 100644 --- a/src/components/common/file-upload/FileUpload.tsx +++ b/src/components/common/file-upload/FileUpload.tsx @@ -3,9 +3,11 @@ import { Button } from '@mui/material' function FileUpload({ onUpload, buttonLabel, + ...rest }: { onUpload: (files: File[]) => void buttonLabel: string + accept?: string }) { return (