diff --git a/migration/1714018700116-add_archived_QFRound_fields.ts b/migration/1714018700116-add_archived_QFRound_fields.ts new file mode 100644 index 000000000..b4767109a --- /dev/null +++ b/migration/1714018700116-add_archived_QFRound_fields.ts @@ -0,0 +1,29 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddArchivedQFRoundFields1714018700116 + implements MigrationInterface +{ + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE "qf_round" + ADD COLUMN IF NOT EXISTS "bannerBgImage" character varying + `); + + await queryRunner.query(` + ALTER TABLE "qf_round" + ADD COLUMN IF NOT EXISTS "sponsorsImgs" character varying[] DEFAULT '{}' NOT NULL + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE "qf_round" + DROP COLUMN IF EXISTS "bannerBgImage" + `); + + await queryRunner.query(` + ALTER TABLE "qf_round" + DROP COLUMN IF EXISTS "sponsorsImgs" + `); + } +} diff --git a/migration/1714566501335-addTokenAndChainToQFRound.ts b/migration/1714566501335-addTokenAndChainToQFRound.ts new file mode 100644 index 000000000..f7cfd1c20 --- /dev/null +++ b/migration/1714566501335-addTokenAndChainToQFRound.ts @@ -0,0 +1,25 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddTokenAndChainToQFRound1714566501335 + implements MigrationInterface +{ + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE IF EXISTS "qf_round" + ADD COLUMN IF NOT EXISTS "allocatedTokenSymbol" text, + ADD COLUMN IF NOT EXISTS "allocatedTokenChainId" integer, + ADD COLUMN IF NOT EXISTS "allocatedFundUSDPreferred" boolean, + ADD COLUMN IF NOT EXISTS "allocatedFundUSD" integer; +`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE IF EXISTS "qf_round" + DROP COLUMN "allocatedTokenSymbol", + DROP COLUMN "allocatedTokenChainId", + DROP COLUMN "allocatedFundUSDPreferred", + DROP COLUMN "allocatedFundUSD"; + `); + } +} diff --git a/package-lock.json b/package-lock.json index 46cb7f720..3d829ae09 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "giveth-graphql-api", - "version": "1.23.2", + "version": "1.23.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "giveth-graphql-api", - "version": "1.23.2", + "version": "1.23.3", "hasInstallScript": true, "license": "ISC", "dependencies": { diff --git a/package.json b/package.json index 7ac059eb1..76d5d55e8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "giveth-graphql-api", - "version": "1.23.2", + "version": "1.23.3", "description": "Backend GraphQL server for Giveth originally forked from Topia", "main": "./dist/index.js", "dependencies": { diff --git a/src/adapters/notifications/MockNotificationAdapter.ts b/src/adapters/notifications/MockNotificationAdapter.ts index 2609538a1..dea041ca3 100644 --- a/src/adapters/notifications/MockNotificationAdapter.ts +++ b/src/adapters/notifications/MockNotificationAdapter.ts @@ -11,6 +11,11 @@ import { logger } from '../../utils/logger'; import { RecurringDonation } from '../../entities/recurringDonation'; export class MockNotificationAdapter implements NotificationAdapterInterface { + async createOrttoProfile(params: User): Promise { + logger.debug('MockNotificationAdapter createOrttoProfile', params); + return Promise.resolve(undefined); + } + async updateOrttoPeople(params: OrttoPerson[]): Promise { logger.debug('MockNotificationAdapter updateOrttoPeople', params); return Promise.resolve(undefined); diff --git a/src/adapters/notifications/NotificationAdapterInterface.ts b/src/adapters/notifications/NotificationAdapterInterface.ts index cad690687..8e0f88dd2 100644 --- a/src/adapters/notifications/NotificationAdapterInterface.ts +++ b/src/adapters/notifications/NotificationAdapterInterface.ts @@ -34,6 +34,8 @@ export interface OrttoPerson { } export interface NotificationAdapterInterface { + createOrttoProfile(params: User): Promise; + updateOrttoPeople(params: OrttoPerson[]): Promise; donationReceived(params: { diff --git a/src/adapters/notifications/NotificationCenterAdapter.ts b/src/adapters/notifications/NotificationCenterAdapter.ts index cc65cc942..75896d01c 100644 --- a/src/adapters/notifications/NotificationCenterAdapter.ts +++ b/src/adapters/notifications/NotificationCenterAdapter.ts @@ -94,6 +94,22 @@ export class NotificationCenterAdapter implements NotificationAdapterInterface { return; } + async createOrttoProfile(user: User): Promise { + try { + const { id, email, firstName, lastName } = user; + await callSendNotification({ + eventName: NOTIFICATIONS_EVENT_NAMES.CREATE_ORTTO_PROFILE, + trackId: 'create-ortto-profile-' + user.id, + userWalletAddress: user.walletAddress!, + segment: { + payload: { userId: id, email, firstName, lastName }, + }, + }); + } catch (e) { + logger.error('createOrttoProfile >> error', e); + } + } + async updateOrttoPeople(people: OrttoPerson[]): Promise { // TODO we should me this to notification-center, it's not good that we call Ortto directly try { @@ -1196,7 +1212,7 @@ interface SendNotificationBody { email?: string; trackId?: string; metadata?: any; - projectId: string; + projectId?: string; userWalletAddress: string; segment?: { payload: any; diff --git a/src/analytics/analytics.ts b/src/analytics/analytics.ts index 62478d0e9..e7ebf9155 100644 --- a/src/analytics/analytics.ts +++ b/src/analytics/analytics.ts @@ -46,4 +46,5 @@ export enum NOTIFICATIONS_EVENT_NAMES { SUPER_TOKENS_BALANCE_WEEK = 'One week left in stream balance', SUPER_TOKENS_BALANCE_MONTH = 'One month left in stream balance', SUPER_TOKENS_BALANCE_DEPLETED = 'Stream balance depleted', + CREATE_ORTTO_PROFILE = 'Create Ortto profile', } diff --git a/src/entities/qfRound.ts b/src/entities/qfRound.ts index 0fdce1a53..b62d7ccb7 100644 --- a/src/entities/qfRound.ts +++ b/src/entities/qfRound.ts @@ -8,8 +8,10 @@ import { UpdateDateColumn, CreateDateColumn, Index, + OneToMany, } from 'typeorm'; import { Project } from './project'; +import { Donation } from './donation'; @Entity() @ObjectType() @@ -43,6 +45,22 @@ export class QfRound extends BaseEntity { @Column() allocatedFund: number; + @Field(_type => Number, { nullable: true }) + @Column({ nullable: true }) + allocatedFundUSD: number; + + @Field(_type => Boolean, { nullable: true }) + @Column({ nullable: true }) + allocatedFundUSDPreferred: boolean; + + @Field(_type => String, { nullable: true }) + @Column({ nullable: true }) + allocatedTokenSymbol: string; + + @Field(_type => Number, { nullable: true }) + @Column({ nullable: true }) + allocatedTokenChainId: number; + @Field(_type => Number) @Column('real', { default: 0.2 }) maximumReward: number; @@ -67,6 +85,14 @@ export class QfRound extends BaseEntity { @Column() endDate: Date; + @Field(_type => String, { nullable: true }) + @Column('text', { nullable: true }) + bannerBgImage: string; + + @Field(_type => [String]) + @Column('text', { array: true, default: [] }) + sponsorsImgs: string[]; + @UpdateDateColumn() updatedAt: Date; @@ -76,6 +102,9 @@ export class QfRound extends BaseEntity { @ManyToMany(_type => Project, project => project.qfRounds) projects: Project[]; + @OneToMany(_type => Donation, donation => donation.qfRound) + donations: Donation[]; + // only projects with status active can be listed automatically isEligibleNetwork(donationNetworkId: number): boolean { // when not specified, all are valid diff --git a/src/repositories/qfRoundRepository.ts b/src/repositories/qfRoundRepository.ts index 45ad7b437..cd1b08426 100644 --- a/src/repositories/qfRoundRepository.ts +++ b/src/repositories/qfRoundRepository.ts @@ -1,5 +1,7 @@ +import { Field, Float, Int, ObjectType, registerEnumType } from 'type-graphql'; import { QfRound } from '../entities/qfRound'; import { AppDataSource } from '../orm'; +import { QfArchivedRoundsOrderBy } from '../resolvers/qfRoundResolver'; const qfRoundEstimatedMatchingParamsCacheDuration = Number( process.env.QF_ROUND_ESTIMATED_MATCHING_CACHE_DURATION || 60000, @@ -11,6 +13,83 @@ export const findAllQfRounds = async (): Promise => { .getMany(); }; +export enum QfArchivedRoundsSortType { + allocatedFund = 'allocatedFund', + totalDonations = 'totalDonations', + uniqueDonors = 'uniqueDonors', + beginDate = 'beginDate', +} + +registerEnumType(QfArchivedRoundsSortType, { + name: 'QfArchivedRoundsSortType', + description: 'The attributes by which archived rounds can be sorted.', +}); + +@ObjectType() +export class QFArchivedRounds { + @Field(_type => String) + id: string; + + @Field(_type => String, { nullable: true }) + name: string; + + @Field(_type => String) + slug: string; + + @Field(_type => Boolean) + isActive: boolean; + + @Field(_type => Int) + allocatedFund: number; + + @Field(_type => [Int]) + eligibleNetworks: number; + + @Field(_type => Date) + beginDate: Date; + + @Field(_type => Date) + endDate: Date; + + @Field(_type => Float, { nullable: true }) + totalDonations: number; + + @Field(_type => String, { nullable: true }) + uniqueDonors: string; +} + +export const findArchivedQfRounds = async ( + limit: number, + skip: number, + orderBy: QfArchivedRoundsOrderBy, +): Promise => { + const { direction, field } = orderBy; + const fieldMap = { + [QfArchivedRoundsSortType.beginDate]: 'qfRound.beginDate', + [QfArchivedRoundsSortType.allocatedFund]: 'qfRound.allocatedFund', + [QfArchivedRoundsSortType.totalDonations]: 'SUM(donation.amount)', + [QfArchivedRoundsSortType.uniqueDonors]: + 'COUNT(DISTINCT donation.fromWalletAddress)', + }; + const fullRounds = await QfRound.createQueryBuilder('qfRound') + .where('"isActive" = false') + .leftJoin('qfRound.donations', 'donation') + .select('qfRound.id', 'id') + .addSelect('qfRound.name', 'name') + .addSelect('qfRound.slug', 'slug') + .addSelect('qfRound.isActive', 'isActive') + .addSelect('qfRound.endDate', 'endDate') + .addSelect('qfRound.eligibleNetworks', 'eligibleNetworks') + .addSelect('SUM(donation.amount)', 'totalDonations') + .addSelect('COUNT(DISTINCT donation.fromWalletAddress)', 'uniqueDonors') + .addSelect('qfRound.allocatedFund', 'allocatedFund') + .addSelect('qfRound.beginDate', 'beginDate') + .groupBy('qfRound.id') + .orderBy(fieldMap[field], direction, 'NULLS LAST') + .getRawMany(); + return fullRounds.slice(skip, skip + limit); +}; + export const findActiveQfRound = async (): Promise => { return QfRound.createQueryBuilder('qf_round') .where('"isActive" = true') diff --git a/src/resolvers/qfRoundHistoryResolver.ts b/src/resolvers/qfRoundHistoryResolver.ts index 9f8f3972b..cecb48aaf 100644 --- a/src/resolvers/qfRoundHistoryResolver.ts +++ b/src/resolvers/qfRoundHistoryResolver.ts @@ -1,5 +1,4 @@ import { Arg, Int, Query, Resolver } from 'type-graphql'; - import { QfRoundHistory } from '../entities/qfRoundHistory'; import { getQfRoundHistory } from '../repositories/qfRoundHistoryRepository'; diff --git a/src/resolvers/qfRoundResolver.test.ts b/src/resolvers/qfRoundResolver.test.ts index 572fd5ca9..40e6add0d 100644 --- a/src/resolvers/qfRoundResolver.test.ts +++ b/src/resolvers/qfRoundResolver.test.ts @@ -9,6 +9,7 @@ import { saveDonationDirectlyToDb, saveProjectDirectlyToDb, saveUserDirectlyToDb, + SEED_DATA, } from '../../test/testUtils'; import { Project } from '../entities/project'; import { QfRound } from '../entities/qfRound'; @@ -16,11 +17,87 @@ import { refreshProjectDonationSummaryView, refreshProjectEstimatedMatchingView, } from '../services/projectViewsService'; -import { qfRoundStatsQuery } from '../../test/graphqlQueries'; +import { + fetchQFArchivedRounds, + qfRoundStatsQuery, +} from '../../test/graphqlQueries'; import { generateRandomString } from '../utils/utils'; +import { OrderDirection } from './projectResolver'; +import { QfArchivedRoundsSortType } from '../repositories/qfRoundRepository'; describe('Fetch estimatedMatching test cases', fetchEstimatedMatchingTestCases); describe('Fetch qfRoundStats test cases', fetchQfRoundStatesTestCases); +describe('Fetch archivedQFRounds test cases', fetchArchivedQFRoundsTestCases); + +function fetchArchivedQFRoundsTestCases() { + it('should return correct data when fetching archived QF rounds', async () => { + await QfRound.update({}, { isActive: true }); + const qfRound1 = QfRound.create({ + isActive: true, + name: 'test1', + slug: generateRandomString(10), + allocatedFund: 100000, + minimumPassportScore: 8, + beginDate: new Date(), + endDate: moment().add(10, 'days').toDate(), + }); + await qfRound1.save(); + + await saveDonationDirectlyToDb( + { + ...createDonationData(), + valueUsd: 150, + qfRoundId: qfRound1.id, + status: 'verified', + }, + SEED_DATA.FIRST_USER.id, + SEED_DATA.FIRST_PROJECT.id, + ); + await saveDonationDirectlyToDb( + { + ...createDonationData(), + valueUsd: 250, + qfRoundId: qfRound1.id, + status: 'verified', + }, + SEED_DATA.FIRST_USER.id, + SEED_DATA.FIRST_PROJECT.id, + ); + + const qfRound2 = QfRound.create({ + isActive: false, + name: 'test2', + slug: generateRandomString(10), + allocatedFund: 200000, + minimumPassportScore: 8, + beginDate: moment().add(-10, 'days').toDate(), + endDate: moment().add(10, 'days').toDate(), + }); + await qfRound2.save(); + const qfRound3 = QfRound.create({ + isActive: false, + name: 'test3', + slug: generateRandomString(10), + allocatedFund: 300000, + minimumPassportScore: 8, + beginDate: new Date(), + endDate: moment().add(10, 'days').toDate(), + }); + await qfRound3.save(); + const result = await axios.post(graphqlUrl, { + query: fetchQFArchivedRounds, + variables: { + orderBy: { + direction: OrderDirection.DESC, + field: QfArchivedRoundsSortType.beginDate, + }, + }, + }); + const res = result.data.data.qfArchivedRounds; + assert.equal(res[0].id, qfRound3.id); + assert.equal(res.length, 2); + }); +} function fetchQfRoundStatesTestCases() { let qfRound: QfRound; diff --git a/src/resolvers/qfRoundResolver.ts b/src/resolvers/qfRoundResolver.ts index 322ca6ab5..14ad60ae5 100644 --- a/src/resolvers/qfRoundResolver.ts +++ b/src/resolvers/qfRoundResolver.ts @@ -1,14 +1,29 @@ -import { Arg, Field, ObjectType, Query, Resolver } from 'type-graphql'; - +import { + Arg, + Args, + ArgsType, + Field, + InputType, + Int, + ObjectType, + Query, + Resolver, +} from 'type-graphql'; +import { Service } from 'typedi'; +import { Max, Min } from 'class-validator'; import { User } from '../entities/user'; import { findActiveQfRound, findAllQfRounds, + findArchivedQfRounds, findQfRoundBySlug, getProjectDonationsSqrtRootSum, getQfRoundTotalProjectsDonationsSum, + QFArchivedRounds, + QfArchivedRoundsSortType, } from '../repositories/qfRoundRepository'; import { QfRound } from '../entities/qfRound'; +import { OrderDirection } from './projectResolver'; @ObjectType() export class QfRoundStatsResponse { @@ -34,6 +49,36 @@ export class ExpectedMatchingResponse { matchingPool: number; } +@InputType() +export class QfArchivedRoundsOrderBy { + @Field(_type => QfArchivedRoundsSortType) + field: QfArchivedRoundsSortType; + + @Field(_type => OrderDirection) + direction: OrderDirection; +} + +@Service() +@ArgsType() +class QfArchivedRoundsArgs { + @Field(_type => Int, { defaultValue: 0 }) + @Min(0) + skip: number; + + @Field(_type => Int, { defaultValue: 10 }) + @Min(0) + @Max(50) + limit: number; + + @Field(_type => QfArchivedRoundsOrderBy, { + defaultValue: { + field: QfArchivedRoundsSortType.beginDate, + direction: OrderDirection.DESC, + }, + }) + orderBy: QfArchivedRoundsOrderBy; +} + @Resolver(_of => User) export class QfRoundResolver { @Query(_returns => [QfRound], { nullable: true }) @@ -41,8 +86,16 @@ export class QfRoundResolver { return findAllQfRounds(); } + @Query(_returns => [QFArchivedRounds], { nullable: true }) + async qfArchivedRounds( + @Args() + { limit, skip, orderBy }: QfArchivedRoundsArgs, + ): Promise { + return findArchivedQfRounds(limit, skip, orderBy); + } + // This will be the formula data separated by parts so frontend - // can calculate the estimated matchin added per new donation + // can calculate the estimated matching added per new donation @Query(() => ExpectedMatchingResponse, { nullable: true }) async expectedMatching( @Arg('projectId') projectId: number, diff --git a/src/resolvers/userResolver.ts b/src/resolvers/userResolver.ts index 4a56ae2a6..32754a08b 100644 --- a/src/resolvers/userResolver.ts +++ b/src/resolvers/userResolver.ts @@ -125,6 +125,7 @@ export class UserResolver { @Arg('email', { nullable: true }) email: string, @Arg('url', { nullable: true }) url: string, @Arg('avatar', { nullable: true }) avatar: string, + @Arg('newUser', { nullable: true }) newUser: boolean, @Ctx() { req: { user } }: ApolloContext, ): Promise { if (!user) @@ -185,6 +186,9 @@ export class UserResolver { userId: dbUser.id.toString(), }); await getNotificationAdapter().updateOrttoPeople([orttoPerson]); + if (newUser) { + await getNotificationAdapter().createOrttoProfile(dbUser); + } return true; } diff --git a/src/server/adminJs/adminJs.ts b/src/server/adminJs/adminJs.ts index f5fff6708..a9dd27c1c 100644 --- a/src/server/adminJs/adminJs.ts +++ b/src/server/adminJs/adminJs.ts @@ -233,6 +233,7 @@ const getadminJsInstance = async () => { }, rootPath: adminJsRootPath, }); + // adminJsInstance.watch(); return adminJsInstance; }; diff --git a/src/server/adminJs/tabs/components/QFRoundBannerBg.tsx b/src/server/adminJs/tabs/components/QFRoundBannerBg.tsx new file mode 100644 index 000000000..1bf6988c3 --- /dev/null +++ b/src/server/adminJs/tabs/components/QFRoundBannerBg.tsx @@ -0,0 +1,128 @@ +import React, { useState, useEffect } from 'react'; +import styled from 'styled-components'; + +const ImageUploadCard = props => { + const { onChange } = props; + + const [imagePreview, setImagePreview] = useState( + null, + ); + + useEffect(() => { + if (props.record) { + const bannerBgImage = props.record.params.bannerBgImage; + if (bannerBgImage) { + setImagePreview(bannerBgImage); + } + } + }, []); + + const handleImageChange = e => { + const file = e.target.files[0]; + if (file) { + const reader = new FileReader(); + reader.onloadend = () => { + setImagePreview(reader.result); + }; + reader.readAsDataURL(file); + onChange('bannerBgImage', file); + } + }; + + const handleCardClick = () => { + const fileInput = document.getElementById('fileInput'); + fileInput?.click(); + }; + + const handleDragOver = (e: Event) => { + e.preventDefault(); + e.stopPropagation(); + }; + + const handleDrop = e => { + e.preventDefault(); + e.stopPropagation(); + const file = e.dataTransfer.files[0]; + + if (file) { + const reader = new FileReader(); + reader.onloadend = () => { + setImagePreview(reader.result); + }; + reader.readAsDataURL(file); + + onChange('bannerBgImage', file); + } + }; + + return ( + <> + + + {imagePreview ? ( + <> + + + ) : ( + + Click here to upload or drag and drop an image + + )} + + + + ); +}; + +export default ImageUploadCard; + +const CardContainer = styled.div` + position: relative; + display: flex; + flex-direction: column; + align-items: center; + height: 200px; + width: 600px; + border: 1px solid #ccc; + border-radius: 8px; + cursor: pointer; + margin-top: 1rem; + margin-bottom: 2rem; + justify-self: center; + margin-inline: auto; +`; + +const ImagePreview = styled.img` + width: 100%; + max-width: 100%; + max-height: 100%; + object-fit: cover; + border-radius: 8px; +`; + +const FileInput = styled.input` + display: none; +`; + +const UploadText = styled.p` + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); +`; + +const Label = styled.label` + font-family: 'Roboto', sans-serif; + font-size: 12px; + line-height: 16px; + margin-bottom: 8px; +`; diff --git a/src/server/adminJs/tabs/components/QFRoundSponsorsImgs.tsx b/src/server/adminJs/tabs/components/QFRoundSponsorsImgs.tsx new file mode 100644 index 000000000..b6cdf7d11 --- /dev/null +++ b/src/server/adminJs/tabs/components/QFRoundSponsorsImgs.tsx @@ -0,0 +1,271 @@ +import React, { useState, useEffect } from 'react'; +import styled from 'styled-components'; + +type File = { + name: string; + size: number; + type: string; +}; + +const FileUploader = props => { + const { onChange, record } = props; + const [files, setFiles] = useState([]); + const [fileDragging, setFileDragging] = useState(null); + const [fileDropping, setFileDropping] = useState(null); + + useEffect(() => { + onChange('sponsorsImgs', files); + onChange('totalSponsorsImgs', files.length); + }, [files]); + + useEffect(() => { + if (record) { + for (let i = 0; i < 6; i++) { + const sponsorImg = record.params[`sponsorsImgs.${i}`]; + if (sponsorImg) { + setFiles(prevFiles => [...prevFiles, sponsorImg]); + } + } + } + }, []); + + const removeFile = index => { + const updatedFiles = [...files]; + updatedFiles.splice(index, 1); + setFiles(updatedFiles); + }; + + const handleDrop = e => { + e.preventDefault(); + const updatedFiles = [...files]; + const removed = + fileDragging !== null ? updatedFiles.splice(fileDragging, 1) : []; + updatedFiles.splice(fileDropping || 0, 0, ...removed); + while (updatedFiles.length > 6) { + updatedFiles.pop(); + } + setFiles(updatedFiles); + setFileDropping(null); + setFileDragging(null); + }; + + const handleDragStart = (e, index) => { + e.dataTransfer.setData('index', index); + setFileDragging(index); + }; + + const handleDragEnd = () => { + setFileDragging(null); + }; + + const addFiles = e => { + const newFiles: File[] = Array.from(e.target.files); + const updatedFiles = [...files, ...newFiles]; + while (updatedFiles.length > 6) { + updatedFiles.pop(); + } + setFiles(updatedFiles); + }; + + return ( + + + + { + e.preventDefault(); + }} + onDrop={handleDrop} + > + + + + + +

+ Drag and drop your files here or click to browse +

+
+
+ {files.length > 0 && ( + e.preventDefault()} + > + {files.map((file, index) => ( + handleDragStart(e, index)} + onDragEnd={handleDragEnd} + data-index={index} + > + removeFile(index)}> + + + + + + + + ))} + + )} +
+
+ ); +}; + +export default FileUploader; +const Label = styled.label` + font-family: 'Roboto', sans-serif; + font-size: 12px; + line-height: 16px; + margin-bottom: 8px; +`; + +const Container = styled.div` + background-color: white; + border-radius: 0.75rem; /* Equivalent to rounded */ + width: 100%; + margin: auto; +`; + +const FileContainer = styled.div` + position: relative; + display: flex; + margin-top: 1rem; + flex-direction: column; + padding: 1rem; /* Equivalent to p-4 */ + color: #718096; /* Equivalent to text-gray-400 */ + border: 1px solid #e2e8f0; /* Equivalent to border */ + border-radius: 0.375rem; /* Equivalent to rounded */ +`; + +const DropZone = styled.div` + position: relative; + display: flex; + flex-direction: column; + border: 1px dashed #e2e8f0; /* Equivalent to border-dashed */ + border-radius: 0.375rem; /* Equivalent to rounded */ + cursor: pointer; + &:hover { + border-color: #4a90e2; /* Equivalent to border-blue-400 */ + box-shadow: 0 0 0 0.25rem #4a90e2; /* Equivalent to ring-4 */ + } +`; + +const StyledInput = styled.input` + position: absolute; + inset: 0; + z-index: 50; + width: 100%; + height: 100%; + padding: 0; + margin: 0; + opacity: 0; + cursor: pointer; +`; + +const TextContainer = styled.div` + justify-content: center; + display: flex; + flex-direction: column; + align-items: center; + padding-block: 2rem; +`; + +const GridContainer = styled.div` + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 1rem; + margin-top: 1rem; + @media (min-width: 768px) { + grid-template-columns: repeat(6, 1fr); + } +`; + +const ImageContainer = styled.div` + position: relative; + display: flex; + flex-direction: column; + align-items: center; + overflow: hidden; + text-align: center; + background-color: #f3f4f6; + border: 1px solid #cbd5e0; + border-radius: 0.375rem; + cursor: move; + user-select: none; + padding-top: 100%; +`; + +const CloseButton = styled.button` + position: absolute; + top: 0; + right: 0; + z-index: 50; + padding: 0.25rem; + background-color: #fff; + border-radius: 0 0.375rem 0 0; + border: none; + cursor: pointer; + display: flex; + justify-content: center; + align-items: center; +`; + +const PreviewImage = styled.img` + position: absolute; + inset: 0; + z-index: 0; + object-fit: cover; + width: 100%; + height: 100%; + border: 0px solid #fff; +`; + +const DroppingOverlay = styled.div` + position: absolute; + inset: 0; + z-index: 40; + transition: background-color 0.3s; + ${({ isDropping }) => + isDropping && 'background-color: rgba(173, 216, 230, 0.5);'} +`; diff --git a/src/server/adminJs/tabs/qfRoundTab.ts b/src/server/adminJs/tabs/qfRoundTab.ts index d937a1bf5..b719a1135 100644 --- a/src/server/adminJs/tabs/qfRoundTab.ts +++ b/src/server/adminJs/tabs/qfRoundTab.ts @@ -1,3 +1,4 @@ +import fs from 'fs'; import { ActionResponse, After, @@ -27,6 +28,7 @@ import { messages } from '../../../utils/messages'; import { addQfRoundDonationsSheetToSpreadsheet } from '../../../services/googleSheets'; import { errorMessages } from '../../../utils/errorMessages'; import { relateManyProjectsToQfRound } from '../../../repositories/qfRoundRepository2'; +import { pinFile } from '../../../middleware/pinataUtils'; export const refreshMaterializedViews = async ( response, @@ -90,6 +92,29 @@ const returnAllQfRoundDonationAnalysis = async ( }; }; +const availableNetworkValues = [ + { value: NETWORK_IDS.MAIN_NET, label: 'MAINNET' }, + { value: NETWORK_IDS.ROPSTEN, label: 'ROPSTEN' }, + { value: NETWORK_IDS.GOERLI, label: 'GOERLI' }, + { value: NETWORK_IDS.POLYGON, label: 'POLYGON' }, + { value: NETWORK_IDS.OPTIMISTIC, label: 'OPTIMISTIC' }, + { value: NETWORK_IDS.ETC, label: 'ETC' }, + { + value: NETWORK_IDS.MORDOR_ETC_TESTNET, + label: 'MORDOR ETC TESTNET', + }, + { value: NETWORK_IDS.OPTIMISM_SEPOLIA, label: 'OPTIMISM SEPOLIA' }, + { value: NETWORK_IDS.CELO, label: 'CELO' }, + { + value: NETWORK_IDS.CELO_ALFAJORES, + label: 'ALFAJORES (Test CELO)', + }, + { value: NETWORK_IDS.ARBITRUM_MAINNET, label: 'ARBITRUM MAINNET' }, + { value: NETWORK_IDS.ARBITRUM_SEPOLIA, label: 'ARBITRUM SEPOLIA' }, + { value: NETWORK_IDS.XDAI, label: 'XDAI' }, + { value: NETWORK_IDS.BSC, label: 'BSC' }, +]; + export const qfRoundTab = { resource: QfRound, options: { @@ -132,6 +157,14 @@ export const qfRoundTab = { allocatedFund: { isVisible: true, }, + allocatedTokenSymbol: { + isVisible: true, + }, + allocatedTokenChainId: { + isVisible: true, + type: 'number', + availableValues: availableNetworkValues, + }, minimumPassportScore: { isVisible: true, }, @@ -141,28 +174,7 @@ export const qfRoundTab = { eligibleNetworks: { isVisible: true, type: 'array', - availableValues: [ - { value: NETWORK_IDS.MAIN_NET, label: 'MAINNET' }, - { value: NETWORK_IDS.ROPSTEN, label: 'ROPSTEN' }, - { value: NETWORK_IDS.GOERLI, label: 'GOERLI' }, - { value: NETWORK_IDS.POLYGON, label: 'POLYGON' }, - { value: NETWORK_IDS.OPTIMISTIC, label: 'OPTIMISTIC' }, - { value: NETWORK_IDS.ETC, label: 'ETC' }, - { - value: NETWORK_IDS.MORDOR_ETC_TESTNET, - label: 'MORDOR ETC TESTNET', - }, - { value: NETWORK_IDS.OPTIMISM_SEPOLIA, label: 'OPTIMISM SEPOLIA' }, - { value: NETWORK_IDS.CELO, label: 'CELO' }, - { - value: NETWORK_IDS.CELO_ALFAJORES, - label: 'ALFAJORES (Test CELO)', - }, - { value: NETWORK_IDS.ARBITRUM_MAINNET, label: 'ARBITRUM MAINNET' }, - { value: NETWORK_IDS.ARBITRUM_SEPOLIA, label: 'ARBITRUM SEPOLIA' }, - { value: NETWORK_IDS.XDAI, label: 'XDAI' }, - { value: NETWORK_IDS.BSC, label: 'BSC' }, - ], + availableValues: availableNetworkValues, }, projects: { type: 'mixed', @@ -176,6 +188,30 @@ export const qfRoundTab = { show: adminJs.bundle('./components/ProjectsInQfRound'), }, }, + bannerBgImage: { + isVisible: { + filter: false, + list: false, + show: false, + new: false, + edit: true, + }, + components: { + edit: adminJs.bundle('./components/QFRoundBannerBg'), + }, + }, + sponsorsImgs: { + isVisible: { + filter: false, + list: false, + show: false, + new: false, + edit: true, + }, + components: { + edit: adminJs.bundle('./components/QFRoundSponsorsImgs'), + }, + }, createdAt: { type: 'string', isVisible: { @@ -223,6 +259,36 @@ export const qfRoundTab = { _response, _context: AdminJsContextInterface, ) => { + if (request.payload.totalSponsorsImgs) { + const sponsorsImgs: string[] = []; + for (let i = 0; i < request.payload.totalSponsorsImgs; i++) { + const sponsorImg = request.payload[`sponsorsImgs.${i}`]; + + if (!sponsorImg || !sponsorImg.path) + sponsorsImgs.push(sponsorImg); + else { + const { path, name } = sponsorImg; + const result = await pinFile(fs.createReadStream(path), name); + sponsorsImgs.push( + `${process.env.PINATA_GATEWAY_ADDRESS}/ipfs/${result.IpfsHash}`, + ); + delete request.payload[`sponsorsImgs.${i}`]; + } + } + request.payload.sponsorsImgs = sponsorsImgs; + delete request.payload.totalSponsorsImgs; + } + + if ( + request.payload.bannerBgImage && + request.payload.bannerBgImage.path + ) { + const { path, name } = request.payload.bannerBgImage; + const result = await pinFile(fs.createReadStream(path), name); + + request.payload.bannerBgImage = `${process.env.PINATA_GATEWAY_ADDRESS}/ipfs/${result.IpfsHash}`; + } + // https://docs.adminjs.co/basics/action#using-before-and-after-hooks if (request?.payload?.id) { const qfRoundId = Number(request.payload.id); diff --git a/src/services/projectViewsService.ts b/src/services/projectViewsService.ts index f932837c3..5711c2fb1 100644 --- a/src/services/projectViewsService.ts +++ b/src/services/projectViewsService.ts @@ -54,7 +54,6 @@ export const getQfRoundActualDonationDetails = async ( const totalWeight = rows.reduce((accumulator, currentRow) => { return accumulator + currentRow.donationsSqrtRootSumSquared; }, 0); - const weightCap = totalWeight * maxRewardShare; const fundingCap = totalReward * maxRewardShare; let remainingWeight = totalWeight; let remainingFunds = totalReward; @@ -68,7 +67,6 @@ export const getQfRoundActualDonationDetails = async ( remainingWeight -= row.donationsSqrtRootSumSquared; remainingFunds -= fundingCap; row.actualMatching = fundingCap; - row.donationsSqrtRootSumSquared = weightCap; result.push(row); } } diff --git a/test/graphqlQueries.ts b/test/graphqlQueries.ts index fe3dac5c0..3e9146d6a 100644 --- a/test/graphqlQueries.ts +++ b/test/graphqlQueries.ts @@ -1318,6 +1318,7 @@ export const updateUser = ` $lastName: String $firstName: String $avatar: String + $newUser: Boolean ) { updateUser( url: $url @@ -1326,6 +1327,7 @@ export const updateUser = ` firstName: $firstName lastName: $lastName avatar: $avatar + newUser: $newUser ) } `; @@ -2282,6 +2284,31 @@ export const doesDonatedToProjectInQfRoundQuery = ` } `; +export const fetchQFArchivedRounds = ` + query ( + $limit: Int + $skip: Int + $orderBy: QfArchivedRoundsOrderBy + ) { + qfArchivedRounds( + limit: $limit + skip: $skip + orderBy: $orderBy + ) { + id + name + slug + isActive + allocatedFund + eligibleNetworks + beginDate + endDate + totalDonations + uniqueDonors + } + } +`; + export const createAnchorContractAddressQuery = ` mutation ($projectId: Int!, $networkId: Int!,