diff --git a/pages/sites/[slug]/[locale]/verify-receipt-data.tsx b/pages/sites/[slug]/[locale]/verify-receipt-data.tsx new file mode 100644 index 000000000..691833b7b --- /dev/null +++ b/pages/sites/[slug]/[locale]/verify-receipt-data.tsx @@ -0,0 +1,84 @@ +import type { + GetStaticPaths, + GetStaticProps, + GetStaticPropsContext, + GetStaticPropsResult, +} from 'next'; +import type { AbstractIntlMessages } from 'next-intl'; +import type { Tenant } from '@planet-sdk/common/build/types/tenant'; + +import { useEffect } from 'react'; +import { useRouter } from 'next/router'; +import { + constructPathsForTenantSlug, + getTenantConfig, +} from '../../../../src/utils/multiTenancy/helpers'; +import getMessagesForPage from '../../../../src/utils/language/getMessagesForPage'; +import { defaultTenant } from '../../../../tenant.config'; +import { useTenant } from '../../../../src/features/common/Layout/TenantContext'; +import DonorReceiptLayout from '../../../../src/features/user/DonorReceipt/DonorReceiptLayout'; +import { DonorReceiptProvider } from '../../../../src/features/common/Layout/DonorReceiptContext'; + +interface PageProps { + messages: AbstractIntlMessages; + tenantConfig: Tenant; +} + +interface Props { + pageProps: PageProps; +} + +export default function DonationReceipt({ + pageProps: { tenantConfig }, +}: Props) { + const router = useRouter(); + const { setTenantConfig } = useTenant(); + + useEffect(() => { + if (router.isReady) setTenantConfig(tenantConfig); + }, [router.isReady]); + + return ( + + + + ); +} + +export const getStaticPaths: GetStaticPaths = async () => { + const subDomainPaths = await constructPathsForTenantSlug(); + + const paths = + subDomainPaths?.map((path) => { + return { + params: { + slug: path.params.slug, + locale: 'en', + }, + }; + }) ?? []; + + return { + paths, + fallback: 'blocking', + }; +}; + +export const getStaticProps: GetStaticProps = async ( + context: GetStaticPropsContext +): Promise> => { + const tenantConfig = + (await getTenantConfig(context.params?.slug as string)) ?? defaultTenant; + + const messages = await getMessagesForPage({ + locale: context.params?.locale as string, + filenames: ['common', 'me', 'country', 'donate'], + }); + + return { + props: { + messages, + tenantConfig, + }, + }; +}; diff --git a/public/static/locales/en/donate.json b/public/static/locales/en/donate.json index fed317502..95849b098 100644 --- a/public/static/locales/en/donate.json +++ b/public/static/locales/en/donate.json @@ -110,6 +110,21 @@ "projectType": "Project Type", "restoration": "Restoration", "filters": "Filters", - "perM2": "per m²" + "perM2": "per m²", + "donationReceipt": { + "verifyTaxHeaderPrimary": "Verify your data to download tax receipt", + "verifyTaxHeaderSecondary": "once the data is verified, it can not be changed again for this receipt", + "downloadTaxReceipt": "Download tax receipt", + "referenceNumber": "Reference Number", + "amountDonated": "Amount Donated", + "paymentDate": "Payment Date", + "recipientInfoHeader": "Receipt will be issued to", + "taxIdentificationNumber": "Tax Identification Number (TIN)", + "modifyContactInformation": "Modify Contact Information", + "confirm": "Confirm", + "download": "Download", + "donationAmount": "{currency, select, EUR {€} USD {$} other {}} {amount}", + "name": "{type, select, individual {Name} organization {Company Name} other {}}" + } } } diff --git a/src/features/common/Layout/DonorReceiptContext.tsx b/src/features/common/Layout/DonorReceiptContext.tsx new file mode 100644 index 000000000..7b894967e --- /dev/null +++ b/src/features/common/Layout/DonorReceiptContext.tsx @@ -0,0 +1,54 @@ +import type { FC } from 'react'; +import type { + ReceiptData, + ReceiptDataAPI, +} from '../../user/DonorReceipt/donorReceipt'; + +import { useMemo, useState, createContext, useContext } from 'react'; +import { formatReceiptData } from '../../user/DonorReceipt/utils'; + +interface DonorReceiptContextInterface { + donorReceiptData: ReceiptData | null; + updateDonorReceiptData: (data: Partial) => void; +} + +const DonorReceiptContext = createContext( + null +); + +export const DonorReceiptProvider: FC = ({ children }) => { + const [donorReceiptData, setDonorReceiptData] = useState( + null + ); + + const updateDonorReceiptData = (data: Partial) => { + const formattedData = formatReceiptData(data); + setDonorReceiptData((prevState) => ({ + ...prevState, + ...formattedData, + })); + }; + + const value: DonorReceiptContextInterface = useMemo( + () => ({ + donorReceiptData, + updateDonorReceiptData, + }), + [updateDonorReceiptData, donorReceiptData] + ); + + return ( + + {children} + + ); +}; + +export const useDonorReceipt = (): DonorReceiptContextInterface => { + const context = useContext(DonorReceiptContext); + if (!context) + throw new Error( + 'DonorReceiptContext must be used within DonorReceiptProvider' + ); + return context; +}; diff --git a/src/features/user/DonorReceipt/DonorReceiptLayout.tsx b/src/features/user/DonorReceipt/DonorReceiptLayout.tsx new file mode 100644 index 000000000..306bc13d3 --- /dev/null +++ b/src/features/user/DonorReceipt/DonorReceiptLayout.tsx @@ -0,0 +1,74 @@ +import type { ReceiptDataAPI } from './donorReceipt'; +import type { APIError } from '@planet-sdk/common'; + +import { useContext, useEffect, useState } from 'react'; +import { handleError } from '@planet-sdk/common'; +import Skeleton from 'react-loading-skeleton'; +import 'react-loading-skeleton/dist/skeleton.css'; +import { useRouter } from 'next/router'; +import { useDonorReceipt } from '../../common/Layout/DonorReceiptContext'; +import styles from './donationReceipt.module.scss'; +import ReceiptDataSection from './microComponents/ReceiptDataSection'; +import ReceiptVerificationHeader from './microComponents/ReceiptVerificationHeader'; +import ReceiptListRedirect from './microComponents/ReceiptListRedirect'; +import { useTenant } from '../../common/Layout/TenantContext'; +import { getRequest } from '../../../utils/apiRequests/api'; +import { ErrorHandlingContext } from '../../common/Layout/ErrorHandlingContext'; + +export const DonorReceiptLayout = () => { + const [isLoading, setIsLoading] = useState(false); + const { tenantConfig } = useTenant(); + const { setErrors, redirect } = useContext(ErrorHandlingContext); + const router = useRouter(); + const { dtn, year, challenge } = router.query; + const { updateDonorReceiptData, donorReceiptData } = useDonorReceipt(); + const showReceipt = !isLoading && donorReceiptData !== null; + + useEffect(() => { + if (!(dtn || year || challenge || router.isReady)) return; + if ( + typeof dtn !== 'string' || + typeof year !== 'string' || + typeof challenge !== 'string' + ) + return; + const fetchReceiptData = async () => { + setIsLoading(true); + try { + const data = await getRequest({ + tenant: tenantConfig.id, + url: '/app/donationReceipt', + queryParams: { + dtn, + year, + challenge, + }, + }); + if (data) updateDonorReceiptData(data); + } catch (err) { + setErrors(handleError(err as APIError)); + redirect('/'); + } finally { + setIsLoading(false); + } + }; + + fetchReceiptData(); + }, [dtn, year, challenge, router.isReady]); + + return showReceipt ? ( +
+
+ + + +
+
+ ) : ( +
+ +
+ ); +}; + +export default DonorReceiptLayout; diff --git a/src/features/user/DonorReceipt/donationReceipt.module.scss b/src/features/user/DonorReceipt/donationReceipt.module.scss new file mode 100644 index 000000000..026cefa08 --- /dev/null +++ b/src/features/user/DonorReceipt/donationReceipt.module.scss @@ -0,0 +1,181 @@ +@import '../../../theme/theme'; + +@mixin flex-layout( + $display: flex, + $flex-direction: null, + $justify-content: null, + $align-items: null, + $gap: null +) { + display: $display; + + @if $justify-content { + justify-content: $justify-content; + } + + @if $align-items { + align-items: $align-items; + } + + @if $gap { + gap: $gap; + } + + @if $flex-direction { + flex-direction: $flex-direction; + } +} + +.donorReceiptSkeleton { + @include flex-layout(flex, null, center); + margin-top: 110px; +} + +.donorReceiptLayout { + @include flex-layout(flex, column, null, center); + width: 100vw; + height: calc(100vh - 80px); + margin-top: 80px; + background: var(--background-base); + @include xsPhoneView { + padding: 0 2%; + } +} + +.donorReceiptContainer { + @include flex-layout(flex, column, null, null, 20px); + width: 100%; + max-width: 760px; + min-height: 60px; + margin-top: 60px; + @include xsPhoneView { + @include flex-layout(flex, null, null, center); + } +} + +.receiptVerificationHeader { + h2 { + font-size: $fontLarge; + font-weight: 600; + } + + h3 { + font-size: $fontSmall; + font-weight: 400; + color: rgba(51, 51, 51, 1); + } +} + +.receiptDataSection { + width: 100%; + border-radius: 16px; + padding: 16px; + background: #fff; +} + +.donationRecord { + @include flex-layout(flex, column, null, null, 12px); + width: 100%; + padding: 6px 16px 16px 16px; + + @include xsPhoneView { + padding: 6px 6px 16px 6px; + } + + .header { + @include flex-layout(flex, null, space-between); + font-weight: 700; + } +} + +.paymentDate { + text-align: right; +} + +.amountDonated { + text-align: center; +} + +.record { + @include flex-layout(flex, null, space-between, center); + padding-top: 8px; + padding-bottom: 8px; + + .amount { + flex: 1; + text-align: center; + } + + .reference { + flex: 1; + text-align: left; + } + + .date { + flex: 1; + text-align: right; + } +} + +.record:not(:last-child) { + border-bottom: 1px solid #ccc; +} + +.donorDetails { + @include flex-layout(flex, column, null, null, 20px); + background: rgba(242, 242, 242, 0.5); + width: 100%; + margin-top: 20px; + border-radius: 9px; + padding: 10px 15px; + + .header { + font-weight: 700; + font-size: $fontSmall; + } + + .details { + @include flex-layout(flex, row, space-between, null, 28px); + flex-wrap: wrap; + } +} + +.address, +.donorName, +.companyName, +.tin { + @include flex-layout(flex, column, null, null, 7px); + color: rgba(130, 130, 130, 1); + + .header { + font-size: $fontXSmall; + font-weight: 700; + } +} + +.receiptActions { + @include flex-layout(flex, null, flex-end, null, 12px); + margin-top: 40px; + padding: 0px 10px; + + @include xsPhoneView { + @include flex-layout(flex, column); + } + + button { + flex: 1 1; + } + + svg { + width: 13px; + } +} + +.downloadButtonContainer { + @include flex-layout(flex, null, flex-end); +} + +.receiptVerificationSpinner { + @include flex-layout(flex, null, center); + margin-top: 40px; +} diff --git a/src/features/user/DonorReceipt/donorReceipt.d.ts b/src/features/user/DonorReceipt/donorReceipt.d.ts new file mode 100644 index 000000000..c2ce6e7f8 --- /dev/null +++ b/src/features/user/DonorReceipt/donorReceipt.d.ts @@ -0,0 +1,59 @@ +export type IssuedDonationView = { + amount: number; + currency: string; + paymentDate: string; + reference: string; +}; + +export type DonorAPI = { + tin: string | null; + city: string; + name: string; + type: 'individual' | 'organization'; + email: string; + country: string; + zipCode: string; + address1: string; + address2: string | null; + reference: string; +}; + +export type DonorView = { + tin: string | null; + name: string; + type: 'individual' | 'organization' | null; +}; + +export type AddressView = { + city: string; + country: string; + zipCode: string; + address1: string; + address2: string | null; +}; + +type ReceiptDataBase = { + dtn: string; + year: string; + challenge: string; + amount: number; + currency: string; + paymentDate: string; + verificationDate: string | null; + downloadUrl: string; + donationCount: number; +}; + +export interface ReceiptDataAPI extends ReceiptDataBase { + country: string; + reference: string; + donor: DonorAPI; + donations: IssuedDonationView[]; +} +export interface ReceiptData extends ReceiptDataBase { + operation: 'verify' | 'download'; + donor: DonorView; + address: AddressView; + issuedDonations: IssuedDonationView[] | null; + hasDonorDataChanged: boolean; // Set it to true if the user modifies the data during the receipt verification process +} diff --git a/src/features/user/DonorReceipt/microComponents/DonationData.tsx b/src/features/user/DonorReceipt/microComponents/DonationData.tsx new file mode 100644 index 000000000..f1981be9f --- /dev/null +++ b/src/features/user/DonorReceipt/microComponents/DonationData.tsx @@ -0,0 +1,44 @@ +import type { IssuedDonationView } from '../donorReceipt'; + +import { useTranslations } from 'next-intl'; +import styles from '../donationReceipt.module.scss'; +import formatDate from '../../../../utils/countryCurrency/getFormattedDate'; + +type Props = { + donations: IssuedDonationView[] | null; +}; + +const DonationData = ({ donations }: Props) => { + const t = useTranslations('Donate.donationReceipt'); + return ( +
+
+
+ {t('referenceNumber')} + {t('amountDonated')} + {t('paymentDate')} +
+
    + {donations?.map((dtn) => { + return ( +
  • + {dtn.reference} + + {t('donationAmount', { + currency: dtn.currency, + amount: dtn.amount, + })} + + +
  • + ); + })} +
+
+
+ ); +}; + +export default DonationData; diff --git a/src/features/user/DonorReceipt/microComponents/DonorDetails.tsx b/src/features/user/DonorReceipt/microComponents/DonorDetails.tsx new file mode 100644 index 000000000..e7ce3258b --- /dev/null +++ b/src/features/user/DonorReceipt/microComponents/DonorDetails.tsx @@ -0,0 +1,66 @@ +import type { AddressView, DonorView } from '../donorReceipt'; +import type { CountryCode } from '@planet-sdk/common'; + +import { useMemo } from 'react'; +import { useTranslations } from 'next-intl'; +import styles from '../donationReceipt.module.scss'; +import { getFormattedAddress } from '../../../../utils/addressManagement'; + +interface Props { + donor: DonorView | undefined; + address: AddressView | undefined; +} + +const DonorDetails = ({ donor, address }: Props) => { + if (address === undefined || donor === undefined) return null; + + const t = useTranslations('Donate'); + const tCountry = useTranslations('Country'); + const { country, zipCode, city, address1, address2 } = address; + const { type, name, tin } = donor; + const countryName = tCountry(country.toLowerCase() as Lowercase); + + const cityStatePostalString = useMemo( + () => getFormattedAddress(zipCode, city, null, countryName), + [zipCode, city, countryName] + ); + + return ( +
+

+ {t('donationReceipt.recipientInfoHeader')} +

+
+
+ + {t('donationReceipt.name', { + type, + })} + + {name} +
+ {tin && ( +
+ + {t('donationReceipt.taxIdentificationNumber')} + + {tin} +
+ )} +
+
+ Address +
+ {address1},{cityStatePostalString} +
+ {address2 && ( +
+ {address2},{cityStatePostalString} +
+ )} +
+
+ ); +}; + +export default DonorDetails; diff --git a/src/features/user/DonorReceipt/microComponents/ReceiptActions.tsx b/src/features/user/DonorReceipt/microComponents/ReceiptActions.tsx new file mode 100644 index 000000000..09fc2f6b7 --- /dev/null +++ b/src/features/user/DonorReceipt/microComponents/ReceiptActions.tsx @@ -0,0 +1,63 @@ +import { useRouter } from 'next/router'; +import { useTranslations } from 'next-intl'; +import { RECEIPT_STATUS } from '../utils'; +import EditIcon from '../../../../../public/assets/images/icons/EditIcon'; +import WebappButton from '../../../common/WebappButton'; +import styles from '../donationReceipt.module.scss'; +import DownloadIcon from '../../../../../public/assets/images/icons/projectV2/DownloadIcon'; + +export type Operation = (typeof RECEIPT_STATUS)[keyof typeof RECEIPT_STATUS]; + +type Props = { + downloadUrl: string | null; + operation: Operation; + confirmDonorData: () => Promise; +}; + +const ReceiptActions = ({ + downloadUrl, + operation, + confirmDonorData, +}: Props) => { + const t = useTranslations('Donate.donationReceipt'); + const router = useRouter(); + + const showDowloadButton = + operation === RECEIPT_STATUS.DOWNLOAD && downloadUrl !== null; + return ( +
+ {showDowloadButton ? ( +
+ } + href={downloadUrl} + target={'_blank'} + /> +
+ ) : ( + <> + } + onClick={() => + router.push(`/profile/tax-receipt/modify-recipient-data`) + } + /> + + + )} +
+ ); +}; + +export default ReceiptActions; diff --git a/src/features/user/DonorReceipt/microComponents/ReceiptDataSection.tsx b/src/features/user/DonorReceipt/microComponents/ReceiptDataSection.tsx new file mode 100644 index 000000000..7abb15fdd --- /dev/null +++ b/src/features/user/DonorReceipt/microComponents/ReceiptDataSection.tsx @@ -0,0 +1,93 @@ +import type { ReceiptData } from '../donorReceipt'; +import type { APIError } from '@planet-sdk/common'; + +import { useCallback, useContext, useState } from 'react'; +import { handleError } from '@planet-sdk/common'; +import { CircularProgress } from '@mui/material'; +import { useDonorReceipt } from '../../../common/Layout/DonorReceiptContext'; +import styles from '../donationReceipt.module.scss'; +import DonationData from './DonationData'; +import ReceiptActions from './ReceiptActions'; +import DonorDetails from './DonorDetails'; +import { getVerificationDate, RECEIPT_STATUS } from '../utils'; +import { useTenant } from '../../../common/Layout/TenantContext'; +import { putRequest } from '../../../../utils/apiRequests/api'; +import { ErrorHandlingContext } from '../../../common/Layout/ErrorHandlingContext'; + +interface Prop { + donorReceiptData: ReceiptData | null; +} + +const ReceiptDataSection = ({ donorReceiptData }: Prop) => { + if (!donorReceiptData) return null; + const { updateDonorReceiptData } = useDonorReceipt(); + const { tenantConfig } = useTenant(); + const { setErrors } = useContext(ErrorHandlingContext); + const [isLoading, setIsLoading] = useState(false); + const { + issuedDonations, + downloadUrl, + operation, + donor, + address, + hasDonorDataChanged, + dtn, + challenge, + year, + } = donorReceiptData; + + const confirmDonorData = useCallback(async () => { + if (operation !== RECEIPT_STATUS.VERIFY) return; + + if (hasDonorDataChanged) { + //TODO: PUT Authentication request logic + } + + try { + setIsLoading(true); + const data = await putRequest({ + tenant: tenantConfig.id, + url: `/app/donationReceipt/verify`, + data: { + dtn, + challenge, + year, + verificationDate: getVerificationDate(), + }, + }); + if (data) updateDonorReceiptData(data); + } catch (error) { + setErrors(handleError(error as APIError)); + } finally { + setIsLoading(false); + } + }, [ + operation, + hasDonorDataChanged, + tenantConfig.id, + dtn, + challenge, + year, + updateDonorReceiptData, + ]); + + return ( +
+ + + {!isLoading ? ( + + ) : ( +
+ +
+ )} +
+ ); +}; + +export default ReceiptDataSection; diff --git a/src/features/user/DonorReceipt/microComponents/ReceiptListRedirect.tsx b/src/features/user/DonorReceipt/microComponents/ReceiptListRedirect.tsx new file mode 100644 index 000000000..bedcb8377 --- /dev/null +++ b/src/features/user/DonorReceipt/microComponents/ReceiptListRedirect.tsx @@ -0,0 +1,9 @@ +const ReceiptFooterSection = () => { + return ( +
+
+
+ ); +}; + +export default ReceiptFooterSection; diff --git a/src/features/user/DonorReceipt/microComponents/ReceiptVerificationHeader.tsx b/src/features/user/DonorReceipt/microComponents/ReceiptVerificationHeader.tsx new file mode 100644 index 000000000..b68d0c9fb --- /dev/null +++ b/src/features/user/DonorReceipt/microComponents/ReceiptVerificationHeader.tsx @@ -0,0 +1,27 @@ +import type { Operation } from './ReceiptActions'; + +import { useTranslations } from 'next-intl'; +import styles from '../donationReceipt.module.scss'; +import { RECEIPT_STATUS } from '../utils'; + +type Props = { + operation: Operation; +}; + +const ReceiptVerificationHeader = ({ operation }: Props) => { + const t = useTranslations('Donate.donationReceipt'); + return ( +
+ {operation === RECEIPT_STATUS.DOWNLOAD ? ( +

{t('downloadTaxReceipt')}

+ ) : ( + <> +

{t('verifyTaxHeaderPrimary')}

+

{t('verifyTaxHeaderSecondary')}

+ + )} +
+ ); +}; + +export default ReceiptVerificationHeader; diff --git a/src/features/user/DonorReceipt/utils.ts b/src/features/user/DonorReceipt/utils.ts new file mode 100644 index 000000000..20260f76f --- /dev/null +++ b/src/features/user/DonorReceipt/utils.ts @@ -0,0 +1,47 @@ +import type { ReceiptDataAPI, ReceiptData } from './donorReceipt'; + +export const RECEIPT_STATUS = { + VERIFY: 'verify', + DOWNLOAD: 'download', + ISSUE: 'issue', +} as const; + +export const formatReceiptData = ( + data: Partial +): ReceiptData => { + return { + dtn: data.dtn || '', + year: data.year || '', + challenge: data.challenge || '', + amount: data.amount || 0, + currency: data.currency || '', + paymentDate: data.paymentDate || '', + verificationDate: data.verificationDate || null, + downloadUrl: data.downloadUrl || '', + donationCount: data.donationCount || 0, + operation: + data.verificationDate === null + ? RECEIPT_STATUS.VERIFY + : RECEIPT_STATUS.DOWNLOAD, + donor: { + tin: data.donor?.tin || null, + name: data.donor?.name || '', + type: data.donor?.type || null, + }, + address: { + city: data.donor?.city || '', + country: data.donor?.country || '', + zipCode: data.donor?.zipCode || '', + address1: data.donor?.address1 || '', + address2: data.donor?.address2 || null, + }, + issuedDonations: data.donations || null, + hasDonorDataChanged: false, + }; +}; + +export const getVerificationDate = () => { + const isoDate = new Date().toISOString(); + const verificationDate = isoDate.replace('T', ' ').split('.')[0]; + return verificationDate; +}; diff --git a/src/utils/apiRequests/api.ts b/src/utils/apiRequests/api.ts index 8dba539ab..9fee567f6 100644 --- a/src/utils/apiRequests/api.ts +++ b/src/utils/apiRequests/api.ts @@ -48,6 +48,10 @@ interface GetRequestOptions extends BaseRequestOptions { queryParams?: { [key: string]: string }; version?: string; } + +interface PutRequestOptions extends BaseRequestOptions { + data: any; +} // API call to private /profile endpoint export async function getAccountInfo({ tenant, @@ -252,7 +256,37 @@ export function postRequest({ tenant, url, data }: PostRequestOptions) { })(); }); } +export function putRequest({ tenant, url, data }: PutRequestOptions) { + const lang = localStorage.getItem('language') || 'en'; + return new Promise((resolve, reject) => { + (async () => { + try { + const res = await fetch(process.env.API_ENDPOINT + url, { + method: 'PUT', + body: JSON.stringify(data), + headers: { + 'Content-Type': 'application/json', + 'tenant-key': `${tenant}`, + 'X-SESSION-ID': await getsessionId(), + 'x-locale': lang, + }, + }); + if (!res.ok) { + throw new APIError(res.status, await res.json()); + } + + if (res.status === 204) { + resolve(true as T); + } else { + resolve(await res.json()); + } + } catch (error) { + reject(error); + } + })(); + }); +} export function deleteAuthenticatedRequest({ tenant, url,