diff --git a/public/locales/bg/profile.json b/public/locales/bg/profile.json index a03fc02a7..afe6c1bad 100644 --- a/public/locales/bg/profile.json +++ b/public/locales/bg/profile.json @@ -1,5 +1,6 @@ { "header": "Личен профил", + "corporate-header": "Корпоративен профил", "campaigns": "Подкрепени кампании", "personalInfo": { "index": "Лична информация", @@ -13,8 +14,14 @@ "noBirthday": "не e наличен", "delete": "изтриване на акаунт/ профил" }, + "corporateInfo": { + "index": "Корпоративни данни", + "name": "Име на компания", + "number": "БУЛСТАТ/ЕИК" + }, "donations": { "index": "Дарения", + "donor": "Дарител", "helpThanks": "благодарим за помощта Ви!", "donateNow": "Подкрепи сега", "totalDonations": "Всички дарения", @@ -33,6 +40,8 @@ "sort": "Вид", "cause": "Кауза", "amount": "Сума", + "cancelDonation": "Откажи дарение", + "cancel": "Откажи", "certificate": "Сертификат", "download": "Свалете", "lv": "лв", @@ -43,7 +52,8 @@ "initial": "започнато", "waiting": "чакащо", "succeeded": "успешно", - "cancelled": "отменено" + "cancelled": "отменено", + "guaranteed": "гарантирано" } }, "certificates-history": { @@ -112,5 +122,15 @@ "saveAccount": "Запази моя акаунт", "disableAccount": "Изтрий моя акаунт", "disabled": "Акаунтът Ви е изтрит." + }, + "affiliate": { + "index": "Партньорска програма", + "join": "Включете се към програмата", + "data-summary": "Данни по партньорска програма", + "guaranteedDonationsList": "Списък с гарантирани дарения", + "code": "Код", + "status": "Статус", + "guaranteedDonations": "Гарантирани дарения(общо)", + "guaranteedAmount": "Гарантирана сума(общо)" } } diff --git a/public/locales/en/profile.json b/public/locales/en/profile.json index 6c73776a0..7aec60407 100644 --- a/public/locales/en/profile.json +++ b/public/locales/en/profile.json @@ -1,5 +1,6 @@ { "header": "Personal profile", + "corporate-header": "Corporate Profile", "personalInfo": { "index": "Personal information", "login": "Login information:", @@ -12,8 +13,14 @@ "noBirthday": "not available", "delete": "delete account/ profile" }, + "corporateInfo": { + "index": "Corporate information", + "name": "Corporate name", + "number": "BULSTAT/EIK" + }, "donations": { "index": "Donations", + "donor": "Donor", "helpThanks": "thank you for your help!", "donateNow": "Donate now", "totalDonations": "All donations", @@ -28,6 +35,8 @@ "type": "Type", "cause": "Cause", "amount": "Amount", + "cancelDonation": "Cancel donation", + "cancel": "Cancel", "certificate": "Certificate", "download": "Download", "lv": "lv", @@ -36,7 +45,8 @@ "initial": "initial", "waiting": "waiting", "succeeded": "succeeded", - "cancelled": "cancelled" + "cancelled": "cancelled", + "guaranteed": "guaranteed" } }, "certificates-history": { @@ -49,6 +59,33 @@ "noCampaigns": "You are not in organizer, coordinator or beneficiery role in any campaign", "donatedTo": "Campaigns I donated to" }, + "myNotifications": { + "index": "My notifications", + "status-title": "Notification subscription status", + "status-msg": "Your subscription status is", + "modal": { + "title-subscribe": "Subscribe for receiving notifications from Podkrepi.bg", + "title-unsubscribe": "Unsubscribe for receiving notifications from Podkrepi.bg", + "campaign-title-unsubscribe": "Unsubscribe from receiving notifications from this campaign", + "cta": "Confirm", + "subscribe-msg": "You've successfully subscribed for receiving notifications", + "unsubscribe-msg": "You've successfully unsubscribed for receiving notifications", + "campaign-unsubscribe-msg": "You've successfully unsubscribed from receiving notifications regarding this campaign" + }, + "status": { + "active": "Active", + "inactive": "Deactivated" + }, + "cta": { + "activate": "Activate", + "deactivate": "Deactivate" + }, + "campaign": { + "index": "Campaign notifications", + "noSubscriptions": "Към момента не сте се записали за получаване на известия по конкретни кампании", + "cta": "Unsubscribe" + } + }, "donationsContract": "Donation contract", "certificates": "Certificates", "birthdateModal": { @@ -78,5 +115,15 @@ "saveAccount": "Save my account", "disableAccount": "Delete my account", "disabled": "Your account has been deleted." + }, + "affiliate": { + "index": "Affiliate program", + "join": "Join the program", + "data-summary": "Affiliate program summary", + "guaranteedDonationsList": "List of guaranteed donations", + "code": "Code", + "status": "Status", + "guaranteedDonations": "Guaranteed donations(total)", + "guaranteedAmount": "Guaranteed amount(total)" } } diff --git a/src/common/hooks/affiliates.ts b/src/common/hooks/affiliates.ts new file mode 100644 index 000000000..353b3a300 --- /dev/null +++ b/src/common/hooks/affiliates.ts @@ -0,0 +1,50 @@ +import { useSession } from 'next-auth/react' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { endpoints } from 'service/apiEndpoints' +import { authQueryFnFactory } from 'service/restRequests' +import { AlertStore } from 'stores/AlertStore' +import { useTranslation } from 'react-i18next' +import { DonationResponse } from 'gql/donations' +import { AxiosError, AxiosResponse } from 'axios' +import { + AffiliateResponse, + AffiliateWithDonationResponse, + CancelAffiliateDonation, +} from 'gql/affiliate' +import { cancelAffiliateDonation, joinAffiliateProgram } from 'service/affiliate' + +export function useGetAffiliateData() { + const { data: session } = useSession() + return useQuery( + [endpoints.affiliate.getData.url], + authQueryFnFactory(session?.accessToken), + ) +} + +export function useJoinAffiliateProgramMutation() { + const { t } = useTranslation() + const mutation = useMutation>([endpoints.affiliate.join], { + mutationFn: joinAffiliateProgram, + onError: () => AlertStore.show(t('common:alerts.error'), 'error'), + onSuccess: () => AlertStore.show(t('common:alerts.message-sent'), 'success'), + }) + return mutation +} + +export function useCancelGuaranteedDonationMutation() { + const queryClient = useQueryClient() + const { t } = useTranslation() + const mutation = useMutation< + AxiosResponse, + AxiosError, + CancelAffiliateDonation + >({ + mutationFn: (data) => cancelAffiliateDonation(data), + onError: () => AlertStore.show(t('common:alerts.error'), 'error'), + onSuccess: () => { + AlertStore.show(t('common:alerts.message-sent'), 'success') + queryClient.invalidateQueries({ queryKey: [endpoints.affiliate.getData.url] }) + }, + }) + return mutation +} diff --git a/src/common/routes.ts b/src/common/routes.ts index 0b4e1f4cf..a1ac34b82 100644 --- a/src/common/routes.ts +++ b/src/common/routes.ts @@ -121,6 +121,7 @@ export const routes = { myCampaigns: '/profile/my-campaigns', recurringDonations: '/profile/recurring-donations', myNotifications: '/profile/my-notifications', + affiliateProgram: '/profile/affiliate-program', }, register: '/register', aboutProject: '/about-project', diff --git a/src/components/client/auth/profile/AffiliateProgramTab.tsx b/src/components/client/auth/profile/AffiliateProgramTab.tsx new file mode 100644 index 000000000..ef757eba0 --- /dev/null +++ b/src/components/client/auth/profile/AffiliateProgramTab.tsx @@ -0,0 +1,233 @@ +import { Box, Button, CircularProgress, Link, TableBody, Typography } from '@mui/material' +import { styled } from '@mui/material/styles' +import { useTranslation } from 'react-i18next' +import ProfileTab from './ProfileTab' +import { ProfileTabs } from './tabs' +import { + useCancelGuaranteedDonationMutation, + useGetAffiliateData, + useJoinAffiliateProgramMutation, +} from 'common/hooks/affiliates' +import { TFunction } from 'next-i18next' +import { DataGrid, GridColDef, GridRenderCellParams } from '@mui/x-data-grid' +import theme from 'common/theme' +import { useMemo } from 'react' +import TableContainer from '@mui/material/TableContainer' +import TableHead from '@mui/material/TableHead' +import TableRow from '@mui/material/TableRow' +import Table from '@mui/material/Table' +import Paper from '@mui/material/Paper' +import TableCell from '@mui/material/TableCell' +import { money } from 'common/util/money' +import { DonationResponse } from 'gql/donations' +import { routes } from 'common/routes' +import { CancelAffiliateDonation } from 'gql/affiliate' + +const PREFIX = 'AffiliateProgramTab' + +const classes = { + boxContainer: `${PREFIX}-boxContainer`, + boxTitle: `${PREFIX}-boxTitle`, + h1: `${PREFIX}-h1`, + h2: `${PREFIX}-h2`, + h3: `${PREFIX}-h3`, +} + +const Root = styled(Box)(({ theme }) => ({ + [`& .${classes.h1}`]: { + fontStyle: 'normal', + fontWeight: '500', + fontSize: '30px', + lineHeight: '65px', + paddingLeft: 2, + }, + [`& .${classes.h3}`]: { + fontStyle: 'normal', + fontWeight: '500', + fontSize: '25px', + lineHeight: '116.7%', + margin: '0', + }, + [`& .${classes.h2}`]: { + fontStyle: 'normal', + fontWeight: '500', + fontSize: '23px', + lineHeight: '116.7%', + marginBottom: theme.spacing(3), + }, + [`& .${classes.boxTitle}`]: { + backgroundColor: theme.palette.common.white, + padding: theme.spacing(3, 9), + paddingBottom: theme.spacing(3), + marginTop: theme.spacing(3), + boxShadow: theme.shadows[3], + }, +})) + +export default function AffiliateProgramTab() { + const { t } = useTranslation('') + const { data: affiliate, isLoading, isSuccess, isError } = useGetAffiliateData() + const joinMutation = useJoinAffiliateProgramMutation() + const cancelDonationMutation = useCancelGuaranteedDonationMutation() + + const onAffilateJoinRequest = () => { + joinMutation.mutate() + } + + const totalSum = useMemo(() => { + return affiliate?.donations.reduce((prev: number, curr: DonationResponse) => { + return (prev += curr.amount) + }, 0) + }, [affiliate?.donations]) + + const onGuaranteedDonationCancel = (affiliateCode: string, donationId: string) => { + const data: CancelAffiliateDonation = { + affiliateCode, + donationId, + } + cancelDonationMutation.mutate(data) + } + + if (isError) { + return {t('common:alerts.error')} + } + + if (isLoading) { + return ( + + + + ) + } + + if (isSuccess && !affiliate) { + return ( + + {t('profile:affiliate.join')} + + + ) + } + + const columns: GridColDef[] = [ + { + field: 'id', + headerName: 'id', + align: 'left', + renderCell: (params: GridRenderCellParams) => <>{params.row.id}, + }, + { + field: 'amount', + headerName: t('donations:amount'), + renderCell: (params: GridRenderCellParams) => { + return {money(params.row.amount)} + }, + align: 'left', + }, + { + field: 'donor', + headerName: t('profile:donations.donor'), + valueGetter(params) { + return params.row.metadata?.name ?? params.row.affiliate.company.companyName + }, + align: 'left', + width: 200, + }, + { + field: 'campaign', + headerName: t('profile:donations.cause'), + renderCell: (params: GridRenderCellParams) => ( + + {params.row.targetVault.campaign.title} + + ), + align: 'left', + width: 200, + }, + { + field: 'cancel', + headerName: t('profile:donations.cancelDonation'), + renderCell: (params: GridRenderCellParams) => { + return ( + + ) + }, + width: 150, + align: 'left', + }, + ] + + return ( + + + + {t('profile:affiliate.data-summary')} + + + + + + {t('profile:affiliate.status')} + {t('profile:affiliate.code')} + {t('profile:affiliate.guaranteedDonations')} + {t('profile:affiliate.guaranteedAmount')} + + + + + {affiliate.status} + {affiliate.affiliateCode} + {affiliate.donations.length} + {money(totalSum)} + + +
+
+
+ + + {t('profile:affiliate.guaranteedDonationsList')} + + + +
+ ) +} + +type Props = { + t: TFunction + children: React.ReactNode +} + +function AffiliateContainer({ t, children }: Props) { + return ( + + + {t('profile:affiliate.index')} + + {children} + + ) +} diff --git a/src/components/client/auth/profile/DonationTab.tsx b/src/components/client/auth/profile/DonationTab.tsx index 6a688e6f4..90a6d835b 100644 --- a/src/components/client/auth/profile/DonationTab.tsx +++ b/src/components/client/auth/profile/DonationTab.tsx @@ -94,7 +94,7 @@ export default function DonationTab() { const router = useRouter() const { t } = useTranslation() - const { data: user } = getCurrentPerson(!!router.query?.register) + const { data: person } = getCurrentPerson(!!router.query?.register) if (router.query?.register) { delete router.query.register @@ -107,7 +107,11 @@ export default function DonationTab() { - {user?.user ? user.user.firstName + ' ' + user.user.lastName + ',' : ''}{' '} + {person + ? person.user.company + ? `${person.user.company.companyName}` + : `${person.user.firstName} ${person.user.lastName}` + : null}{' '} {t('profile:donations.helpThanks')}{' '} diff --git a/src/components/client/auth/profile/PersonalInfoTab.tsx b/src/components/client/auth/profile/PersonalInfoTab.tsx index 1e1216c31..30cb0ab5b 100644 --- a/src/components/client/auth/profile/PersonalInfoTab.tsx +++ b/src/components/client/auth/profile/PersonalInfoTab.tsx @@ -167,6 +167,22 @@ export default function PersonalInfoTab() { + {person?.company && ( + <> +

{t('profile:corporateInfo.index')}

+ + +

{t('profile:corporateInfo.name')}

+

{person?.company.companyName}

+
+ +

{t('profile:corporateInfo.number')}

+ {person.company.companyNumber} +
+
+ + + )}

{t('profile:personalInfo.personal')}

diff --git a/src/components/client/auth/profile/ProfilePage.tsx b/src/components/client/auth/profile/ProfilePage.tsx index 984109adf..e5fda5887 100644 --- a/src/components/client/auth/profile/ProfilePage.tsx +++ b/src/components/client/auth/profile/ProfilePage.tsx @@ -11,6 +11,7 @@ import { Assignment as CertificateIcon, AccountBalance as CampaignIcon, EventRepeat as RecurringDonationIcon, + AddBusiness as AffiliateProgramIcon, } from '@mui/icons-material' import { useSession } from 'next-auth/react' @@ -52,7 +53,7 @@ export default function ProfilePage() { const matches = useMediaQuery(theme.breakpoints.down('sm')) const currentTab = router.query.slug ?? ProfileTabs.donations - const { error: userError, isError } = getCurrentPerson(!!router.query?.register) + const { data: person, error: userError, isError } = getCurrentPerson(!!router.query?.register) const tab = useMemo(() => { return tabs.find((tab) => tab.slug === currentTab) ?? tabs[0] @@ -87,7 +88,7 @@ export default function ProfilePage() { boxShadow: 3, }}>

- {t('profile:header')} + {!person?.user.company ? t('profile:header') : t('profile:corporate-header')}

@@ -139,6 +140,16 @@ export default function ProfilePage() { onClick={() => router.push(routes.profile.myNotifications)} icon={matches ? : undefined} /> + {person?.user.company && ( + router.push(routes.profile.affiliateProgram)} + icon={matches ? : undefined} + /> + )} {/* Currently we don't generate donation contract, when such document is generated we can either combine it with the certificate or unhide the contracts section. */} {/* string, diff --git a/src/pages/profile/[slug].ts b/src/pages/profile/[slug].ts index b5cf73099..b6772dbe7 100644 --- a/src/pages/profile/[slug].ts +++ b/src/pages/profile/[slug].ts @@ -1,13 +1,10 @@ -import { securedPropsWithTranslation } from 'middleware/auth/securedProps' +import { securedAdminProps } from 'middleware/auth/securedProps' import ProfilePage from 'components/client/auth/profile/ProfilePage' +import { endpoints } from 'service/apiEndpoints' -export const getServerSideProps = securedPropsWithTranslation([ - 'auth', - 'profile', - 'common', - 'validation', - 'campaigns', - 'recurring-donation', -]) +export const getServerSideProps = securedAdminProps( + ['auth', 'profile', 'common', 'validation', 'campaigns', 'recurring-donation', 'donations'], + () => endpoints.account.me.url, +) export default ProfilePage diff --git a/src/pages/profile/index.ts b/src/pages/profile/index.ts index e600f4bb6..d3f0c78f4 100644 --- a/src/pages/profile/index.ts +++ b/src/pages/profile/index.ts @@ -1,13 +1,10 @@ import ProfilePage from 'components/client/auth/profile/ProfilePage' -import { securedPropsWithTranslation } from 'middleware/auth/securedProps' +import { securedAdminProps } from 'middleware/auth/securedProps' +import { endpoints } from 'service/apiEndpoints' -export const getServerSideProps = securedPropsWithTranslation([ - 'auth', - 'profile', - 'common', - 'validation', - 'campaigns', - 'recurring-donation', -]) +export const getServerSideProps = securedAdminProps( + ['auth', 'profile', 'common', 'validation', 'campaigns', 'recurring-donation, donations'], + () => endpoints.account.me.url, +) export default ProfilePage diff --git a/src/service/affiliate.ts b/src/service/affiliate.ts new file mode 100644 index 000000000..56d29a0d2 --- /dev/null +++ b/src/service/affiliate.ts @@ -0,0 +1,21 @@ +import { getSession } from 'next-auth/react' +import { apiClient } from './apiClient' +import { authConfig } from './restRequests' +import { AffiliateResponse, CancelAffiliateDonation } from 'gql/affiliate' +import { DonationResponse } from 'gql/donations' +import { endpoints } from './apiEndpoints' + +export async function joinAffiliateProgram() { + const session = await getSession() + return await apiClient.post( + endpoints.affiliate.join.url, + null, + authConfig(session?.accessToken), + ) +} + +export async function cancelAffiliateDonation(data: CancelAffiliateDonation) { + return await apiClient.patch( + endpoints.affiliate.cancelDonation(data.affiliateCode, data.donationId).url, + ) +} diff --git a/src/service/apiEndpoints.ts b/src/service/apiEndpoints.ts index 1c907ba48..5d9a976bb 100644 --- a/src/service/apiEndpoints.ts +++ b/src/service/apiEndpoints.ts @@ -14,6 +14,12 @@ export const endpoints = { refresh: { url: '/refresh', method: 'POST' }, providerLogin: { url: '/provider-login', method: 'POST' }, }, + affiliate: { + join: { url: '/affiliate/join', method: 'POST' }, + getData: { url: '/affiliate/data', method: 'GET' }, + cancelDonation: (affiliateCode: string, donationId: string) => + { url: `/affiliate/${affiliateCode}/donations/${donationId}/cancel` }, + }, campaign: { listCampaigns: { url: '/campaign/list', method: 'GET' }, listAdminCampaigns: { url: '/campaign/list-all', method: 'GET' },