diff --git a/.env.example b/.env.example index 8c09bf33e3..85a03ea086 100644 --- a/.env.example +++ b/.env.example @@ -20,6 +20,8 @@ NEXT_PUBLIC_AUTH_BASE_ROUTE= NEXT_PUBLIC_NOTIFICATION_BASE_ROUTE= NEXT_PUBLIC_BACKEND_LINK= NEXT_PUBLIC_FRONTEND_LINK= +NEXT_PUBLIC_V6_FRONTEND_LINK= +NEXT_PUBLIC_V6_GRAPHQL_ENDPOINT= # backup donation service MONGO_DONATION_URL= diff --git a/.gitignore b/.gitignore index 4ef86cc77b..93e680f355 100644 --- a/.gitignore +++ b/.gitignore @@ -29,6 +29,7 @@ yarn-error.log* .env.development.local .env.test.local .env.production.local +.env* # vercel .vercel diff --git a/lang/ct.json b/lang/ct.json index c89471eefc..bdce55563d 100644 --- a/lang/ct.json +++ b/lang/ct.json @@ -230,6 +230,9 @@ "label.campaign": "Campanya", "label.cancel": "Cancel·lar", "label.cancel_upload": "Cancel·la la pujada", + "label.v6_qf_redirect.body": "Per assegurar-te que la teva donació compti per al matching, dona a través de la pàgina de la ronda de QF.", + "label.v6_qf_redirect.go_to_qf_project_page": "Anar a la pàgina del projecte a QF", + "label.v6_qf_redirect.title": "Aquest projecte és en una ronda de Finançament quadràtic", "label.cant_donate": "No podeu fer una donació? Compartiu aquesta pàgina en lloc.", "label.categories_for_your_project": "categories per al teu projecte.", "label.category": "per categoria", diff --git a/lang/en.json b/lang/en.json index d11b0c8756..87520a91d1 100644 --- a/lang/en.json +++ b/lang/en.json @@ -230,6 +230,9 @@ "label.campaign": "Campaign", "label.cancel": "Cancel", "label.cancel_upload": "Cancel upload", + "label.v6_qf_redirect.body": "To make sure your donation counts toward matching, please donate through the QF round page.", + "label.v6_qf_redirect.go_to_qf_project_page": "Go to QF project page", + "label.v6_qf_redirect.title": "This project is in a Quadratic Funding round", "label.cant_donate": "Can't donate? Share this page instead.", "label.categories_for_your_project": "categories for your project.", "label.category": "by category", diff --git a/lang/es.json b/lang/es.json index 4142fe92ca..d51fd76011 100644 --- a/lang/es.json +++ b/lang/es.json @@ -228,6 +228,9 @@ "label.campaign": "Campaña", "label.cancel": "Cancelar", "label.cancel_upload": "Cancelar subida", + "label.v6_qf_redirect.body": "Para asegurarte de que tu donación cuente para el matching, dona a través de la página de la ronda de QF.", + "label.v6_qf_redirect.go_to_qf_project_page": "Ir a la página del proyecto en QF", + "label.v6_qf_redirect.title": "Este proyecto está en una ronda de Financiamiento Cuadrático", "label.cant_donate": "¿No puedes donar? Comparte esta pagina.", "label.categories_for_your_project": "categorias para tu proyecto.", "label.category": "por categoria", diff --git a/src/components/V6ProjectDonateLink.tsx b/src/components/V6ProjectDonateLink.tsx new file mode 100644 index 0000000000..506b51043f --- /dev/null +++ b/src/components/V6ProjectDonateLink.tsx @@ -0,0 +1,90 @@ +import Link, { LinkProps } from 'next/link'; +import { CSSProperties, MouseEvent, ReactNode, useState } from 'react'; +import { useRouter } from 'next/router'; +import { useQueryClient } from '@tanstack/react-query'; +import V6ProjectQFRedirectModal from '@/components/modals/V6ProjectQFRedirectModal'; +import { v6QfRedirectQueryOptions } from '@/services/v6QF'; + +interface IV6ProjectDonateLinkProps extends LinkProps { + children: ReactNode; + projectId?: number | string; + className?: string; + id?: string; + style?: CSSProperties; + onClick?: (event: MouseEvent) => void; +} + +const shouldInterceptDonateClick = (event: MouseEvent) => { + return ( + event.button === 0 && + !event.defaultPrevented && + !event.metaKey && + !event.ctrlKey && + !event.shiftKey && + !event.altKey + ); +}; + +export const V6ProjectDonateLink = ({ + children, + projectId, + href, + onClick, + ...linkProps +}: IV6ProjectDonateLinkProps) => { + const router = useRouter(); + const queryClient = useQueryClient(); + const [redirectUrl, setRedirectUrl] = useState(); + const [showRedirectModal, setShowRedirectModal] = useState(false); + const [isChecking, setIsChecking] = useState(false); + + return ( + <> + { + onClick?.(event); + if ( + !shouldInterceptDonateClick(event) || + !projectId || + typeof href !== 'string' + ) { + return; + } + + event.preventDefault(); + setIsChecking(true); + + try { + const redirectInfo = await queryClient.fetchQuery( + v6QfRedirectQueryOptions(Number(projectId)), + ); + if (redirectInfo?.redirectUrl) { + setRedirectUrl(redirectInfo.redirectUrl); + setShowRedirectModal(true); + return; + } + } catch { + // Fall through to normal navigation on error + } finally { + setIsChecking(false); + } + + router.push(href); + }} + aria-busy={isChecking || undefined} + > + {children} + + {showRedirectModal && redirectUrl && ( + + )} + + ); +}; + +export default V6ProjectDonateLink; diff --git a/src/components/modals/V6ProjectQFRedirectModal.tsx b/src/components/modals/V6ProjectQFRedirectModal.tsx new file mode 100644 index 0000000000..59a661bd18 --- /dev/null +++ b/src/components/modals/V6ProjectQFRedirectModal.tsx @@ -0,0 +1,85 @@ +import { FC } from 'react'; +import styled from 'styled-components'; +import { Button, P } from '@giveth/ui-design-system'; +import { useIntl } from 'react-intl'; +import { mediaQueries } from '@/lib/constants/constants'; +import { Modal } from '@/components/modals/Modal'; +import { useModalAnimation } from '@/hooks/useModalAnimation'; +import { IModal } from '@/types/common'; + +interface IV6ProjectQFRedirectModalProps extends IModal { + redirectUrl: string; +} + +export const V6ProjectQFRedirectModal: FC = ({ + setShowModal, + redirectUrl, +}) => { + const { isAnimating, closeModal } = useModalAnimation(setShowModal); + const { formatMessage } = useIntl(); + + return ( + + + + {formatMessage({ + id: 'label.v6_qf_redirect.body', + })} + + + { + window.location.assign(redirectUrl); + }} + /> + + + + + ); +}; + +const ModalContainer = styled.div` + padding: 24px; + width: 100%; + + ${mediaQueries.tablet} { + width: 494px; + } +`; + +const Description = styled(P)` + margin: 0 0 24px; + text-align: left; +`; + +const Buttons = styled.div` + display: flex; + flex-direction: column; + gap: 12px; +`; + +const PrimaryButton = styled(Button)``; + +const SecondaryButton = styled(Button)``; + +export default V6ProjectQFRedirectModal; diff --git a/src/components/project-card/ProjectCard.tsx b/src/components/project-card/ProjectCard.tsx index 48f99a73c3..7f49a5786f 100644 --- a/src/components/project-card/ProjectCard.tsx +++ b/src/components/project-card/ProjectCard.tsx @@ -46,6 +46,7 @@ import { getSumDonationValueUsdForActiveQfRound, haveProjectRound, } from '@/lib/helpers/projectHelpers'; +import V6ProjectDonateLink from '@/components/V6ProjectDonateLink'; const cardRadius = '12px'; const imgHeight = '226px'; @@ -424,8 +425,9 @@ const ProjectCard = (props: IProjectCard) => { {!isListingInsideCauseProjectTabs && ( - { setDestination(donateLink); @@ -438,7 +440,7 @@ const ProjectCard = (props: IProjectCard) => { label={formatMessage({ id: 'label.donate' })} $isHover={isHover} /> - + )} {showHintModal && activeQFRound && ( diff --git a/src/components/views/donate/DonateIndex.tsx b/src/components/views/donate/DonateIndex.tsx index 43c41f8c37..fc6da03b16 100644 --- a/src/components/views/donate/DonateIndex.tsx +++ b/src/components/views/donate/DonateIndex.tsx @@ -42,6 +42,7 @@ import { isWalletSanctioned } from '@/services/donation'; import SanctionModal from '@/components/modals/SanctionedModal'; import { GIVBACKS_DONATION_QUALIFICATION_VALUE_USD } from '@/lib/constants/constants'; import QRDonationDetails from './OneTime/SelectTokenModal/QRCodeDonation/QRDonationDetails'; +import V6ProjectQFRedirectModal from '@/components/modals/V6ProjectQFRedirectModal'; const DonateIndex: FC = () => { const { formatMessage } = useIntl(); @@ -61,6 +62,10 @@ const DonateIndex: FC = () => { startTimer, setDonateModalByPriority, setIsModalPriorityChecked, + isV6ProjectInActiveQFRound, + v6ProjectRedirectUrl, + showV6ProjectRedirectModal, + setShowV6ProjectRedirectModal, } = useDonateData(); const { renewExpirationDate, retrieveDraftDonation } = useQRCodeDonation(project); @@ -118,6 +123,12 @@ const DonateIndex: FC = () => { validateSanctions(); }, [project, address]); + useEffect(() => { + if (isV6ProjectInActiveQFRound) { + setShowV6ProjectRedirectModal(true); + } + }, [isV6ProjectInActiveQFRound, setShowV6ProjectRedirectModal]); + useEffect(() => { if ( userData?.id !== undefined && @@ -272,29 +283,38 @@ const DonateIndex: FC = () => { - {shouldRenderModal( - DonateModalPriorityValues.DonationByProjectOwner, - ) && ( - { - setDonateModalByPriority( - DonateModalPriorityValues.None, - ); - }} + {showV6ProjectRedirectModal && v6ProjectRedirectUrl && ( + )} + {shouldRenderModal( + DonateModalPriorityValues.DonationByProjectOwner, + ) && + !isV6ProjectInActiveQFRound && ( + { + setDonateModalByPriority( + DonateModalPriorityValues.None, + ); + }} + /> + )} + {shouldRenderModal( DonateModalPriorityValues.OFACSanctionListModal, - ) && ( - { - setDonateModalByPriority( - DonateModalPriorityValues.None, - ); - }} - /> - )} + ) && + !isV6ProjectInActiveQFRound && ( + { + setDonateModalByPriority( + DonateModalPriorityValues.None, + ); + }} + /> + )} {showAlreadyDonatedWrapper && ( diff --git a/src/components/views/donate/OneTime/OneTimeDonationCard.tsx b/src/components/views/donate/OneTime/OneTimeDonationCard.tsx index dbf926c18a..d3c355c438 100644 --- a/src/components/views/donate/OneTime/OneTimeDonationCard.tsx +++ b/src/components/views/donate/OneTime/OneTimeDonationCard.tsx @@ -88,6 +88,9 @@ const CryptoDonation: FC<{ shouldRenderModal, setDonateModalByPriority, setIsModalPriorityChecked, + isV6ProjectInActiveQFRound, + setShowV6ProjectRedirectModal, + ensureV6ProjectRedirect, } = useDonateData(); const dispatch = useAppDispatch(); @@ -586,7 +589,16 @@ const CryptoDonation: FC<{ id='Donate_Final' label={formatMessage({ id: 'label.donate' })} size='medium' - onClick={handleDonate} + onClick={async () => { + if (isV6ProjectInActiveQFRound) { + setShowV6ProjectRedirectModal(true); + return; + } + if (await ensureV6ProjectRedirect()) { + return; + } + handleDonate(); + }} /> ))} {!isConnected && ( diff --git a/src/components/views/donate/Recurring/RecurringDonationCard.tsx b/src/components/views/donate/Recurring/RecurringDonationCard.tsx index c43f756841..13b561db83 100644 --- a/src/components/views/donate/Recurring/RecurringDonationCard.tsx +++ b/src/components/views/donate/Recurring/RecurringDonationCard.tsx @@ -98,7 +98,14 @@ export function mapValueInverse(value: number) { } export const RecurringDonationCard = () => { - const { project, selectedRecurringToken, tokenStreams } = useDonateData(); + const { + project, + selectedRecurringToken, + tokenStreams, + isV6ProjectInActiveQFRound, + setShowV6ProjectRedirectModal, + ensureV6ProjectRedirect, + } = useDonateData(); const isGivethProject = Number(project.id!) === config.GIVETH_PROJECT_ID; const isActive = project.status?.name === EProjectStatus.ACTIVE; const [amount, setAmount] = useState(0n); @@ -774,7 +781,16 @@ export const RecurringDonationCard = () => { )} { + if (isV6ProjectInActiveQFRound) { + setShowV6ProjectRedirectModal(true); + return; + } + if (await ensureV6ProjectRedirect()) { + return; + } + handleDonate(); + }} disabled={!isActive || isFormInvalid} /> diff --git a/src/components/views/project/ProjectIndex.tsx b/src/components/views/project/ProjectIndex.tsx index ca423bb95c..d57f9e6bb1 100644 --- a/src/components/views/project/ProjectIndex.tsx +++ b/src/components/views/project/ProjectIndex.tsx @@ -16,7 +16,6 @@ import { P, IconSpark, } from '@giveth/ui-design-system'; -import Link from 'next/link'; import styled from 'styled-components'; import { useIntl } from 'react-intl'; import ProjectHeader from './ProjectHeader'; @@ -47,6 +46,7 @@ import { ChainType } from '@/types/config'; import { useAppSelector } from '@/features/hooks'; import { EndaomentProjectsInfo } from '@/components/views/project/EndaomentProjectsInfo'; import VerifyEmailBanner from '../userProfile/VerifyEmailBanner'; +import V6ProjectDonateLink from '@/components/V6ProjectDonateLink'; const ProjectDonations = dynamic( () => import('./projectDonations/ProjectDonations.index'), @@ -184,7 +184,8 @@ const ProjectIndex: FC = () => {

- @@ -192,7 +193,7 @@ const ProjectIndex: FC = () => { id: 'page.project.donate_with_stellar', })} - + )} { const [showModal, setShowShareModal] = useState(false); @@ -134,8 +134,9 @@ export const ProjectPublicActions = () => { return ( - { disabled={!isActive} linkType='primary' /> - + isActive && setShowShareModal(true)} diff --git a/src/components/views/project/projectDonations/NoDonation.tsx b/src/components/views/project/projectDonations/NoDonation.tsx index 9eefaae294..f537c22190 100644 --- a/src/components/views/project/projectDonations/NoDonation.tsx +++ b/src/components/views/project/projectDonations/NoDonation.tsx @@ -1,5 +1,4 @@ import { ButtonLink, H1, H2, brandColors } from '@giveth/ui-design-system'; -import Link from 'next/link'; import { FC, useState } from 'react'; import styled from 'styled-components'; import { useIntl } from 'react-intl'; @@ -8,6 +7,7 @@ import { useProjectContext } from '@/context/project.context'; import { IQFRound } from '@/apollo/types/types'; import { getNowUnixMS } from '@/helpers/time'; import { RoundNotStartedModal } from '@/components/project-card/RoundNotStartedModal'; +import V6ProjectDonateLink from '@/components/V6ProjectDonateLink'; interface INoDonation { selectedQF: IQFRound | null; @@ -19,7 +19,7 @@ export const NoDonation: FC = ({ selectedQF, recurring }) => { const { formatMessage } = useIntl(); const { projectData, isActive, isCause } = useProjectContext(); - const { slug } = projectData || {}; + const { slug, id } = projectData || {}; const _startDate = selectedQF ? new Date(selectedQF.beginDate).getTime() : 0; @@ -55,12 +55,16 @@ export const NoDonation: FC = ({ selectedQF, recurring }) => {

in This Round

)} {isActive ? ( - handleClick(e)}> + handleClick(e)} + > - + ) : ( >; setPendingDonationExists?: Dispatch>; + isV6ProjectInActiveQFRound: boolean; + isV6ProjectInActiveQFRoundLoading: boolean; + v6ProjectRedirectUrl?: string; + showV6ProjectRedirectModal: boolean; + setShowV6ProjectRedirectModal: Dispatch>; + ensureV6ProjectRedirect: () => Promise; } interface IProviderProps { @@ -104,6 +116,11 @@ const DonateContext = createContext({ draftDonationLoading: false, setDraftDonationData: () => {}, setPendingDonationExists: () => {}, + isV6ProjectInActiveQFRound: false, + isV6ProjectInActiveQFRoundLoading: false, + showV6ProjectRedirectModal: false, + setShowV6ProjectRedirectModal: () => {}, + ensureV6ProjectRedirect: async () => false, }); DonateContext.displayName = 'DonateContext'; @@ -143,8 +160,11 @@ export const DonateProvider: FC = ({ children, project }) => { const [projectData, setProjectData] = useState(project); const [currentDonateModal, setCurrentDonateModal] = useState(DonateModalPriorityValues.None); + const [showV6ProjectRedirectModal, setShowV6ProjectRedirectModal] = + useState(false); const { chain } = useAccount(); + const queryClient = useQueryClient(); useEffect(() => { setSelectedOneTimeToken(undefined); @@ -240,6 +260,33 @@ export const DonateProvider: FC = ({ children, project }) => { project?.qfRounds, ).activeStartedRound; + const { + data: v6ActiveQfProjectRedirect, + isLoading: isV6ProjectInActiveQFRoundLoading, + } = useQuery({ + ...v6QfRedirectQueryOptions(Number(projectData.id)), + enabled: !!projectData.id, + refetchInterval: V6_ACTIVE_QF_PROJECT_REDIRECT_STALE_TIME, + }); + const v6ProjectRedirectUrl = v6ActiveQfProjectRedirect?.redirectUrl; + const isV6ProjectInActiveQFRound = !!v6ProjectRedirectUrl; + + const ensureV6ProjectRedirect = useCallback(async () => { + const projectId = Number(projectData.id); + + const redirectInfo = + await queryClient.fetchQuery( + v6QfRedirectQueryOptions(projectId), + ); + + if (redirectInfo?.redirectUrl) { + setShowV6ProjectRedirectModal(true); + return true; + } + + return false; + }, [projectData?.id, queryClient]); + return ( = ({ children, project }) => { draftDonationLoading: loading, choosedModalRound, setChoosedModalRound, + isV6ProjectInActiveQFRound, + isV6ProjectInActiveQFRoundLoading, + v6ProjectRedirectUrl, + showV6ProjectRedirectModal, + setShowV6ProjectRedirectModal, + ensureV6ProjectRedirect, }} > {children} diff --git a/src/lib/helpers/v6QF.ts b/src/lib/helpers/v6QF.ts new file mode 100644 index 0000000000..8b9527d5dd --- /dev/null +++ b/src/lib/helpers/v6QF.ts @@ -0,0 +1,52 @@ +import { getNowUnixMS } from '@/helpers/time'; + +export interface IV6QFRound { + id: number | string; + isActive: boolean; + beginDate: string; + endDate: string; +} + +export interface IV6Project { + id: number | string; + slug: string; + title: string; + qfRounds?: IV6QFRound[]; +} + +export const V6_PROJECT_BY_ID_WITH_QF_ROUNDS_QUERY = ` + query ProjectById($id: Int!) { + project(id: $id) { + id + slug + title + projectQfRounds { + qfRound { + id + isActive + beginDate + endDate + } + } + } + } +`; + +export const isV6QfRoundCurrentlyActive = (round?: IV6QFRound | null) => { + if (!round?.isActive) return false; + + const now = getNowUnixMS(); + const beginDate = new Date(round.beginDate).getTime(); + const endDate = new Date(round.endDate).getTime(); + + return beginDate <= now && now <= endDate; +}; + +export const getActiveV6QfRound = (project?: IV6Project | null) => { + return project?.qfRounds?.find(round => isV6QfRoundCurrentlyActive(round)); +}; + +export const buildV6ProjectUrl = (baseUrl: string, projectSlug: string) => { + const normalizedBaseUrl = baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`; + return new URL(`project/${projectSlug}`, normalizedBaseUrl).toString(); +}; diff --git a/src/services/v6QF.ts b/src/services/v6QF.ts new file mode 100644 index 0000000000..05afc78644 --- /dev/null +++ b/src/services/v6QF.ts @@ -0,0 +1,75 @@ +import config from '@/configuration'; +import { gqlRequest } from '@/helpers/requests'; +import { + buildV6ProjectUrl, + getActiveV6QfRound, + IV6Project, + V6_PROJECT_BY_ID_WITH_QF_ROUNDS_QUERY, +} from '@/lib/helpers/v6QF'; + +interface IV6ProjectByIdResponse { + data?: { + project?: IV6Project | null; + }; + errors?: { + message: string; + }[]; +} + +export interface IV6ActiveQfProjectRedirect { + projectSlug: string; + projectTitle: string; + redirectUrl: string; +} + +export const V6_ACTIVE_QF_PROJECT_REDIRECT_STALE_TIME = 5 * 60 * 1000; // 5 minutes + +export const fetchV6ActiveQfProjectRedirect = async (projectId: number) => { + if (!config.V6_FRONTEND_LINK || !config.V6_GRAPHQL_ENDPOINT) { + return null; + } + + const response = (await gqlRequest( + config.V6_GRAPHQL_ENDPOINT, + false, + V6_PROJECT_BY_ID_WITH_QF_ROUNDS_QUERY, + { id: projectId }, + )) as IV6ProjectByIdResponse; + + if (response.errors?.length) { + throw new Error(response.errors[0].message); + } + + const project = response.data?.project; + if (!project?.slug || !getActiveV6QfRound(project)) { + return null; + } + + return { + projectSlug: project.slug, + projectTitle: project.title, + redirectUrl: buildV6ProjectUrl(config.V6_FRONTEND_LINK, project.slug), + }; +}; + +export const getV6ActiveQfProjectRedirect = async (projectId?: number) => { + if (!projectId || Number.isNaN(projectId)) { + return null; + } + + try { + return await fetchV6ActiveQfProjectRedirect(projectId); + } catch (error) { + console.error('Error fetching v6 QF round data:', error); + return null; + } +}; + +export const v6QfRedirectQueryOptions = (projectId: number) => { + return { + queryKey: ['v6-active-qf-project-redirect', projectId], + queryFn: () => getV6ActiveQfProjectRedirect(projectId), + staleTime: V6_ACTIVE_QF_PROJECT_REDIRECT_STALE_TIME, + gcTime: V6_ACTIVE_QF_PROJECT_REDIRECT_STALE_TIME * 5, + }; +}; diff --git a/src/types/config.ts b/src/types/config.ts index 35c5c4fc3d..84ac98ac55 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -234,6 +234,8 @@ export interface EnvConfig { CLASSIC_CONFIG: NetworkConfig; BACKEND_LINK: string; FRONTEND_LINK: string; + V6_FRONTEND_LINK: string; + V6_GRAPHQL_ENDPOINT: string; MICROSERVICES: MicroservicesConfig; RARIBLE_ADDRESS: string; SOLANA_CONFIG: NonEVMNetworkConfig;