diff --git a/.vscode/settings.json b/.vscode/settings.json index eac2aee64..901bd2e2e 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -48,6 +48,7 @@ "=createproject", "amichaeltest", "Aptos", + "astraplusplus", "Attributify", "bitget", "builddao", @@ -82,6 +83,7 @@ "localnet", "METAPOOL", "Metaverse", + "Mintbase", "Mochi", "mpdao", "mpdaovoting", @@ -90,6 +92,7 @@ "NADABOT", "narwallets", "naxios", + "ndctools", "nearblocks", "nearfi", "openapi", @@ -111,6 +114,7 @@ "socialdb", "Solana", "SOURCECODE", + "sputnikdao", "stnear", "svgr", "TGAS", @@ -124,11 +128,20 @@ "usehooks", "viem", "wagmi", + "weigthed", "welldone", "wpdas", "xdefi", "yearofchef", "yocto", "zustand" - ] + ], + "files.watcherExclude": { + "**/.git/objects/**": true, + "**/.git/subtree-cache/**": true, + "**/.hg/store/**": true, + "**/.git/**": true, + "**/node_modules/**": true, + "**/.vscode/**": true + } } \ No newline at end of file diff --git a/_tests/homepage.tests.tsx b/_tests/homepage.tests.tsx index 6b23309b5..107e0a628 100644 --- a/_tests/homepage.tests.tsx +++ b/_tests/homepage.tests.tsx @@ -3,13 +3,64 @@ import * as React from "react"; import { screen, waitFor } from "@testing-library/react"; import { expect, test, vi } from "vitest"; -import { NextNavigationMock, renderWithStore } from "./test-env"; +import { renderWithStore } from "./test-env"; import Homepage from "../src/pages"; -vi.mock("next/navigation", () => NextNavigationMock); -renderWithStore(); +// JSDOM does not implement browser APIs used by embla-carousel +Object.defineProperty(window, "matchMedia", { + writable: true, + value: vi.fn().mockImplementation((query: string) => ({ + matches: false, + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + })), +}); + +global.IntersectionObserver = vi.fn().mockImplementation(() => ({ + observe: vi.fn(), + unobserve: vi.fn(), + disconnect: vi.fn(), + root: null, + rootMargin: "", + thresholds: [], + takeRecords: vi.fn().mockReturnValue([]), +})); + +global.ResizeObserver = vi.fn().mockImplementation(() => ({ + observe: vi.fn(), + unobserve: vi.fn(), + disconnect: vi.fn(), +})); + +vi.mock("next/navigation", async () => { + const mockRouter = await import("next-router-mock"); + + return { + ...mockRouter, + notFound: vi.fn(), + redirect: vi.fn().mockImplementation((url: string) => { + mockRouter.memoryRouter.setCurrentUrl(url); + }), + }; +}); + +vi.mock("next/router", async () => { + const mockRouter = await import("next-router-mock"); + + return { + ...mockRouter, + useRouter: mockRouter.useRouter, + }; +}); test("Homepage", async () => { + renderWithStore(); + await waitFor( () => expect( diff --git a/next.config.js b/next.config.js index 940e2629b..1e64b1984 100644 --- a/next.config.js +++ b/next.config.js @@ -1,7 +1,9 @@ /** @type {import('next').NextConfig} */ const nextConfig = { reactStrictMode: true, - + experimental: { + esmExternals: "loose", + }, async redirects() { return [ { @@ -9,6 +11,17 @@ const nextConfig = { destination: "/404", permanent: false, }, + // Redirect old campaign subroutes to new tab-based routes + { + source: "/campaign/:campaignId/leaderboard", + destination: "/campaign/:campaignId?tab=leaderboard", + permanent: true, + }, + { + source: "/campaign/:campaignId/settings", + destination: "/campaign/:campaignId?tab=settings", + permanent: true, + }, ]; }, diff --git a/package.json b/package.json index f08a3bace..0e29207ae 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { - "name": "@potlock/next", + "name": "@potlock/frontend", "description": "Decentralized funding stack for public goods", - "version": "2.1.0", + "version": "2.2.1", "packageManager": "yarn@1.22.22", "private": true, "type": "module", @@ -27,28 +27,8 @@ "@ebay/nice-modal-react": "^1.2.13", "@formkit/tempo": "^0.1.2", "@hookform/resolvers": "^3.9.1", - "@near-wallet-selector/bitget-wallet": "^8.10.2", - "@near-wallet-selector/bitte-wallet": "^8.10.2", - "@near-wallet-selector/coin98-wallet": "^8.10.2", - "@near-wallet-selector/core": "^8.10.2", - "@near-wallet-selector/ethereum-wallets": "^8.10.2", - "@near-wallet-selector/here-wallet": "^8.10.2", - "@near-wallet-selector/ledger": "^8.10.2", - "@near-wallet-selector/math-wallet": "^8.10.2", - "@near-wallet-selector/meteor-wallet": "^8.10.2", - "@near-wallet-selector/mintbase-wallet": "^8.10.2", - "@near-wallet-selector/modal-ui": "^8.10.2", - "@near-wallet-selector/my-near-wallet": "^8.10.2", - "@near-wallet-selector/narwallets": "^8.10.2", - "@near-wallet-selector/near-mobile-wallet": "^8.10.2", - "@near-wallet-selector/near-snap": "^8.10.2", - "@near-wallet-selector/nearfi": "^8.10.2", - "@near-wallet-selector/neth": "^8.10.2", - "@near-wallet-selector/nightly": "^8.10.2", - "@near-wallet-selector/ramper-wallet": "^8.10.2", - "@near-wallet-selector/sender": "^8.10.2", - "@near-wallet-selector/welldone-wallet": "^8.10.2", - "@near-wallet-selector/xdefi": "^8.10.2", + "@hot-labs/near-connect": "^0.8.2", + "@near-js/transactions": "^2.5.1", "@radix-ui/react-accordion": "^1.1.2", "@radix-ui/react-avatar": "^1.0.4", "@radix-ui/react-checkbox": "^1.0.4", @@ -75,24 +55,31 @@ "@rematch/persist": "^2.1.2", "@tanstack/react-table": "^8.20.5", "@tanstack/react-virtual": "^3.11.0", + "@tiptap/core": "^2.23.0", + "@tiptap/extension-link": "^2.23.0", + "@tiptap/react": "^2.23.0", + "@tiptap/starter-kit": "^2.23.0", "@uidotdev/usehooks": "^2.4.1", "@unocss/reset": "^0.60.4", "@web3modal/wagmi": "^5.1.10", - "@wpdas/naxios": "2.2.3", + "@wpdas/naxios": "^2.5.0", "axios": "^1.7.2", "big.js": "^6.2.1", + "browserslist": "^4.24.2", "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", "cmdk": "^1.0.0", "date-fns": "^3.6.0", "embla-carousel-react": "^8.3.0", "immer": "^9.0.21", + "lightningcss": "^1.30.1", "lucide-react": "^0.378.0", "markdown-truncate": "^1.1.1", - "near-api-js": "^2.1.4", + "near-api-js": "6.5.1", "next": "^14.2.3", "node-fetch": "^3.3.2", "pinata-web3": "^0.5.4", + "qrcode.react": "^4.2.0", "query-string": "^9.1.0", "react": "^18.3.1", "react-copy-to-clipboard": "^5.1.0", @@ -115,11 +102,15 @@ "tailwind-merge": "^2.3.0", "tailwindcss-animate": "^1.0.7", "temporal-polyfill": "^0.2.5", + "usehooks-ts": "^3.1.1", "viem": "^2.21.27", "wagmi": "^2.12.16", "zod": "^3.23.8", "zustand": "^5.0.1" }, + "resolutions": { + "@noble/curves": "^1.8.0" + }, "devDependencies": { "@stylistic/eslint-plugin": "^2.11.0", "@svgr/webpack": "^8.1.0", diff --git a/public/assets/icons/info-icon.svg b/public/assets/icons/info-icon.svg deleted file mode 100644 index e0f665f72..000000000 --- a/public/assets/icons/info-icon.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - \ No newline at end of file diff --git a/public/brand-logo.png b/public/brand-logo.png new file mode 100644 index 000000000..ca6e64334 Binary files /dev/null and b/public/brand-logo.png differ diff --git a/src/common/_config/index.ts b/src/common/_config/index.ts index e6b36cd30..e3cd843a3 100644 --- a/src/common/_config/index.ts +++ b/src/common/_config/index.ts @@ -12,6 +12,10 @@ export const { contractMetadata: { version: CONTRACT_SOURCECODE_VERSION, repoUrl: CONTRACT_SOURCECODE_REPO_URL }, core: { + namespaceRoot: { + contract: { accountId: NAMESPACE_ROOT_CONTRACT_ACCOUNT_ID }, + }, + campaigns: { contract: { accountId: CAMPAIGNS_CONTRACT_ACCOUNT_ID }, }, @@ -39,6 +43,7 @@ export const { }, social: { + platformName: SOCIAL_PLATFORM_NAME, app: { url: SOCIAL_APP_LINK_URL }, contract: { accountId: SOCIAL_DB_CONTRACT_ACCOUNT_ID }, }, diff --git a/src/common/_config/production.env-config.ts b/src/common/_config/production.env-config.ts index c6207f0ee..51e3e73a8 100644 --- a/src/common/_config/production.env-config.ts +++ b/src/common/_config/production.env-config.ts @@ -14,8 +14,12 @@ export const envConfig: EnvConfig = { }, core: { + namespaceRoot: { + contract: { accountId: "potlock.near" }, + }, + campaigns: { - contract: { accountId: "campaigns.potlock.near" }, + contract: { accountId: "v1.campaigns.staging.potlock.near" }, }, donation: { @@ -43,6 +47,7 @@ export const envConfig: EnvConfig = { }, social: { + platformName: "NEAR Social", app: { url: "https://near.social" }, contract: { accountId: "social.near" }, }, diff --git a/src/common/_config/staging.env-config.ts b/src/common/_config/staging.env-config.ts index c3ec08781..eb8b814b9 100644 --- a/src/common/_config/staging.env-config.ts +++ b/src/common/_config/staging.env-config.ts @@ -14,6 +14,10 @@ export const envConfig: EnvConfig = { }, core: { + namespaceRoot: { + contract: { accountId: "potlock.near" }, + }, + donation: { contract: { accountId: "donate.potlock.near" }, }, @@ -43,6 +47,7 @@ export const envConfig: EnvConfig = { }, social: { + platformName: "NEAR Social", app: { url: "https://near.social" }, contract: { accountId: "social.near" }, }, diff --git a/src/common/_config/test.env-config.ts b/src/common/_config/test.env-config.ts index ac8cdf305..fd3d8156f 100644 --- a/src/common/_config/test.env-config.ts +++ b/src/common/_config/test.env-config.ts @@ -14,6 +14,10 @@ export const envConfig: EnvConfig = { }, core: { + namespaceRoot: { + contract: { accountId: "potlock.testnet" }, + }, + donation: { contract: { accountId: "donate.potlock.testnet" }, }, @@ -41,6 +45,7 @@ export const envConfig: EnvConfig = { }, social: { + platformName: "NEAR Social (testnet)", app: { url: "https://test.near.social" }, contract: { accountId: "v1.social08.testnet" }, }, diff --git a/src/common/api/indexer/hooks.ts b/src/common/api/indexer/hooks.ts index fc5ebe802..aa89abd4b 100644 --- a/src/common/api/indexer/hooks.ts +++ b/src/common/api/indexer/hooks.ts @@ -2,12 +2,20 @@ import type { AxiosResponse } from "axios"; import { NOOP_STRING } from "@/common/constants"; import { isAccountId, isEthereumAddress } from "@/common/lib"; -import { ByAccountId, ByListId, type ConditionalActivation } from "@/common/types"; +import { + ByAccountId, + ByListId, + type ConditionalActivation, + type LiveUpdateParams, +} from "@/common/types"; import * as generatedClient from "./internal/client.generated"; -import { INDEXER_CLIENT_CONFIG } from "./internal/config"; +import { INDEXER_CLIENT_CONFIG, INDEXER_CLIENT_CONFIG_STAGING } from "./internal/config"; import { ByPotId } from "./types"; +const currentNetworkConfig = + process.env.NEXT_PUBLIC_ENV === "test" ? INDEXER_CLIENT_CONFIG : INDEXER_CLIENT_CONFIG_STAGING; + /** * https://test-dev.potlock.io/api/schema/swagger-ui/#/v1/v1_stats_retrieve */ @@ -86,18 +94,27 @@ export const useAccountActivePots = ({ */ export const useAccountListRegistrations = ({ enabled = true, + live = false, accountId, ...params }: ByAccountId & generatedClient.V1AccountsListRegistrationsRetrieveParams & - ConditionalActivation) => { + ConditionalActivation & + LiveUpdateParams) => { const queryResult = generatedClient.useV1AccountsListRegistrationsRetrieve(accountId, params, { ...INDEXER_CLIENT_CONFIG, - swr: { - enabled, - shouldRetryOnError: (err) => err.status !== 404, - }, + swr: live + ? { + enabled, + } + : { + enabled, + shouldRetryOnError: (err) => err.status !== 404, + revalidateIfStale: false, + revalidateOnFocus: false, + revalidateOnReconnect: false, + }, }); return { ...queryResult, data: queryResult.data?.data }; @@ -330,3 +347,34 @@ export const useMpdaoVoter = ({ return { ...queryResult, data: queryResult.data?.data }; }; + +/** + * https://test-dev.potlock.io/api/schema/swagger-ui/#/v1/v1_campaigns_retrieve + */ + +export const useCampaigns = ({ + enabled = true, + ...params +}: generatedClient.V1CampaignsRetrieveParams & ConditionalActivation = {}) => { + const queryResult = generatedClient.useV1CampaignsRetrieve(params, { + ...currentNetworkConfig, + swr: { enabled }, + }); + + return { ...queryResult, data: queryResult.data?.data }; +}; + +export const useCampaign = ({ campaignId }: { campaignId: number }) => { + const queryResult = generatedClient.useV1CampaignsRetrieve2(campaignId, { + ...currentNetworkConfig, + swr: { + enabled: true, + refreshInterval: 3000, + // Retry on error (handles race condition when campaign is just created but not yet indexed) + errorRetryCount: 10, + errorRetryInterval: 2000, + }, + }); + + return { ...queryResult, data: queryResult.data?.data }; +}; diff --git a/src/common/api/indexer/index.ts b/src/common/api/indexer/index.ts index 8b58add30..a1190c99e 100644 --- a/src/common/api/indexer/index.ts +++ b/src/common/api/indexer/index.ts @@ -1,3 +1,4 @@ export * as indexerClient from "./internal/client.generated"; export * as indexer from "./hooks"; export * from "./types"; +export { syncApi } from "./sync"; diff --git a/src/common/api/indexer/internal/client.generated.ts b/src/common/api/indexer/internal/client.generated.ts index af13237b0..4a7a23692 100644 --- a/src/common/api/indexer/internal/client.generated.ts +++ b/src/common/api/indexer/internal/client.generated.ts @@ -236,6 +236,86 @@ export type V1DonateContractConfigRetrieveParams = { page_size?: number; }; +export type V1CampaignsDonationsRetrieveParams = { + /** + * Filter donations by donor account ID + */ + donor?: string; + /** + * Exclude refunded donations (true/false) + */ + exclude_refunded?: boolean; + /** + * Page number for pagination + */ + page?: number; + /** + * Number of results per page + */ + page_size?: number; +}; + +export type V1CampaignsRetrieveStatus = + (typeof V1CampaignsRetrieveStatus)[keyof typeof V1CampaignsRetrieveStatus]; + +// eslint-disable-next-line @typescript-eslint/no-redeclare +export const V1CampaignsRetrieveStatus = { + active: "active", + ended: "ended", + unfufilled: "unfufilled", + upcoming: "upcoming", +} as const; + +export type V1CampaignsRetrieveParams = { + /** + * Filter campaigns by owner account ID + */ + owner?: string; + /** + * Page number for pagination + */ + page?: number; + /** + * Number of results per page + */ + page_size?: number; + /** + * Filter campaigns by recipient account ID + */ + recipient?: string; + /** + * Filter by active campaigns (true/false) + */ + status?: V1CampaignsRetrieveStatus; + /** + * Filter campaigns by token account ID + */ + token?: string; +}; + +export type V1CampaignDonationsRetrieveParams = { + /** + * Filter donations by campaign ID + */ + campaign_id?: number; + /** + * Filter donations by donor account ID + */ + donor?: string; + /** + * Exclude refunded donations (true/false) + */ + exclude_refunded?: boolean; + /** + * Page number for pagination + */ + page?: number; + /** + * Number of results per page + */ + page_size?: number; +}; + export type V1AccountsUpvotedListsRetrieveParams = { /** * Page number for pagination @@ -555,6 +635,8 @@ export interface Round { * @nullable */ max_participants?: number | null; + /** Minimum deposit. */ + minimum_deposit: string; /** Round name. */ name: string; /** @@ -1035,13 +1117,13 @@ export interface PaginatedMpdaoUsers { results: MpdaoVoterItem[]; } -export interface PaginatedListsResponse { +export interface PaginatedListRegistrationsResponse { count: number; /** @nullable */ next: string | null; /** @nullable */ previous: string | null; - results: List[]; + results: ListRegistration[]; } export interface PaginatedDonationsResponse { @@ -1053,32 +1135,31 @@ export interface PaginatedDonationsResponse { results: Donation[]; } -export interface PaginatedAccountsResponse { +export interface PaginatedCampaignsResponse { count: number; /** @nullable */ next: string | null; /** @nullable */ previous: string | null; - results: Account[]; + results: Campaign[]; } -export interface NearSocialProfileData { - backgroundImage?: Image; - description?: string; - image?: Image; - linktree?: Linktree; - name?: string; - /** JSON-stringified array of category strings */ - plCategories?: string; - /** JSON-stringified array of funding source objects */ - plFundingSources?: string; - /** JSON-stringified array of URLs */ - plGithubRepos?: string; - plPublicGoodReason?: string; - /** JSON-stringified object with chain names as keys that map to nested objects of contract addresses */ - plSmartContracts?: string; - /** JSON-stringified array of team member account ID strings */ - plTeam?: string; +export interface PaginatedCampaignDonationsResponse { + count: number; + /** @nullable */ + next: string | null; + /** @nullable */ + previous: string | null; + results: CampaignDonation[]; +} + +export interface PaginatedAccountsResponse { + count: number; + /** @nullable */ + next: string | null; + /** @nullable */ + previous: string | null; + results: Account[]; } export interface Nft { @@ -1093,13 +1174,6 @@ export interface Nft { */ export type MpdaoVoterItemAccountData = Account | null; -export interface MpdaoVoterItem { - /** @nullable */ - account_data: MpdaoVoterItemAccountData; - voter_data: MpdaoSnapshot; - voter_id: string; -} - export interface LockingPosition { amount: string; index: number; @@ -1127,6 +1201,13 @@ export interface MpdaoSnapshot { voting_power: string | null; } +export interface MpdaoVoterItem { + /** @nullable */ + account_data: MpdaoVoterItemAccountData; + voter_data: MpdaoSnapshot; + voter_id: string; +} + export interface ListUpvote { /** Account that upvoted the list. */ account: string; @@ -1177,15 +1258,6 @@ export interface ListRegistration { updated_at: string; } -export interface PaginatedListRegistrationsResponse { - count: number; - /** @nullable */ - next: string | null; - /** @nullable */ - previous: string | null; - results: ListRegistration[]; -} - export interface List { /** Admin only registrations. */ admin_only_registrations: boolean; @@ -1232,6 +1304,15 @@ export interface List { upvotes: ListUpvote[]; } +export interface PaginatedListsResponse { + count: number; + /** @nullable */ + next: string | null; + /** @nullable */ + previous: string | null; + results: List[]; +} + export interface Linktree { github?: string; telegram?: string; @@ -1245,6 +1326,25 @@ export interface Image { url?: string; } +export interface NearSocialProfileData { + backgroundImage?: Image; + description?: string; + image?: Image; + linktree?: Linktree; + name?: string; + /** JSON-stringified array of category strings */ + plCategories?: string; + /** JSON-stringified array of funding source objects */ + plFundingSources?: string; + /** JSON-stringified array of URLs */ + plGithubRepos?: string; + plPublicGoodReason?: string; + /** JSON-stringified object with chain names as keys that map to nested objects of contract addresses */ + plSmartContracts?: string; + /** JSON-stringified array of team member account ID strings */ + plTeam?: string; +} + export interface DonationContractConfig { owner: string; protocol_fee_basis_points: number; @@ -1361,6 +1461,222 @@ export const DefaultRegistrationStatusEnum = { Blacklisted: "Blacklisted", } as const; +export interface CampaignDonation { + campaign: Campaign; + /** + * Creator fee. + * @maxLength 64 + * @nullable + */ + creator_fee?: string | null; + /** + * Creator fee in USD. + * @nullable + * @pattern ^-?\d{0,18}(?:\.\d{0,2})?$ + */ + creator_fee_usd?: string | null; + /** Donation date. */ + donated_at: string; + donor: Account; + /** Is Donation Escrowed. */ + escrowed?: boolean; + /** Donation id. */ + readonly id: number; + /** + * Donation message. + * @maxLength 1024 + * @nullable + */ + message?: string | null; + /** + * Net amount. + * @maxLength 64 + */ + net_amount: string; + /** + * Net amount in USD. + * @nullable + * @pattern ^-?\d{0,18}(?:\.\d{0,2})?$ + */ + net_amount_usd?: string | null; + /** + * Campaign donation id in contract + * @minimum -2147483648 + * @maximum 2147483647 + */ + on_chain_id: number; + /** + * Protocol fee. + * @maxLength 64 + */ + protocol_fee: string; + /** + * Protocol fee in USD. + * @nullable + * @pattern ^-?\d{0,18}(?:\.\d{0,2})?$ + */ + protocol_fee_usd?: string | null; + referrer: Account; + /** + * Referrer fee. + * @maxLength 64 + * @nullable + */ + referrer_fee?: string | null; + /** + * Referrer fee in USD. + * @nullable + * @pattern ^-?\d{0,18}(?:\.\d{0,2})?$ + */ + referrer_fee_usd?: string | null; + /** + * Donation returned date. + * @nullable + */ + returned_at?: string | null; + token: Token; + /** + * Total amount. + * @maxLength 64 + */ + total_amount: string; + /** + * Total amount in USD. + * @nullable + * @pattern ^-?\d{0,18}(?:\.\d{0,2})?$ + */ + total_amount_usd?: string | null; + /** + * Transaction hash. + * @maxLength 64 + * @nullable + */ + tx_hash?: string | null; +} + +export interface CampaignContractConfig { + default_creator_fee_basis_points: number; + default_referral_fee_basis_points: number; + owner: string; + protocol_fee_basis_points: number; + protocol_fee_recipient_account: string; +} + +export interface Campaign { + /** Allow fee avoidance. */ + allow_fee_avoidance?: boolean; + /** + * Campaign cover image URL. + * @nullable + */ + cover_image_url?: string | null; + /** Campaign creation date. */ + created_at: string; + /** + * Creator fee basis points. + * @minimum 0 + * @maximum 2147483647 + */ + creator_fee_basis_points: number; + /** + * Campaign description. + * @nullable + */ + description?: string | null; + /** + * Campaign end date. + * @nullable + */ + end_at?: string | null; + /** + * Campaign escrow balance. + * @maxLength 64 + */ + escrow_balance?: string; + /** + * Escrow balance in USD. + * @nullable + * @pattern ^-?\d{0,18}(?:\.\d{0,2})?$ + */ + escrow_balance_usd?: string | null; + /** + * Campaign maximum amount. + * @maxLength 64 + * @nullable + */ + max_amount?: string | null; + /** + * Maximum amount in USD. + * @nullable + * @pattern ^-?\d{0,18}(?:\.\d{0,2})?$ + */ + max_amount_usd?: string | null; + /** + * Campaign minimum amount. + * @maxLength 64 + * @nullable + */ + min_amount?: string | null; + /** + * Minimum amount in USD. + * @nullable + * @pattern ^-?\d{0,18}(?:\.\d{0,2})?$ + */ + min_amount_usd?: string | null; + /** Campaign name. */ + name: string; + /** + * Campaign net raised amount. + * @maxLength 64 + */ + net_raised_amount?: string; + /** + * Net raised amount in USD. + * @nullable + * @pattern ^-?\d{0,18}(?:\.\d{0,2})?$ + */ + net_raised_amount_usd?: string | null; + /** + * @minimum -9223372036854776000 + * @maximum 9223372036854776000 + */ + on_chain_id: number; + owner: Account; + recipient: Account; + /** + * Referral fee basis points. + * @minimum 0 + * @maximum 2147483647 + */ + referral_fee_basis_points: number; + /** Campaign start date. */ + start_at: string; + readonly status: string; + /** + * Campaign target amount. + * @maxLength 64 + */ + target_amount: string; + /** + * Target amount in USD. + * @nullable + * @pattern ^-?\d{0,18}(?:\.\d{0,2})?$ + */ + target_amount_usd?: string | null; + token: Token; + /** + * Campaign total raised amount. + * @maxLength 64 + */ + total_raised_amount?: string; + /** + * Total raised amount in USD. + * @nullable + * @pattern ^-?\d{0,18}(?:\.\d{0,2})?$ + */ + total_raised_amount_usd?: string | null; +} + export interface ApplicationReview { /** * Review notes. @@ -2013,6 +2329,246 @@ export const useV1AccountsUpvotedListsRetrieve = >( }; }; +/** + * Get campaign contract configuration + */ +export const v1CampaignContractConfigRetrieve = ( + options?: AxiosRequestConfig, +): Promise> => { + return axios.get(`/api/v1/campaign_contract_config`, options); +}; + +export const getV1CampaignContractConfigRetrieveKey = () => + [`/api/v1/campaign_contract_config`] as const; + +export type V1CampaignContractConfigRetrieveQueryResult = NonNullable< + Awaited> +>; + +export type V1CampaignContractConfigRetrieveQueryError = AxiosError; + +export const useV1CampaignContractConfigRetrieve = >(options?: { + swr?: SWRConfiguration>, TError> & { + swrKey?: Key; + enabled?: boolean; + }; + axios?: AxiosRequestConfig; +}) => { + const { swr: swrOptions, axios: axiosOptions } = options ?? {}; + + const isEnabled = swrOptions?.enabled !== false; + + const swrKey = + swrOptions?.swrKey ?? (() => (isEnabled ? getV1CampaignContractConfigRetrieveKey() : null)); + + const swrFn = () => v1CampaignContractConfigRetrieve(axiosOptions); + + const query = useSwr>, TError>(swrKey, swrFn, swrOptions); + + return { + swrKey, + ...query, + }; +}; + +/** + * Get paginated list of all campaign donations with optional filters + */ +export const v1CampaignDonationsRetrieve = ( + params?: V1CampaignDonationsRetrieveParams, + options?: AxiosRequestConfig, +): Promise> => { + return axios.get(`/api/v1/campaign_donations`, { + ...options, + params: { ...params, ...options?.params }, + }); +}; + +export const getV1CampaignDonationsRetrieveKey = (params?: V1CampaignDonationsRetrieveParams) => + [`/api/v1/campaign_donations`, ...(params ? [params] : [])] as const; + +export type V1CampaignDonationsRetrieveQueryResult = NonNullable< + Awaited> +>; + +export type V1CampaignDonationsRetrieveQueryError = AxiosError; + +export const useV1CampaignDonationsRetrieve = >( + params?: V1CampaignDonationsRetrieveParams, + options?: { + swr?: SWRConfiguration>, TError> & { + swrKey?: Key; + enabled?: boolean; + }; + axios?: AxiosRequestConfig; + }, +) => { + const { swr: swrOptions, axios: axiosOptions } = options ?? {}; + + const isEnabled = swrOptions?.enabled !== false; + + const swrKey = + swrOptions?.swrKey ?? (() => (isEnabled ? getV1CampaignDonationsRetrieveKey(params) : null)); + + const swrFn = () => v1CampaignDonationsRetrieve(params, axiosOptions); + + const query = useSwr>, TError>(swrKey, swrFn, swrOptions); + + return { + swrKey, + ...query, + }; +}; + +/** + * Get paginated list of campaigns with optional filters + */ +export const v1CampaignsRetrieve = ( + params?: V1CampaignsRetrieveParams, + options?: AxiosRequestConfig, +): Promise> => { + return axios.get(`/api/v1/campaigns`, { + ...options, + params: { ...params, ...options?.params }, + }); +}; + +export const getV1CampaignsRetrieveKey = (params?: V1CampaignsRetrieveParams) => + [`/api/v1/campaigns`, ...(params ? [params] : [])] as const; + +export type V1CampaignsRetrieveQueryResult = NonNullable< + Awaited> +>; + +export type V1CampaignsRetrieveQueryError = AxiosError; + +export const useV1CampaignsRetrieve = >( + params?: V1CampaignsRetrieveParams, + options?: { + swr?: SWRConfiguration>, TError> & { + swrKey?: Key; + enabled?: boolean; + }; + axios?: AxiosRequestConfig; + }, +) => { + const { swr: swrOptions, axios: axiosOptions } = options ?? {}; + + const isEnabled = swrOptions?.enabled !== false; + + const swrKey = + swrOptions?.swrKey ?? (() => (isEnabled ? getV1CampaignsRetrieveKey(params) : null)); + + const swrFn = () => v1CampaignsRetrieve(params, axiosOptions); + + const query = useSwr>, TError>(swrKey, swrFn, swrOptions); + + return { + swrKey, + ...query, + }; +}; + +/** + * Get campaign details by ID + */ +export const v1CampaignsRetrieve2 = ( + campaignId: number, + options?: AxiosRequestConfig, +): Promise> => { + return axios.get(`/api/v1/campaigns/${campaignId}`, options); +}; + +export const getV1CampaignsRetrieve2Key = (campaignId: number) => + [`/api/v1/campaigns/${campaignId}`] as const; + +export type V1CampaignsRetrieve2QueryResult = NonNullable< + Awaited> +>; + +export type V1CampaignsRetrieve2QueryError = AxiosError; + +export const useV1CampaignsRetrieve2 = >( + campaignId: number, + options?: { + swr?: SWRConfiguration>, TError> & { + swrKey?: Key; + enabled?: boolean; + }; + axios?: AxiosRequestConfig; + }, +) => { + const { swr: swrOptions, axios: axiosOptions } = options ?? {}; + + const isEnabled = swrOptions?.enabled !== false && !!campaignId; + + const swrKey = + swrOptions?.swrKey ?? (() => (isEnabled ? getV1CampaignsRetrieve2Key(campaignId) : null)); + + const swrFn = () => v1CampaignsRetrieve2(campaignId, axiosOptions); + + const query = useSwr>, TError>(swrKey, swrFn, swrOptions); + + return { + swrKey, + ...query, + }; +}; + +/** + * Get paginated list of donations for a specific campaign + */ +export const v1CampaignsDonationsRetrieve = ( + campaignId: number, + params?: V1CampaignsDonationsRetrieveParams, + options?: AxiosRequestConfig, +): Promise> => { + return axios.get(`/api/v1/campaigns/${campaignId}/donations`, { + ...options, + params: { ...params, ...options?.params }, + }); +}; + +export const getV1CampaignsDonationsRetrieveKey = ( + campaignId: number, + params?: V1CampaignsDonationsRetrieveParams, +) => [`/api/v1/campaigns/${campaignId}/donations`, ...(params ? [params] : [])] as const; + +export type V1CampaignsDonationsRetrieveQueryResult = NonNullable< + Awaited> +>; + +export type V1CampaignsDonationsRetrieveQueryError = AxiosError; + +export const useV1CampaignsDonationsRetrieve = >( + campaignId: number, + params?: V1CampaignsDonationsRetrieveParams, + options?: { + swr?: SWRConfiguration>, TError> & { + swrKey?: Key; + enabled?: boolean; + }; + axios?: AxiosRequestConfig; + }, +) => { + const { swr: swrOptions, axios: axiosOptions } = options ?? {}; + + const isEnabled = swrOptions?.enabled !== false && !!campaignId; + + const swrKey = + swrOptions?.swrKey ?? + (() => (isEnabled ? getV1CampaignsDonationsRetrieveKey(campaignId, params) : null)); + + const swrFn = () => v1CampaignsDonationsRetrieve(campaignId, params, axiosOptions); + + const query = useSwr>, TError>(swrKey, swrFn, swrOptions); + + return { + swrKey, + ...query, + }; +}; + export const v1DonateContractConfigRetrieve = ( params?: V1DonateContractConfigRetrieveParams, options?: AxiosRequestConfig, diff --git a/src/common/api/indexer/internal/config.ts b/src/common/api/indexer/internal/config.ts index 623932075..3a7f987f9 100644 --- a/src/common/api/indexer/internal/config.ts +++ b/src/common/api/indexer/internal/config.ts @@ -8,3 +8,11 @@ import { INDEXER_API_ENDPOINT_URL } from "@/common/_config"; export const INDEXER_CLIENT_CONFIG: Record<"axios", AxiosRequestConfig> = { axios: { baseURL: INDEXER_API_ENDPOINT_URL }, }; + +/** + * Force to use testnet for staging + * NOTE: this is temporary, we will remove this once we have a proper staging environment + */ +export const INDEXER_CLIENT_CONFIG_STAGING: Record<"axios", AxiosRequestConfig> = { + axios: { baseURL: "https://dev.potlock.io" }, +}; diff --git a/src/common/api/indexer/sync.ts b/src/common/api/indexer/sync.ts new file mode 100644 index 000000000..820f0fbb4 --- /dev/null +++ b/src/common/api/indexer/sync.ts @@ -0,0 +1,533 @@ +import { INDEXER_API_ENDPOINT_URL } from "@/common/_config"; + +// Campaigns only exist on dev backend, everything else is on prod +const SYNC_API_BASE_URL = INDEXER_API_ENDPOINT_URL; + +const CAMPAIGNS_SYNC_API_BASE_URL = + process.env.NEXT_PUBLIC_ENV === "test" ? INDEXER_API_ENDPOINT_URL : "https://dev.potlock.io"; + +export const syncApi = { + /** + * Sync a campaign after creation or update + * @param campaignId - The on-chain campaign ID + */ + async campaign(campaignId: number | string): Promise<{ success: boolean; message?: string }> { + try { + const response = await fetch( + `${CAMPAIGNS_SYNC_API_BASE_URL}/api/v1/campaigns/${campaignId}/sync`, + { + method: "POST", + }, + ); + + if (!response.ok) { + const error = await response.json().catch(() => ({})); + console.warn("Failed to sync campaign:", error); + return { success: false, message: error?.error || "Sync failed" }; + } + + const result = await response.json(); + return { success: true, message: result.message }; + } catch (error) { + console.warn("Failed to sync campaign:", error); + return { success: false, message: String(error) }; + } + }, + + /** + * Sync a campaign donation after a donation is made + * @param campaignId - The on-chain campaign ID + * @param txHash - Transaction hash from the donation + * @param senderId - Account ID of the donor + */ + async campaignDonation( + campaignId: number | string, + txHash: string, + senderId: string, + ): Promise<{ success: boolean; message?: string }> { + try { + const response = await fetch( + `${CAMPAIGNS_SYNC_API_BASE_URL}/api/v1/campaigns/${campaignId}/donations/sync`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ tx_hash: txHash, sender_id: senderId }), + }, + ); + + if (!response.ok) { + const error = await response.json().catch(() => ({})); + console.warn("Failed to sync campaign donation:", error); + return { success: false, message: error?.error || "Sync failed" }; + } + + const result = await response.json(); + return { success: true, message: result.message }; + } catch (error) { + console.warn("Failed to sync campaign donation:", error); + return { success: false, message: String(error) }; + } + }, + + /** + * Sync a campaign deletion after the owner deletes it on-chain + * @param campaignId - The on-chain campaign ID + * @param txHash - Transaction hash from the delete transaction + * @param senderId - Account ID of the campaign owner who deleted it + */ + async campaignDelete( + campaignId: number | string, + txHash: string, + senderId: string, + ): Promise<{ success: boolean; message?: string }> { + try { + const response = await fetch( + `${CAMPAIGNS_SYNC_API_BASE_URL}/api/v1/campaigns/${campaignId}/delete/sync`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ tx_hash: txHash, sender_id: senderId }), + }, + ); + + if (!response.ok) { + const error = await response.json().catch(() => ({})); + console.warn("Failed to sync campaign deletion:", error); + return { success: false, message: error?.error || "Sync failed" }; + } + + const result = await response.json(); + return { success: true, message: result.message }; + } catch (error) { + console.warn("Failed to sync campaign deletion:", error); + return { success: false, message: String(error) }; + } + }, + + /** + * Sync campaign donation refunds after process_refunds_batch is executed + * @param campaignId - The on-chain campaign ID + * @param txHash - Transaction hash from the refund transaction + * @param senderId - Account ID of the sender who triggered refunds + */ + async campaignRefund( + campaignId: number | string, + txHash: string, + senderId: string, + ): Promise<{ success: boolean; message?: string }> { + try { + const response = await fetch( + `${CAMPAIGNS_SYNC_API_BASE_URL}/api/v1/campaigns/${campaignId}/refunds/sync`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ tx_hash: txHash, sender_id: senderId }), + }, + ); + + if (!response.ok) { + const error = await response.json().catch(() => ({})); + console.warn("Failed to sync campaign refunds:", error); + return { success: false, message: error?.error || "Sync failed" }; + } + + const result = await response.json(); + return { success: true, message: result.message }; + } catch (error) { + console.warn("Failed to sync campaign refunds:", error); + return { success: false, message: String(error) }; + } + }, + + /** + * Sync campaign donation unescrow after process_escrowed_donations_batch is executed + * @param campaignId - The on-chain campaign ID + * @param txHash - Transaction hash from the unescrow transaction + * @param senderId - Account ID of the sender who triggered unescrow + */ + async campaignUnescrow( + campaignId: number | string, + txHash: string, + senderId: string, + ): Promise<{ success: boolean; message?: string }> { + try { + const response = await fetch( + `${CAMPAIGNS_SYNC_API_BASE_URL}/api/v1/campaigns/${campaignId}/unescrow/sync`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ tx_hash: txHash, sender_id: senderId }), + }, + ); + + if (!response.ok) { + const error = await response.json().catch(() => ({})); + console.warn("Failed to sync campaign unescrow:", error); + return { success: false, message: error?.error || "Sync failed" }; + } + + const result = await response.json(); + return { success: true, message: result.message }; + } catch (error) { + console.warn("Failed to sync campaign unescrow:", error); + return { success: false, message: String(error) }; + } + }, + + /** + * Sync a list deletion after the owner deletes it on-chain + * @param listId - The on-chain list ID + * @param txHash - Transaction hash from the delete transaction + * @param senderId - Account ID of the list owner who deleted it + */ + async listDelete( + listId: number | string, + txHash: string, + senderId: string, + ): Promise<{ success: boolean; message?: string }> { + try { + const response = await fetch(`${SYNC_API_BASE_URL}/api/v1/lists/${listId}/delete/sync`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ tx_hash: txHash, sender_id: senderId }), + }); + + if (!response.ok) { + const error = await response.json().catch(() => ({})); + console.warn("Failed to sync list deletion:", error); + return { success: false, message: error?.error || "Sync failed" }; + } + + const result = await response.json(); + return { success: true, message: result.message }; + } catch (error) { + console.warn("Failed to sync list deletion:", error); + return { success: false, message: String(error) }; + } + }, + + /** + * Sync a list upvote after the user upvotes on-chain + * @param listId - The on-chain list ID + * @param txHash - Transaction hash from the upvote transaction + * @param senderId - Account ID of the user who upvoted + */ + async listUpvote( + listId: number | string, + txHash: string, + senderId: string, + ): Promise<{ success: boolean; message?: string }> { + try { + const response = await fetch(`${SYNC_API_BASE_URL}/api/v1/lists/${listId}/upvote/sync`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ tx_hash: txHash, sender_id: senderId }), + }); + + if (!response.ok) { + const error = await response.json().catch(() => ({})); + console.warn("Failed to sync list upvote:", error); + return { success: false, message: error?.error || "Sync failed" }; + } + + const result = await response.json(); + return { success: true, message: result.message }; + } catch (error) { + console.warn("Failed to sync list upvote:", error); + return { success: false, message: String(error) }; + } + }, + + /** + * Sync a list remove-upvote after the user removes their upvote on-chain + * @param listId - The on-chain list ID + * @param txHash - Transaction hash from the remove-upvote transaction + * @param senderId - Account ID of the user who removed their upvote + */ + async listRemoveUpvote( + listId: number | string, + txHash: string, + senderId: string, + ): Promise<{ success: boolean; message?: string }> { + try { + const response = await fetch( + `${SYNC_API_BASE_URL}/api/v1/lists/${listId}/remove-upvote/sync`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ tx_hash: txHash, sender_id: senderId }), + }, + ); + + if (!response.ok) { + const error = await response.json().catch(() => ({})); + console.warn("Failed to sync list remove-upvote:", error); + return { success: false, message: error?.error || "Sync failed" }; + } + + const result = await response.json(); + return { success: true, message: result.message }; + } catch (error) { + console.warn("Failed to sync list remove-upvote:", error); + return { success: false, message: String(error) }; + } + }, + + /** + * Sync an account profile and recalculate donation stats + * @param accountId - The NEAR account ID + */ + async account(accountId: string): Promise<{ success: boolean; message?: string }> { + try { + const response = await fetch(`${SYNC_API_BASE_URL}/api/v1/accounts/${accountId}/sync`, { + method: "POST", + }); + + if (!response.ok) { + const error = await response.json().catch(() => ({})); + console.warn("Failed to sync account:", error); + return { success: false, message: error?.error || "Sync failed" }; + } + + const result = await response.json(); + return { success: true, message: result.message }; + } catch (error) { + console.warn("Failed to sync account:", error); + return { success: false, message: String(error) }; + } + }, + + /** + * Sync a list after creation or update + * @param listId - The on-chain list ID + */ + async list(listId: number | string): Promise<{ success: boolean; message?: string }> { + try { + const response = await fetch(`${SYNC_API_BASE_URL}/api/v1/lists/${listId}/sync`, { + method: "POST", + }); + + if (!response.ok) { + const error = await response.json().catch(() => ({})); + console.warn("Failed to sync list:", error); + return { success: false, message: error?.error || "Sync failed" }; + } + + const result = await response.json(); + return { success: true, message: result.message }; + } catch (error) { + console.warn("Failed to sync list:", error); + return { success: false, message: String(error) }; + } + }, + + /** + * Sync all registrations for a list + * @param listId - The on-chain list ID + */ + async listRegistrations( + listId: number | string, + ): Promise<{ success: boolean; message?: string }> { + try { + const response = await fetch( + `${SYNC_API_BASE_URL}/api/v1/lists/${listId}/registrations/sync`, + { + method: "POST", + }, + ); + + if (!response.ok) { + const error = await response.json().catch(() => ({})); + console.warn("Failed to sync list registrations:", error); + return { success: false, message: error?.error || "Sync failed" }; + } + + const result = await response.json(); + return { success: true, message: result.message }; + } catch (error) { + console.warn("Failed to sync list registrations:", error); + return { success: false, message: String(error) }; + } + }, + + /** + * Sync a single registration for a list + * @param listId - The on-chain list ID + * @param registrantId - The registrant account ID + */ + async listRegistration( + listId: number | string, + registrantId: string, + ): Promise<{ success: boolean; message?: string }> { + try { + const response = await fetch( + `${SYNC_API_BASE_URL}/api/v1/lists/${listId}/registrations/${registrantId}/sync`, + { + method: "POST", + }, + ); + + if (!response.ok) { + const error = await response.json().catch(() => ({})); + console.warn("Failed to sync list registration:", error); + return { success: false, message: error?.error || "Sync failed" }; + } + + const result = await response.json(); + return { success: true, message: result.message }; + } catch (error) { + console.warn("Failed to sync list registration:", error); + return { success: false, message: String(error) }; + } + }, + + /** + * Sync a direct donation after it's made + * @param txHash - Transaction hash from the donation + * @param senderId - Account ID of the donor + */ + async directDonation( + txHash: string, + senderId: string, + ): Promise<{ success: boolean; message?: string }> { + try { + const response = await fetch(`${SYNC_API_BASE_URL}/api/v1/donations/sync`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ tx_hash: txHash, sender_id: senderId }), + }); + + if (!response.ok) { + const error = await response.json().catch(() => ({})); + console.warn("Failed to sync direct donation:", error); + return { success: false, message: error?.error || "Sync failed" }; + } + + const result = await response.json(); + return { success: true, message: result.message }; + } catch (error) { + console.warn("Failed to sync direct donation:", error); + return { success: false, message: String(error) }; + } + }, + + /** + * Sync a pot config after deployment or update + * @param potId - The pot account ID (e.g. "mypot.v1.potfactory.potlock.near") + */ + async pot(potId: string): Promise<{ success: boolean; message?: string }> { + try { + const response = await fetch(`${SYNC_API_BASE_URL}/api/v1/pots/${potId}/sync`, { + method: "POST", + }); + + if (!response.ok) { + const error = await response.json().catch(() => ({})); + console.warn("Failed to sync pot:", error); + return { success: false, message: error?.error || "Sync failed" }; + } + + const result = await response.json(); + return { success: true, message: result.message }; + } catch (error) { + console.warn("Failed to sync pot:", error); + return { success: false, message: String(error) }; + } + }, + + /** + * Sync all donations for a pot + * @param potId - The pot account ID + */ + async potDonations(potId: string): Promise<{ success: boolean; message?: string }> { + try { + const response = await fetch(`${SYNC_API_BASE_URL}/api/v1/pots/${potId}/donations/sync`, { + method: "POST", + }); + + if (!response.ok) { + const error = await response.json().catch(() => ({})); + console.warn("Failed to sync pot donations:", error); + return { success: false, message: error?.error || "Sync failed" }; + } + + const result = await response.json(); + return { success: true, message: result.message }; + } catch (error) { + console.warn("Failed to sync pot donations:", error); + return { success: false, message: String(error) }; + } + }, + + /** + * Sync all applications for a pot + * @param potId - The pot account ID + */ + async potApplications(potId: string): Promise<{ success: boolean; message?: string }> { + try { + const response = await fetch(`${SYNC_API_BASE_URL}/api/v1/pots/${potId}/applications/sync`, { + method: "POST", + }); + + if (!response.ok) { + const error = await response.json().catch(() => ({})); + console.warn("Failed to sync pot applications:", error); + return { success: false, message: error?.error || "Sync failed" }; + } + + const result = await response.json(); + return { success: true, message: result.message }; + } catch (error) { + console.warn("Failed to sync pot applications:", error); + return { success: false, message: String(error) }; + } + }, + + /** + * Sync all payouts for a pot + * @param potId - The pot account ID + */ + async potPayouts(potId: string): Promise<{ success: boolean; message?: string }> { + try { + const response = await fetch(`${SYNC_API_BASE_URL}/api/v1/pots/${potId}/payouts/sync`, { + method: "POST", + }); + + if (!response.ok) { + const error = await response.json().catch(() => ({})); + console.warn("Failed to sync pot payouts:", error); + return { success: false, message: error?.error || "Sync failed" }; + } + + const result = await response.json(); + return { success: true, message: result.message }; + } catch (error) { + console.warn("Failed to sync pot payouts:", error); + return { success: false, message: String(error) }; + } + }, + + /** + * Sync payout challenges for a pot + * @param potId - The pot account ID + */ + async potChallenges(potId: string): Promise<{ success: boolean; message?: string }> { + try { + const response = await fetch(`${SYNC_API_BASE_URL}/api/v1/pots/${potId}/challenges/sync`, { + method: "POST", + }); + + if (!response.ok) { + const error = await response.json().catch(() => ({})); + console.warn("Failed to sync pot challenges:", error); + return { success: false, message: error?.error || "Sync failed" }; + } + + const result = await response.json(); + return { success: true, message: result.message }; + } catch (error) { + console.warn("Failed to sync pot challenges:", error); + return { success: false, message: String(error) }; + } + }, +}; diff --git a/src/common/blockchains/near-protocol/client.ts b/src/common/blockchains/near-protocol/client.ts index 0dbfbc5da..918c7ea44 100644 --- a/src/common/blockchains/near-protocol/client.ts +++ b/src/common/blockchains/near-protocol/client.ts @@ -1,92 +1,266 @@ -import { setupBitgetWallet } from "@near-wallet-selector/bitget-wallet"; -import { setupBitteWallet } from "@near-wallet-selector/bitte-wallet"; -import { setupCoin98Wallet } from "@near-wallet-selector/coin98-wallet"; -import { - EthereumWalletsParams, - setupEthereumWallets, -} from "@near-wallet-selector/ethereum-wallets"; -import { setupHereWallet } from "@near-wallet-selector/here-wallet"; -import { setupLedger } from "@near-wallet-selector/ledger"; -import { setupMathWallet } from "@near-wallet-selector/math-wallet"; -import { setupMeteorWallet } from "@near-wallet-selector/meteor-wallet"; -import { setupMintbaseWallet } from "@near-wallet-selector/mintbase-wallet"; -import { setupNarwallets } from "@near-wallet-selector/narwallets"; -import { setupNearMobileWallet } from "@near-wallet-selector/near-mobile-wallet"; -// import { setupNearSnap } from "@near-wallet-selector/near-snap"; -import { setupNearFi } from "@near-wallet-selector/nearfi"; -import { setupNeth } from "@near-wallet-selector/neth"; -import { setupNightly } from "@near-wallet-selector/nightly"; -import { setupRamperWallet } from "@near-wallet-selector/ramper-wallet"; -import { setupSender } from "@near-wallet-selector/sender"; -import { setupWelldoneWallet } from "@near-wallet-selector/welldone-wallet"; -import { setupXDEFI } from "@near-wallet-selector/xdefi"; -import naxios from "@wpdas/naxios"; -import { AccountView } from "near-api-js/lib/providers/provider"; +import { NearConnector } from "@hot-labs/near-connect"; +import type { NearWalletBase } from "@hot-labs/near-connect"; +import type { Account } from "@hot-labs/near-connect/build/types"; +import { actionCreators } from "@near-js/transactions"; +import type { + FinalExecutionOutcome, + QueryResponseKind, +} from "@near-js/types/lib/provider/response"; +import { providers } from "near-api-js"; import { NETWORK, SOCIAL_DB_CONTRACT_ACCOUNT_ID } from "@/common/_config"; import { FULL_TGAS } from "@/common/constants"; -import { AccountId } from "@/common/types"; - -import { wagmiConfig, web3Modal } from "./web3modal"; export const RPC_NODE_URL = `https://${NETWORK === "mainnet" ? "free.rpc.fastnear.com" : "test.rpc.fastnear.com"}`; -// Naxios (Contract/Wallet) Instance -export const naxiosInstance = new naxios({ - rpcNodeUrl: RPC_NODE_URL, - contractId: SOCIAL_DB_CONTRACT_ACCOUNT_ID, - network: NETWORK, - walletSelectorModules: [ - setupSender(), - setupHereWallet(), - setupMeteorWallet(), - setupLedger(), - setupEthereumWallets({ - wagmiConfig: wagmiConfig as EthereumWalletsParams["wagmiConfig"], - web3Modal: web3Modal as EthereumWalletsParams["web3Modal"], - alwaysOnboardDuringSignIn: true, - }), - setupNearMobileWallet(), - setupNightly(), - setupBitgetWallet(), - setupCoin98Wallet(), - setupMathWallet(), - setupMintbaseWallet(), - setupBitteWallet(), - setupNearFi(), - setupWelldoneWallet(), - setupXDEFI(), - // INFO: This is breaking the app because it needs to access 'fs' module which is not present on the client side - // setupNearSnap(), - setupNarwallets(), - setupRamperWallet(), - setupNeth({ - gas: FULL_TGAS, - bundle: false, - }), - ], -}); - -/** - * DO NOT USE DIRECTLY! - */ -export const walletApi = naxiosInstance.walletApi(); - -/** - * NEAR JsonRpcProvider - */ -export const nearRpc = naxiosInstance.rpcApi(); - -export const near = { - isAccountValid: async (account_id: AccountId) => - account_id.length > 4 - ? await nearRpc - .query({ - request_type: "view_account", - finality: "final", - account_id, - }) - .then(Boolean) - .catch(() => false) - : false, +const nearRpc = new providers.JsonRpcProvider({ url: RPC_NODE_URL }); + +type CallProps> = { + args?: A; + gas?: string; + deposit?: string; + callbackUrl?: string; +}; + +export type Transaction> = { + receiverId?: string; + method: string; + args?: A; + gas?: string; + deposit?: string; +}; + +const createWalletApi = () => { + const state = { + connector: undefined as NearConnector | undefined, + wallet: undefined as NearWalletBase | undefined, + accounts: [] as Account[], + }; + + let initPromise: Promise | undefined; + + const syncAccounts = async () => { + if (!state.connector) { + state.wallet = undefined; + state.accounts = []; + return; + } + + try { + const connected = await state.connector.getConnectedWallet(); + state.wallet = connected.wallet; + state.accounts = connected.accounts; + } catch (error) { + state.wallet = undefined; + state.accounts = []; + } + }; + + const initNear = () => { + if (initPromise) { + return initPromise; + } + + initPromise = (async () => { + state.connector = new NearConnector({ + network: NETWORK as "mainnet" | "testnet", + signIn: { + contractId: SOCIAL_DB_CONTRACT_ACCOUNT_ID, + }, + }); + + state.connector.on("wallet:signIn", ({ wallet, accounts }) => { + state.wallet = wallet; + state.accounts = accounts; + }); + + state.connector.on("wallet:signOut", () => { + state.wallet = undefined; + state.accounts = []; + }); + + await state.connector.whenManifestLoaded; + await syncAccounts(); + })(); + + return initPromise; + }; + + const ensureWallet = async () => { + await initNear(); + + if (!state.connector) { + throw new Error("Wallet connector is not initialized."); + } + + await syncAccounts(); + + if (!state.wallet) { + state.wallet = await state.connector.wallet(); + } + + return state.wallet!; + }; + + const signInModal = async () => { + await initNear(); + + if (!state.connector) { + throw new Error("Wallet connector is not initialized."); + } + + const walletId = await state.connector.selectWallet(); + await state.connector.connect(walletId); + await syncAccounts(); + }; + + const signOut = async () => { + await initNear(); + + if (!state.connector) { + return; + } + + try { + const connected = await state.connector.getConnectedWallet().catch(() => undefined); + + if (connected?.wallet) { + await state.connector.disconnect(connected.wallet); + } else { + await state.connector.disconnect(); + } + } finally { + state.wallet = undefined; + state.accounts = []; + } + }; + + return { + get connector() { + return state.connector; + }, + get wallet() { + return state.wallet; + }, + + get accountId() { + return state.accounts.at(0)?.accountId; + }, + get isSignedIn() { + return state.accounts.length > 0; + }, + + initNear, + signInModal, + ensureWallet, + signOut, + }; +}; + +export const walletApi = createWalletApi(); + +export { nearRpc }; + +const buildAction = (method: string, props?: CallProps) => + actionCreators.functionCall( + method, + props?.args ?? {}, + BigInt(props?.gas ?? FULL_TGAS), + BigInt(props?.deposit ?? "0"), + ); + +export const contractApi = ({ contractId }: { contractId?: string } = {}) => { + const targetContractId = contractId ?? SOCIAL_DB_CONTRACT_ACCOUNT_ID; + + const view = async , R = unknown>( + method: string, + props?: { args?: A }, + ) => { + const response = (await nearRpc.query({ + request_type: "call_function", + account_id: targetContractId, + method_name: method, + args_base64: Buffer.from(JSON.stringify(props?.args ?? {})).toString("base64"), + finality: "optimistic", + })) as QueryResponseKind & { result: Uint8Array }; + + return JSON.parse(Buffer.from(response.result).toString()) as R; + }; + + const call = async (method: string, props?: CallProps) => { + const wallet = await walletApi.ensureWallet(); + const signerId = walletApi.accountId; + + if (!signerId) { + throw new Error("Wallet is not signed in."); + } + + const transaction = { + signerId, + receiverId: contractId ?? targetContractId, + actions: [buildAction(method, props as CallProps)], + }; + + let outcome: FinalExecutionOutcome | unknown; + + try { + if (!("signAndSendTransaction" in wallet)) { + throw new Error("Wallet does not support signAndSendTransaction"); + } + + outcome = await wallet.signAndSendTransaction({ + signerId, + receiverId: transaction.receiverId, + actions: transaction.actions, + }); + } catch (error) { + // Fallback for wallets that only support signAndSendTransactions + if ("signAndSendTransactions" in wallet) { + outcome = await wallet.signAndSendTransactions({ + transactions: [ + { + receiverId: transaction.receiverId, + actions: transaction.actions, + }, + ], + }); + } else { + throw error; + } + } + + const result = providers.getTransactionLastResult(outcome as FinalExecutionOutcome); + + // Some wallets don't return a last result; return the outcome instead. + if (result === undefined) { + return outcome as unknown as R; + } + + return result as R; + }; + + const callMultiple = async ( + transactionsList: Transaction[], + _callbackUrl?: string, + ) => { + const wallet = await walletApi.ensureWallet(); + const signerId = walletApi.accountId; + + if (!signerId) { + throw new Error("Wallet is not signed in."); + } + + const transactions = transactionsList.map((transaction) => ({ + receiverId: transaction.receiverId ?? targetContractId, + actions: [buildAction(transaction.method, transaction as CallProps)], + })); + + return wallet.signAndSendTransactions({ transactions }); + }; + + return { + view, + call, + callMultiple, + }; }; diff --git a/src/common/blockchains/near-protocol/hooks.ts b/src/common/blockchains/near-protocol/hooks.ts index b514f3b32..3fa13625a 100644 --- a/src/common/blockchains/near-protocol/hooks.ts +++ b/src/common/blockchains/near-protocol/hooks.ts @@ -40,16 +40,21 @@ export const useViewAccount = ({ enabled = true, live = false, ...params -}: ByAccountId & ConditionalActivation & LiveUpdateParams): SWRResponse => +}: ByAccountId & ConditionalActivation & LiveUpdateParams): SWRResponse< + AccountView | undefined, + Error +> => useSWR( - () => (!enabled || !IS_CLIENT ? null : ["view_account", params.accountId]), + () => (enabled ? ["view_account", params.accountId] : null), ([_queryKeyHead, accountId]) => - nearRpc.query({ - request_type: "view_account", - account_id: accountId, - finality: "final", - }), + !IS_CLIENT + ? undefined + : nearRpc.query({ + request_type: "view_account", + account_id: accountId, + finality: "final", + }), { ...(live diff --git a/src/common/blockchains/near-protocol/index.ts b/src/common/blockchains/near-protocol/index.ts index 528dfcc2c..0cc08b2eb 100644 --- a/src/common/blockchains/near-protocol/index.ts +++ b/src/common/blockchains/near-protocol/index.ts @@ -1,6 +1,8 @@ import * as nearProtocolClient from "./client"; import * as nearProtocolHooks from "./hooks"; +import * as nearProtocolSchemas from "./model/schemas"; export * from "./types"; +export * from "./utils/validations"; -export { nearProtocolClient, nearProtocolHooks }; +export { nearProtocolClient, nearProtocolHooks, nearProtocolSchemas }; diff --git a/src/common/blockchains/near-protocol/model/schemas.ts b/src/common/blockchains/near-protocol/model/schemas.ts new file mode 100644 index 000000000..91755d2fa --- /dev/null +++ b/src/common/blockchains/near-protocol/model/schemas.ts @@ -0,0 +1,21 @@ +import { string } from "zod"; + +import { NETWORK } from "@/common/_config"; + +import { isNearAccountValid } from "../utils/validations"; + +const primitive = string().min(5, "Account ID is too short"); + +export const validAccountId = primitive.refine(isNearAccountValid, { + message: `Account doesn't exist on ${NETWORK}`, +}); + +export const validAccountIdOrNothing = primitive + .optional() + .nullable() + .refine( + async (accountId) => + typeof accountId === "string" ? await isNearAccountValid(accountId) : true, + + { message: `Account doesn't exist on ${NETWORK}` }, + ); diff --git a/src/common/blockchains/near-protocol/utils/validations.ts b/src/common/blockchains/near-protocol/utils/validations.ts new file mode 100644 index 000000000..10a091017 --- /dev/null +++ b/src/common/blockchains/near-protocol/utils/validations.ts @@ -0,0 +1,34 @@ +import type { AccountView } from "near-api-js/lib/providers/provider"; + +import type { AccountId } from "@/common/types"; + +import { nearRpc } from "../client"; + +const accountValidationCache = new Map(); +let lastValidationTimestamp = 0; +const DEBOUNCE_MS = 300; + +export const isNearAccountValid = async (account_id: AccountId) => { + if (account_id.length <= 4) return false; + + const cached = accountValidationCache.get(account_id); + if (cached !== undefined) return cached; + + // Debounce: wait before making RPC call, skip if a newer call arrives + const timestamp = Date.now(); + lastValidationTimestamp = timestamp; + await new Promise((resolve) => setTimeout(resolve, DEBOUNCE_MS)); + if (lastValidationTimestamp !== timestamp) return false; + + const isValid = await nearRpc + .query({ + request_type: "view_account", + finality: "final", + account_id, + }) + .then(Boolean) + .catch(() => false); + + accountValidationCache.set(account_id, isValid); + return isValid; +}; diff --git a/src/common/constants.ts b/src/common/constants.ts index f28540b41..338631c6f 100644 --- a/src/common/constants.ts +++ b/src/common/constants.ts @@ -1,13 +1,20 @@ import { Big } from "big.js"; -import { utils } from "near-api-js"; -import { NEAR_NOMINATION_EXP } from "near-api-js/lib/utils/format"; +import { NEAR_NOMINATION_EXP, parseNearAmount } from "near-api-js/lib/utils/format"; import { Metadata } from "next"; import type { SWRConfiguration } from "swr"; -import { NETWORK, PLATFORM_NAME } from "./_config"; +import { + NAMESPACE_ROOT_CONTRACT_ACCOUNT_ID, + NETWORK, + PLATFORM_NAME, + SOCIAL_PLATFORM_NAME, +} from "./_config"; import { ChronologicalSortOrderVariant, type TokenId } from "./types"; +import workspacePackageManifest from "../../package.json"; -export { PLATFORM_NAME }; +export const FRAMEWORK_VERSION = workspacePackageManifest.version; + +export { NAMESPACE_ROOT_CONTRACT_ACCOUNT_ID, PLATFORM_NAME, SOCIAL_PLATFORM_NAME }; export const IS_CLIENT = typeof window !== "undefined"; @@ -28,6 +35,7 @@ export const APP_BOS_COUNTERPART_URL = "https://bos.potlock.org"; export const APP_METADATA: Metadata & { title: string; description: NonNullable; + other: { version: string }; manifest: NonNullable; openGraph: { @@ -43,6 +51,7 @@ export const APP_METADATA: Metadata & { } = { title: PLATFORM_NAME, description: "Bringing public goods funding to the table, built on NEAR", + other: { version: FRAMEWORK_VERSION }, manifest: "/manifest.json", icons: { @@ -125,11 +134,11 @@ export const PUBLIC_GOODS_REGISTRY_LIST_ID = 1; // Separates contract_id and method_name in ProviderId export const PROVIDER_ID_DELIMITER = ":"; -export const ONE_NEAR = utils.format.parseNearAmount("1")!; -export const HALF_NEAR = utils.format.parseNearAmount("0.5")!; -export const ONE_TENTH_NEAR = utils.format.parseNearAmount("0.1")!; -export const ONE_HUNDREDTH_NEAR = utils.format.parseNearAmount("0.01")!; -export const TWO_HUNDREDTHS_NEAR = utils.format.parseNearAmount("0.02")!; +export const ONE_NEAR = parseNearAmount("1")!; +export const HALF_NEAR = parseNearAmount("0.5")!; +export const ONE_TENTH_NEAR = parseNearAmount("0.1")!; +export const ONE_HUNDREDTH_NEAR = parseNearAmount("0.01")!; +export const TWO_HUNDREDTHS_NEAR = parseNearAmount("0.02")!; // 300 TGas (full) export const FULL_TGAS = "300000000000000"; @@ -160,6 +169,8 @@ export const CHRONOLOGICAL_SORT_OPTIONS: { export const NOOP_STRING = "noop"; +export const NOOP_FUNCTION: VoidFunction = () => void null; + export const NOOP_BALANCE_VIEW = new Promise((resolve) => resolve(Big(0))); export const CONTRACT_SWR_CONFIG: SWRConfiguration = { @@ -197,3 +208,13 @@ export const SUPPORTED_FTS: Record< .toFixed(decimals || 2), }, }; + +console.info(` + ___ ___ _____ _ ___ ___ _ __ + | _ \\/ _ \\_ _| | / _ \\ / __| |/ / + | _/ (_) || | | |_| (_) | (__| ' < + |_| \\___/ |_| |____\\___/ \\___|_|\\_\\ + + version: ${FRAMEWORK_VERSION} + +`); diff --git a/src/common/contracts/core/campaigns/client.ts b/src/common/contracts/core/campaigns/client.ts index 64feed968..f9ab89e75 100644 --- a/src/common/contracts/core/campaigns/client.ts +++ b/src/common/contracts/core/campaigns/client.ts @@ -1,10 +1,19 @@ -import { MemoryCache } from "@wpdas/naxios"; +import bigJs from "big.js"; -import { CAMPAIGNS_CONTRACT_ACCOUNT_ID } from "@/common/_config"; -import { naxiosInstance } from "@/common/blockchains/near-protocol/client"; -import { FULL_TGAS } from "@/common/constants"; -import { floatToYoctoNear } from "@/common/lib"; +import { + CAMPAIGNS_CONTRACT_ACCOUNT_ID, + LISTS_CONTRACT_ACCOUNT_ID, + SOCIAL_DB_CONTRACT_ACCOUNT_ID, +} from "@/common/_config"; +import { + Transaction, + contractApi as createContractApi, +} from "@/common/blockchains/near-protocol/client"; +import { FULL_TGAS, PUBLIC_GOODS_REGISTRY_LIST_ID } from "@/common/constants"; +import { floatToYoctoNear, parseNearAmount } from "@/common/lib"; import { AccountId, CampaignId, type IndivisibleUnits } from "@/common/types"; +import { ACCOUNT_PROFILE_IMAGE_PLACEHOLDER_SRC } from "@/entities/_shared/account"; +import { profileConfigurationInputsToSocialDbFormat } from "@/features/profile-configuration/utils/normalization"; import { Campaign, @@ -13,8 +22,9 @@ import { CampaignInputs, CampaignsContractConfig, } from "./interfaces"; +import { NEARSocialUserProfile } from "../../social-db"; -const contractApi = naxiosInstance.contractApi({ +const contractApi = createContractApi({ contractId: CAMPAIGNS_CONTRACT_ACCOUNT_ID, }); @@ -22,24 +32,125 @@ export const get_config = () => contractApi.view<{}, CampaignsContractConfig>("g export type CreateCampaignParams = { args: CampaignInputs }; -export const create_campaign = ({ args }: CreateCampaignParams) => - contractApi.call("create_campaign", { - args, - deposit: floatToYoctoNear(0.021), - gas: FULL_TGAS, - }); +export const create_campaign = ({ args }: CreateCampaignParams) => { + // If the project name is provided, we need to create a social profile for the project + if (args.project_name && args.recipient === args.owner) { + const { project_name, project_description, ...rest } = args; -export const process_escrowed_donations_batch = ({ args }: { args: { campaign_id: CampaignId } }) => - contractApi.call("process_escrowed_donations_batch", { - args, - gas: FULL_TGAS, - }); + const socialArgs: NEARSocialUserProfile = profileConfigurationInputsToSocialDbFormat({ + name: project_name, + description: project_description ?? "", + categories: [], // Default category for new projects + profileImage: ACCOUNT_PROFILE_IMAGE_PLACEHOLDER_SRC, + }); -export const process_refunds_batch = ({ args }: { args: { campaign_id: CampaignId } }) => - contractApi.call("process_refunds_batch", { + const depositFloat = bigJs(JSON.stringify(socialArgs).length * 0.00003) + .add(0.1) + .toString(); + + const transactions: Transaction>[] = [ + { + receiverId: SOCIAL_DB_CONTRACT_ACCOUNT_ID, + method: "set", + args: { + data: { + [args.owner as AccountId]: { + profile: socialArgs, + }, + }, + }, + deposit: parseNearAmount(depositFloat)!, + }, + { + receiverId: LISTS_CONTRACT_ACCOUNT_ID, + method: "register_batch", + args: { list_id: PUBLIC_GOODS_REGISTRY_LIST_ID }, + deposit: parseNearAmount("0.05")!, + gas: FULL_TGAS, + }, + { + method: "create_campaign", + args: rest, + deposit: floatToYoctoNear(0.021), + gas: FULL_TGAS, + }, + ]; + + return contractApi.callMultiple(transactions); + } else { + return contractApi.call("create_campaign", { + args, + deposit: floatToYoctoNear(0.021), + gas: FULL_TGAS, + }); + } +}; + +export type TxHashResult = { + txHash: string | null; +}; + +const callWithTxHash = async ( + method: string, + args: Record, + deposit?: string, +): Promise => { + const { walletApi } = await import("@/common/blockchains/near-protocol/client"); + const wallet = await walletApi.ensureWallet(); + const signerId = walletApi.accountId; + + if (!signerId) { + throw new Error("Wallet is not signed in."); + } + + const { actionCreators } = await import("@near-js/transactions"); + + const action = actionCreators.functionCall( + method, args, - gas: FULL_TGAS, - }); + BigInt(FULL_TGAS), + BigInt(deposit ?? "0"), + ); + + let outcome: any; + const walletAny = wallet as any; + + if ("signAndSendTransaction" in walletAny) { + outcome = await walletAny.signAndSendTransaction({ + signerId, + receiverId: CAMPAIGNS_CONTRACT_ACCOUNT_ID, + actions: [action], + }); + } else if ("signAndSendTransactions" in walletAny) { + const results = await walletAny.signAndSendTransactions({ + transactions: [ + { + receiverId: CAMPAIGNS_CONTRACT_ACCOUNT_ID, + actions: [action], + }, + ], + }); + + outcome = Array.isArray(results) ? results[0] : results; + } else { + throw new Error("Wallet does not support transaction signing"); + } + + const txHash = outcome?.transaction?.hash || outcome?.transaction_outcome?.id || null; + return { txHash }; +}; + +export const process_escrowed_donations_batch = ({ + args, +}: { + args: { campaign_id: CampaignId }; +}): Promise => callWithTxHash("process_escrowed_donations_batch", args); + +export const process_refunds_batch = ({ + args, +}: { + args: { campaign_id: CampaignId }; +}): Promise => callWithTxHash("process_refunds_batch", args); export type UpdateCampaignParams = { args: CampaignInputs & { campaign_id: CampaignId } }; @@ -52,20 +163,65 @@ export const update_campaign = ({ args }: UpdateCampaignParams) => export type DeleteCampaignParams = { args: { campaign_id: CampaignId } }; -export const delete_campaign = ({ args }: DeleteCampaignParams) => - contractApi.call("delete_campaign", { - args, - deposit: floatToYoctoNear(0.021), - gas: FULL_TGAS, - }); +export const delete_campaign = ({ args }: DeleteCampaignParams): Promise => + callWithTxHash("delete_campaign", args, floatToYoctoNear(0.021)); + +export type DonateResult = { + donation: CampaignDonation; + txHash: string | null; +}; + +export const donate = async ( + args: CampaignDonationArgs, + depositAmountYocto: IndivisibleUnits, +): Promise => { + const { walletApi } = await import("@/common/blockchains/near-protocol/client"); + const wallet = await walletApi.ensureWallet(); + const signerId = walletApi.accountId; -export const donate = (args: CampaignDonationArgs, depositAmountYocto: IndivisibleUnits) => - contractApi.call("donate", { + if (!signerId) { + throw new Error("Wallet is not signed in."); + } + + const { actionCreators } = await import("@near-js/transactions"); + const { providers } = await import("near-api-js"); + + const action = actionCreators.functionCall( + "donate", args, - deposit: depositAmountYocto, - gas: FULL_TGAS, - callbackUrl: window.location.href, - }); + BigInt(FULL_TGAS), + BigInt(depositAmountYocto), + ); + + let outcome: any; + const walletAny = wallet as any; + + if ("signAndSendTransaction" in walletAny) { + outcome = await walletAny.signAndSendTransaction({ + signerId, + receiverId: CAMPAIGNS_CONTRACT_ACCOUNT_ID, + actions: [action], + }); + } else if ("signAndSendTransactions" in walletAny) { + const results = await walletAny.signAndSendTransactions({ + transactions: [ + { + receiverId: CAMPAIGNS_CONTRACT_ACCOUNT_ID, + actions: [action], + }, + ], + }); + + outcome = Array.isArray(results) ? results[0] : results; + } else { + throw new Error("Wallet does not support transaction signing"); + } + + const txHash = outcome?.transaction?.hash || outcome?.transaction_outcome?.id || null; + const donation = providers.getTransactionLastResult(outcome) as CampaignDonation; + + return { donation, txHash }; +}; export const get_campaigns = () => contractApi.view<{}, Campaign[]>("get_campaigns"); diff --git a/src/common/contracts/core/campaigns/hooks.ts b/src/common/contracts/core/campaigns/hooks.ts index 3e930d999..7fab52f76 100644 --- a/src/common/contracts/core/campaigns/hooks.ts +++ b/src/common/contracts/core/campaigns/hooks.ts @@ -7,8 +7,8 @@ import * as contractClient from "./client"; export const useCampaigns = ({ enabled = true }: ConditionalActivation | undefined = {}) => useSWR( - ["get_campaigns"], - () => (!enabled || !IS_CLIENT ? undefined : contractClient.get_campaigns()), + () => (enabled ? ["get_campaigns"] : null), + () => (!IS_CLIENT ? undefined : contractClient.get_campaigns()), CONTRACT_SWR_CONFIG, ); @@ -20,10 +20,10 @@ export const useOwnedCampaigns = ({ Omit & ConditionalActivation) => useSWR( - ["useOwnedCampaigns", accountId, params], + () => (enabled ? ["useOwnedCampaigns", accountId, params] : null), ([_queryKeyHead, accountIdKey, paramsKey]) => - !enabled || !IS_CLIENT + !IS_CLIENT ? undefined : contractClient.get_campaigns_by_owner({ owner_id: accountIdKey, ...paramsKey }), @@ -32,12 +32,10 @@ export const useOwnedCampaigns = ({ export const useCampaign = ({ enabled = true, campaignId }: ByCampaignId & ConditionalActivation) => useSWR( - ["useCampaign", campaignId], + () => (enabled ? ["useCampaign", campaignId] : null), ([_queryKeyHead, campaignIdKey]) => - !enabled || !IS_CLIENT - ? undefined - : contractClient.get_campaign({ campaign_id: campaignIdKey }), + !IS_CLIENT ? undefined : contractClient.get_campaign({ campaign_id: campaignIdKey }), CONTRACT_SWR_CONFIG, ); @@ -48,10 +46,10 @@ export const useCampaignDonations = ({ ...params }: ByCampaignId & Omit & ConditionalActivation) => useSWR( - ["useCampaignDonations", campaignId, params], + () => (enabled ? ["useCampaignDonations", campaignId, params] : null), ([_queryKeyHead, campaignIdKey, paramsKey]) => - !enabled || !IS_CLIENT + !IS_CLIENT ? undefined : contractClient.get_donations_for_campaign({ campaign_id: campaignIdKey, ...paramsKey }), @@ -64,10 +62,10 @@ export const useHasEscrowedDonationsToProcess = ({ ...params }: ByCampaignId & ConditionalActivation) => useSWR( - ["useHasEscrowedDonationsToProcess", campaignId, params], + () => (enabled ? ["useHasEscrowedDonationsToProcess", campaignId, params] : null), ([_queryKeyHead, campaignIdKey, paramsKey]) => - !enabled || !IS_CLIENT + !IS_CLIENT ? undefined : contractClient.has_escrowed_donations_to_process({ campaign_id: campaignIdKey, @@ -83,15 +81,18 @@ export const useIsDonationRefundsProcessed = ({ ...params }: ByCampaignId & ConditionalActivation) => useSWR( - ["isDonationsRefundsProcessed", campaignId, params], + () => (enabled ? ["isDonationsRefundsProcessed", campaignId, params] : null), ([_queryKeyHead, campaignIdKey, paramsKey]) => - !enabled || !IS_CLIENT + !IS_CLIENT ? undefined - : contractClient.can_process_refunds({ - campaign_id: campaignIdKey, - ...paramsKey, - }), + : contractClient.can_process_refunds({ campaign_id: campaignIdKey, ...paramsKey }), CONTRACT_SWR_CONFIG, ); + +export const useConfig = ({ enabled = true }: ConditionalActivation | undefined = {}) => + useSWR( + () => (enabled ? ["campaigns_config"] : null), + ([_queryKeyHead]) => (!IS_CLIENT ? undefined : contractClient.get_config()), + ); diff --git a/src/common/contracts/core/campaigns/interfaces.ts b/src/common/contracts/core/campaigns/interfaces.ts index 3dfb19a18..3719552ca 100644 --- a/src/common/contracts/core/campaigns/interfaces.ts +++ b/src/common/contracts/core/campaigns/interfaces.ts @@ -25,6 +25,9 @@ export type CampaignInputs = { referral_fee_basis_points?: number; creator_fee_basis_points?: number; allow_fee_avoidance?: boolean; + project_name?: string; + project_description?: string; + project_banner_image_url?: string; }; export type Campaign = { diff --git a/src/common/contracts/core/donation/client.ts b/src/common/contracts/core/donation/client.ts index 998b7f8a4..2424c0fc0 100644 --- a/src/common/contracts/core/donation/client.ts +++ b/src/common/contracts/core/donation/client.ts @@ -1,7 +1,5 @@ -import { MemoryCache } from "@wpdas/naxios"; - import { DONATION_CONTRACT_ACCOUNT_ID } from "@/common/_config"; -import { naxiosInstance } from "@/common/blockchains/near-protocol/client"; +import { contractApi, walletApi } from "@/common/blockchains/near-protocol/client"; import { FULL_TGAS } from "@/common/constants"; import type { IndivisibleUnits } from "@/common/types"; @@ -12,9 +10,13 @@ import { DirectDonationConfig, } from "./interfaces"; -const contractApi = naxiosInstance.contractApi({ +export type DirectDonateResult = { + donation: DirectDonation; + txHash: string | null; +}; + +const donationContractApi = contractApi({ contractId: DONATION_CONTRACT_ACCOUNT_ID, - cache: new MemoryCache({ expirationTime: 10 }), // 10 seg }); // READ METHODS @@ -22,49 +24,169 @@ const contractApi = naxiosInstance.contractApi({ /** * Get donate contract config */ -export const get_config = () => contractApi.view<{}, DirectDonationConfig>("get_config"); +export const get_config = () => donationContractApi.view<{}, DirectDonationConfig>("get_config"); /** * Get direct donations */ export const get_donations = (args: { fromIndex?: number; limit?: number }) => - contractApi.view("get_donations", { args }); + donationContractApi.view("get_donations", { args }); /** * Get donations for a recipient id */ export const get_donations_for_recipient = (args: { recipient_id: string }) => - contractApi.view("get_donations_for_recipient", { args }); + donationContractApi.view("get_donations_for_recipient", { args }); /** * Get donations for donor id */ export const get_donations_for_donor = (args: { donor_id: string }) => - contractApi.view("get_donations_for_donor", { + donationContractApi.view("get_donations_for_donor", { args, }); -export const donate = (args: DirectDonationArgs, depositAmountYocto: IndivisibleUnits) => - contractApi.call("donate", { +export const donate = async ( + args: DirectDonationArgs, + depositAmountYocto: IndivisibleUnits, +): Promise => { + const wallet = await walletApi.ensureWallet(); + const signerId = walletApi.accountId; + + if (!signerId) { + throw new Error("Wallet is not signed in."); + } + + const { actionCreators } = await import("@near-js/transactions"); + const { providers } = await import("near-api-js"); + + const action = actionCreators.functionCall( + "donate", args, - deposit: depositAmountYocto, - gas: FULL_TGAS, - callbackUrl: window.location.href, - }); + BigInt(FULL_TGAS), + BigInt(depositAmountYocto), + ); + + let outcome: any; + const walletAny = wallet as any; + + if ("signAndSendTransaction" in walletAny) { + outcome = await walletAny.signAndSendTransaction({ + signerId, + receiverId: DONATION_CONTRACT_ACCOUNT_ID, + actions: [action], + }); + } else if ("signAndSendTransactions" in walletAny) { + const results = await walletAny.signAndSendTransactions({ + transactions: [ + { + receiverId: DONATION_CONTRACT_ACCOUNT_ID, + actions: [action], + }, + ], + }); + + outcome = Array.isArray(results) ? results[0] : results; + } else { + throw new Error("Wallet does not support transaction signing"); + } + + const txHash = outcome?.transaction?.hash || outcome?.transaction_outcome?.id || null; + const donation = providers.getTransactionLastResult(outcome) as DirectDonation; + + return { donation, txHash }; +}; + +export type DirectBatchDonateResult = { + donations: DirectDonation[]; + txHash: string | null; +}; -export const donateBatch = (txInputs: DirectBatchDonationItem[]) => - contractApi.callMultiple( - txInputs.map(({ amountYoctoNear, ...txInput }) => ({ - method: "donate", - deposit: amountYoctoNear, - gas: FULL_TGAS, +export const donateBatch = async ( + txInputs: DirectBatchDonationItem[], +): Promise => { + const wallet = await walletApi.ensureWallet(); + const signerId = walletApi.accountId; - ...txInput, - })), + if (!signerId) { + throw new Error("Wallet is not signed in."); + } + + const { actionCreators } = await import("@near-js/transactions"); + const { providers } = await import("near-api-js"); + + // Create actions for each donation + const actions = txInputs.map(({ amountYoctoNear, args }) => + actionCreators.functionCall("donate", args, BigInt(FULL_TGAS), BigInt(amountYoctoNear)), ); + let outcome: any; + const walletAny = wallet as any; + + if ("signAndSendTransaction" in walletAny) { + // Single transaction with multiple actions + outcome = await walletAny.signAndSendTransaction({ + signerId, + receiverId: DONATION_CONTRACT_ACCOUNT_ID, + actions, + }); + } else if ("signAndSendTransactions" in walletAny) { + // For wallets that only support signAndSendTransactions + const results = await walletAny.signAndSendTransactions({ + transactions: [ + { + receiverId: DONATION_CONTRACT_ACCOUNT_ID, + actions, + }, + ], + }); + + outcome = Array.isArray(results) ? results[0] : results; + } else { + throw new Error("Wallet does not support transaction signing"); + } + + const txHash = outcome?.transaction?.hash || outcome?.transaction_outcome?.id || null; + + // Parse all donations from the outcome + const donations: DirectDonation[] = []; + + if (outcome?.receipts_outcome) { + for (const receipt of outcome.receipts_outcome) { + const successValue = receipt?.outcome?.status?.SuccessValue; + + if (successValue) { + try { + const parsed = JSON.parse(atob(successValue)); + + if (parsed && "recipient_id" in parsed && "donor_id" in parsed) { + donations.push(parsed as DirectDonation); + } + } catch { + // Not valid JSON, skip + } + } + } + } + + // Fallback: try to get last result + if (donations.length === 0) { + try { + const lastResult = providers.getTransactionLastResult(outcome); + + if (lastResult && typeof lastResult === "object" && "recipient_id" in lastResult) { + donations.push(lastResult as DirectDonation); + } + } catch { + // Ignore + } + } + + return { donations, txHash }; +}; + export const storage_deposit = (depositAmountYocto: IndivisibleUnits) => - contractApi.call<{}, IndivisibleUnits>("storage_deposit", { + donationContractApi.call<{}, IndivisibleUnits>("storage_deposit", { deposit: depositAmountYocto, args: {}, gas: "100000000000000", diff --git a/src/common/contracts/core/donation/hooks.ts b/src/common/contracts/core/donation/hooks.ts index 37917402f..0a32350da 100644 --- a/src/common/contracts/core/donation/hooks.ts +++ b/src/common/contracts/core/donation/hooks.ts @@ -6,6 +6,7 @@ import type { ConditionalActivation } from "@/common/types"; import * as contractClient from "./client"; export const useConfig = ({ enabled = true }: ConditionalActivation | undefined = {}) => - useSWR(["get_config"], ([_queryKeyHead]) => - !enabled || !IS_CLIENT ? undefined : contractClient.get_config(), + useSWR( + () => (enabled ? ["get_config"] : null), + ([_queryKeyHead]) => (!IS_CLIENT ? undefined : contractClient.get_config()), ); diff --git a/src/common/contracts/core/donation/index.ts b/src/common/contracts/core/donation/index.ts index 1c14b0c35..b32380be8 100644 --- a/src/common/contracts/core/donation/index.ts +++ b/src/common/contracts/core/donation/index.ts @@ -3,5 +3,6 @@ import * as donationContractHooks from "./hooks"; export type * from "./hooks"; export * from "./interfaces"; +export type { DirectDonateResult, DirectBatchDonateResult } from "./client"; export { donationContractClient, donationContractHooks }; diff --git a/src/common/contracts/core/lists/client.ts b/src/common/contracts/core/lists/client.ts index 3c1c16f6b..2d7dfd4ad 100644 --- a/src/common/contracts/core/lists/client.ts +++ b/src/common/contracts/core/lists/client.ts @@ -1,8 +1,6 @@ -import { MemoryCache } from "@wpdas/naxios"; - import { LISTS_CONTRACT_ACCOUNT_ID } from "@/common/_config"; -import { naxiosInstance } from "@/common/blockchains/near-protocol/client"; -import { PUBLIC_GOODS_REGISTRY_LIST_ID } from "@/common/constants"; +import { contractApi as createContractApi } from "@/common/blockchains/near-protocol/client"; +import { FULL_TGAS, PUBLIC_GOODS_REGISTRY_LIST_ID } from "@/common/constants"; import { floatToYoctoNear } from "@/common/lib"; import { AccountId } from "@/common/types"; @@ -15,11 +13,64 @@ import { UpdateRegistration, } from "./interfaces"; -const contractApi = naxiosInstance.contractApi({ +const contractApi = createContractApi({ contractId: LISTS_CONTRACT_ACCOUNT_ID, - cache: new MemoryCache({ expirationTime: 10 }), }); +export type TxHashResult = { + txHash: string | null; +}; + +const callWithTxHash = async ( + method: string, + args: Record, + deposit?: string, +): Promise => { + const { walletApi } = await import("@/common/blockchains/near-protocol/client"); + const wallet = await walletApi.ensureWallet(); + const signerId = walletApi.accountId; + + if (!signerId) { + throw new Error("Wallet is not signed in."); + } + + const { actionCreators } = await import("@near-js/transactions"); + + const action = actionCreators.functionCall( + method, + args, + BigInt(FULL_TGAS), + BigInt(deposit ?? "0"), + ); + + let outcome: any; + const walletAny = wallet as any; + + if ("signAndSendTransaction" in walletAny) { + outcome = await walletAny.signAndSendTransaction({ + signerId, + receiverId: LISTS_CONTRACT_ACCOUNT_ID, + actions: [action], + }); + } else if ("signAndSendTransactions" in walletAny) { + const results = await walletAny.signAndSendTransactions({ + transactions: [ + { + receiverId: LISTS_CONTRACT_ACCOUNT_ID, + actions: [action], + }, + ], + }); + + outcome = Array.isArray(results) ? results[0] : results; + } else { + throw new Error("Wallet does not support transaction signing"); + } + + const txHash = outcome?.transaction?.hash || outcome?.transaction_outcome?.id || null; + return { txHash }; +}; + export const get_lists = () => contractApi.view<{}, List[]>("get_lists"); export const create_list = ({ @@ -110,19 +161,11 @@ export const update_registered_project = (args: UpdateRegistration) => args, }); -export const delete_list = (args: { list_id: number }) => - contractApi.call("delete_list", { - args, - deposit: floatToYoctoNear(0.01), - gas: "300000000000000", - }); +export const delete_list = (args: { list_id: number }): Promise => + callWithTxHash("delete_list", args, floatToYoctoNear(0.01)); -export const upvote = (args: { list_id: number }) => - contractApi.call("upvote", { - args, - deposit: floatToYoctoNear(0.01), - gas: "300000000000000", - }); +export const upvote = (args: { list_id: number }): Promise => + callWithTxHash("upvote", args, floatToYoctoNear(0.01)); export const add_admins_to_list = (args: { list_id: number; admins: Array }) => contractApi.call("owner_add_admins", { @@ -145,12 +188,8 @@ export const transfer_list_ownership = (args: { list_id: number; new_owner_id: s gas: "300000000000000", }); -export const remove_upvote = (args: { list_id: number }) => - contractApi.call("remove_upvote", { - args, - deposit: floatToYoctoNear(0.01), - gas: "300000000000000", - }); +export const remove_upvote = (args: { list_id: number }): Promise => + callWithTxHash("remove_upvote", args, floatToYoctoNear(0.01)); export const get_list_for_owner = (args: { owner_id: string }) => contractApi.view("get_lists_for_owner", { args }); diff --git a/src/common/contracts/core/lists/hooks.ts b/src/common/contracts/core/lists/hooks.ts index 95311beca..61f1df7ed 100644 --- a/src/common/contracts/core/lists/hooks.ts +++ b/src/common/contracts/core/lists/hooks.ts @@ -1,13 +1,21 @@ import useSWR from "swr"; import { IS_CLIENT } from "@/common/constants"; -import type { ByAccountId, ByListId, ConditionalActivation } from "@/common/types"; +import type { + ByAccountId, + ByListId, + ConditionalActivation, + LiveUpdateParams, +} from "@/common/types"; import * as contractClient from "./client"; export const useList = ({ enabled = true, listId }: ByListId & ConditionalActivation) => - useSWR(["useList", listId], ([_queryKeyHead, listIdKey]) => - !enabled || !IS_CLIENT ? undefined : contractClient.get_list({ list_id: listIdKey }), + useSWR( + () => (enabled ? ["useList", listId] : null), + + ([_queryKeyHead, listIdKey]) => + !IS_CLIENT ? undefined : contractClient.get_list({ list_id: listIdKey }), ); export const useIsRegistered = ({ @@ -20,9 +28,10 @@ export const useIsRegistered = ({ Pick & ConditionalActivation) => useSWR( - ["useIsRegistered", accountId, listId, params], + () => (enabled ? ["useIsRegistered", accountId, listId, params] : null), + ([_queryKeyHead, accountIdKey, listIdKey, paramsKey]) => - !enabled || !IS_CLIENT + !IS_CLIENT ? undefined : contractClient.is_registered({ account_id: accountIdKey, @@ -38,19 +47,34 @@ export const useRegistrations = ({ }: ByListId & Omit & ConditionalActivation) => - useSWR(["useRegistrations", listId, params], ([_queryKeyHead, listIdKey, paramsKey]) => - !enabled || !IS_CLIENT - ? undefined - : contractClient.get_registrations_for_list({ list_id: listIdKey, ...paramsKey }), + useSWR( + () => (enabled ? ["useRegistrations", listId, params] : null), + + ([_queryKeyHead, listIdKey, paramsKey]) => + !IS_CLIENT + ? undefined + : contractClient.get_registrations_for_list({ list_id: listIdKey, ...paramsKey }), ); export const useRegistration = ({ enabled = true, + live = false, accountId, listId, -}: ByAccountId & ByListId & ConditionalActivation) => - useSWR(["useRegistration", accountId, listId], ([_queryKeyHead, accountIdKey, listIdKey]) => - !enabled || !IS_CLIENT - ? undefined - : contractClient.getRegistration({ registrant_id: accountIdKey, list_id: listIdKey }), +}: ByAccountId & ByListId & ConditionalActivation & LiveUpdateParams) => + useSWR( + () => (enabled ? ["useRegistration", accountId, listId] : null), + + ([_queryKeyHead, accountIdKey, listIdKey]) => + !IS_CLIENT + ? undefined + : contractClient.getRegistration({ registrant_id: accountIdKey, list_id: listIdKey }), + + live + ? {} + : { + revalidateIfStale: false, + revalidateOnFocus: false, + revalidateOnReconnect: false, + }, ); diff --git a/src/common/contracts/core/lists/interfaces.ts b/src/common/contracts/core/lists/interfaces.ts index 07150516b..1ed5bd118 100644 --- a/src/common/contracts/core/lists/interfaces.ts +++ b/src/common/contracts/core/lists/interfaces.ts @@ -1,10 +1,9 @@ export enum RegistrationStatus { + Pending = "Pending", Approved = "Approved", Rejected = "Rejected", - Pending = "Pending", Graylisted = "Graylisted", Blacklisted = "Blacklisted", - Unregistered = "Unregistered", } export type ListId = number; @@ -40,20 +39,20 @@ export type GetListArgs = { list_id: ListId; }; -export interface ApplyToList { - list_id: string; +export type ApplyToList = { + list_id: ListId; notes?: null | string; - registrations: Array<{ + registrations: { registrant_id: string; status: string; submitted_ms: number; updated_ms: number; notes: string; - }>; -} + }[]; +} & Record; -export interface UpdateRegistration { +export type UpdateRegistration = { registration_id: number; status: RegistrationStatus; notes?: string; -} +} & Record; diff --git a/src/common/contracts/core/pot-factory/client.ts b/src/common/contracts/core/pot-factory/client.ts index 169672c91..35b58ef0b 100644 --- a/src/common/contracts/core/pot-factory/client.ts +++ b/src/common/contracts/core/pot-factory/client.ts @@ -1,23 +1,21 @@ -import { MemoryCache } from "@wpdas/naxios"; import { Big } from "big.js"; import { POT_FACTORY_CONTRACT_ACCOUNT_ID } from "@/common/_config"; -import { naxiosInstance } from "@/common/blockchains/near-protocol/client"; +import { contractApi } from "@/common/blockchains/near-protocol/client"; import { FULL_TGAS } from "@/common/constants"; import { PotArgs, PotDeploymentResult, PotFactoryConfig } from "./interfaces"; -const contractApi = naxiosInstance.contractApi({ +const potFactoryContractApi = contractApi({ contractId: POT_FACTORY_CONTRACT_ACCOUNT_ID, - cache: new MemoryCache({ expirationTime: 5 }), // 10 seg }); -export const get_config = () => contractApi.view<{}, PotFactoryConfig>("get_config"); +export const get_config = () => potFactoryContractApi.view<{}, PotFactoryConfig>("get_config"); export const calculate_min_deployment_deposit = (args: { args: PotArgs; }): Promise => - contractApi + potFactoryContractApi .view("calculate_min_deployment_deposit", { args }) .then((amount) => Big(amount).plus(Big("20000000000000000000000")).toFixed()) .catch((error) => { @@ -29,7 +27,7 @@ export const deploy_pot = async (args: { pot_args: PotArgs; pot_handle?: null | string; }): Promise => - contractApi.call("deploy_pot", { + potFactoryContractApi.call("deploy_pot", { args, deposit: await calculate_min_deployment_deposit({ args: args.pot_args }), gas: FULL_TGAS, diff --git a/src/common/contracts/core/pot-factory/hooks.ts b/src/common/contracts/core/pot-factory/hooks.ts index 7204530e5..eb8ac8bbc 100644 --- a/src/common/contracts/core/pot-factory/hooks.ts +++ b/src/common/contracts/core/pot-factory/hooks.ts @@ -6,6 +6,7 @@ import type { ConditionalActivation } from "@/common/types"; import * as contractClient from "./client"; export const useConfig = ({ enabled = true }: ConditionalActivation | undefined = {}) => - useSWR(["useConfig"], ([_queryKeyHead]) => - !enabled || !IS_CLIENT ? undefined : contractClient.get_config(), + useSWR( + () => (enabled ? ["useConfig"] : null), + ([_queryKeyHead]) => (!IS_CLIENT ? undefined : contractClient.get_config()), ); diff --git a/src/common/contracts/core/pot/client.ts b/src/common/contracts/core/pot/client.ts index 866f8f4e9..fd9eb81aa 100644 --- a/src/common/contracts/core/pot/client.ts +++ b/src/common/contracts/core/pot/client.ts @@ -20,7 +20,7 @@ import { } from "./interfaces"; export const contractApi = (potId: string) => - nearProtocolClient.naxiosInstance.contractApi({ + nearProtocolClient.contractApi({ contractId: potId, }); diff --git a/src/common/contracts/core/pot/hooks.ts b/src/common/contracts/core/pot/hooks.ts index 1ced4f127..828b4f506 100644 --- a/src/common/contracts/core/pot/hooks.ts +++ b/src/common/contracts/core/pot/hooks.ts @@ -7,21 +7,33 @@ import type { ConditionalActivation } from "@/common/types"; import * as contractClient from "./client"; export const useConfig = ({ enabled = true, potId }: ByPotId & ConditionalActivation) => - useSWR(["useConfig", potId], ([_queryKeyHead, potIdKey]) => - !enabled || !IS_CLIENT ? undefined : contractClient.get_config({ potId: potIdKey }), + useSWR( + () => (enabled ? ["useConfig", potId] : null), + + ([_queryKeyHead, potIdKey]) => + !IS_CLIENT ? undefined : contractClient.get_config({ potId: potIdKey }), ); export const useApplications = ({ enabled = true, potId }: ByPotId & ConditionalActivation) => - useSWR(["useApplications", potId], ([_queryKeyHead, potIdKey]) => - !enabled || !IS_CLIENT ? undefined : contractClient.get_applications({ potId: potIdKey }), + useSWR( + () => (enabled ? ["useApplications", potId] : null), + + ([_queryKeyHead, potIdKey]) => + !IS_CLIENT ? undefined : contractClient.get_applications({ potId: potIdKey }), ); export const usePayouts = ({ enabled = true, potId }: ByPotId & ConditionalActivation) => - useSWR(["usePayouts", potId], ([_queryKeyHead, potIdKey]) => - !enabled || !IS_CLIENT ? undefined : contractClient.get_payouts({ potId: potIdKey }), + useSWR( + () => (enabled ? ["usePayouts", potId] : null), + + ([_queryKeyHead, potIdKey]) => + !IS_CLIENT ? undefined : contractClient.get_payouts({ potId: potIdKey }), ); export const usePayoutChallenges = ({ enabled = true, potId }: ByPotId & ConditionalActivation) => - useSWR(["usePayoutChallenges", potId], ([_queryKeyHead, potIdKey]) => - !enabled || !IS_CLIENT ? undefined : contractClient.get_payouts_challenges({ potId: potIdKey }), + useSWR( + () => (enabled ? ["usePayoutChallenges", potId] : null), + + ([_queryKeyHead, potIdKey]) => + !IS_CLIENT ? undefined : contractClient.get_payouts_challenges({ potId: potIdKey }), ); diff --git a/src/common/contracts/core/sybil-resistance/client.ts b/src/common/contracts/core/sybil-resistance/client.ts index f358c0a95..afdab3ee8 100644 --- a/src/common/contracts/core/sybil-resistance/client.ts +++ b/src/common/contracts/core/sybil-resistance/client.ts @@ -1,5 +1,3 @@ -import { Provider } from "near-api-js/lib/providers"; - import { SYBIL_CONTRACT_ACCOUNT_ID } from "@/common/_config"; import { nearProtocolClient } from "@/common/blockchains/near-protocol"; import { FULL_TGAS, ONE_HUNDREDTH_NEAR, TWO_HUNDREDTHS_NEAR } from "@/common/constants"; @@ -14,6 +12,7 @@ import { type GetStampsForAccountIdInput, type GetUsersForStampInput, type HumanScoreResponse, + type Provider, ProviderExternal, RegisterProviderInput, type StampExternal, @@ -21,7 +20,7 @@ import { UpdateProviderInput, } from "./interfaces"; -const contractApi = nearProtocolClient.naxiosInstance.contractApi({ +const contractApi = nearProtocolClient.contractApi({ contractId: SYBIL_CONTRACT_ACCOUNT_ID, }); @@ -45,8 +44,8 @@ export const is_human = (args: GetHumanScoreInput): Promise => * Anyone can call this method to register a provider. If caller is admin, provider is automatically activated. */ export const register_provider = (args: RegisterProviderInput) => - contractApi.call("register_provider", { - args, + contractApi.call, ProviderExternal>("register_provider", { + args: args as unknown as Record, gas: FULL_TGAS, deposit: ONE_HUNDREDTH_NEAR, }); @@ -56,8 +55,8 @@ export type AdminSetDefaultHumanThresholdArgs = { }; export const admin_set_default_human_threshold = (args: AdminSetDefaultHumanThresholdArgs) => - contractApi.call("admin_set_default_human_threshold", { - args, + contractApi.call, void>("admin_set_default_human_threshold", { + args: args as unknown as Record, deposit: ONE_HUNDREDTH_NEAR, }); @@ -84,25 +83,29 @@ export const add_stamp = (providerId: string) => * @returns */ export const update_provider = (args: UpdateProviderInput) => - contractApi.call("update_provider", { - args, + contractApi.call, ProviderExternal>("update_provider", { + args: args as unknown as Record, deposit: ONE_HUNDREDTH_NEAR, }); export const admin_activate_provider = (args: ActivateProviderInput) => - contractApi.call("admin_activate_provider", { - args, + contractApi.call, Provider>("admin_activate_provider", { + args: args as unknown as Record, deposit: ONE_HUNDREDTH_NEAR, }); export const admin_deactivate_provider = (args: DeactivateProviderInput) => - contractApi.call("admin_deactivate_provider", { - args, + contractApi.call, Provider>("admin_deactivate_provider", { + args: args as unknown as Record, deposit: ONE_HUNDREDTH_NEAR, }); export const admin_flag_provider = (args: FlagProviderInput) => - contractApi.call("admin_flag_provider", { args }); + contractApi.call, Provider>("admin_flag_provider", { + args: args as unknown as Record, + }); export const admin_unflag_provider = (args: UnflagProviderInput) => - contractApi.call("admin_unflag_provider", { args }); + contractApi.call, Provider>("admin_unflag_provider", { + args: args as unknown as Record, + }); diff --git a/src/common/contracts/core/sybil-resistance/hooks.ts b/src/common/contracts/core/sybil-resistance/hooks.ts index d1f8be376..b9db189fa 100644 --- a/src/common/contracts/core/sybil-resistance/hooks.ts +++ b/src/common/contracts/core/sybil-resistance/hooks.ts @@ -6,6 +6,15 @@ import type { ByAccountId, ConditionalActivation } from "@/common/types"; import * as contractClient from "./client"; export const useIsHuman = ({ enabled = true, accountId }: ByAccountId & ConditionalActivation) => - useSWR(["useIsHuman", accountId], ([_queryKeyHead, accountIdKey]) => - !enabled || !IS_CLIENT ? undefined : contractClient.is_human({ account_id: accountIdKey }), + useSWR( + () => (enabled ? ["useIsHuman", accountId] : null), + + ([_queryKeyHead, accountIdKey]) => + !IS_CLIENT ? undefined : contractClient.is_human({ account_id: accountIdKey }), + + { + revalidateIfStale: false, + revalidateOnFocus: false, + revalidateOnReconnect: false, + }, ); diff --git a/src/common/contracts/core/voting/client.ts b/src/common/contracts/core/voting/client.ts index 0b24b2b7b..595a59f97 100644 --- a/src/common/contracts/core/voting/client.ts +++ b/src/common/contracts/core/voting/client.ts @@ -1,6 +1,4 @@ -import naxios, { MemoryCache } from "@wpdas/naxios"; - -import { naxiosInstance } from "@/common/blockchains/near-protocol/client"; +import { contractApi } from "@/common/blockchains/near-protocol/client"; import type { AccountId, @@ -21,18 +19,14 @@ import type { * Provides methods to create and manage elections, cast votes, and query election data */ class VotingClient implements Omit { - private contract: ReturnType; + private contract: ReturnType; /** * Creates a new VotingClient instance * @param contractId The NEAR account ID of the deployed voting contract - * @param network The NEAR network to connect to (mainnet, testnet, etc.) */ - constructor(naxiosInstance: naxios, contractId: string) { - this.contract = naxiosInstance.contractApi({ - contractId, - cache: new MemoryCache({ expirationTime: 60 }), - }); + constructor(contractId: string) { + this.contract = contractApi({ contractId }); } // View Methods @@ -277,4 +271,4 @@ class VotingClient implements Omit { * @param network The NEAR network to connect to */ export const createVotingClient = (contractId: string): VotingClient => - new VotingClient(naxiosInstance, contractId); + new VotingClient(contractId); diff --git a/src/common/contracts/core/voting/hooks.ts b/src/common/contracts/core/voting/hooks.ts index 3c42d6230..004349af8 100644 --- a/src/common/contracts/core/voting/hooks.ts +++ b/src/common/contracts/core/voting/hooks.ts @@ -14,39 +14,39 @@ export interface ByElectionId { type BasicElectionQueryKey = ByElectionId & ConditionalActivation; export const useElections = ({ enabled = true }: ConditionalActivation | undefined = {}) => - useSWR(["get_elections"], () => - !enabled || !IS_CLIENT ? undefined : votingContractClient.get_elections({}), + useSWR( + () => (enabled ? ["get_elections"] : null), + () => (!IS_CLIENT ? undefined : votingContractClient.get_elections({})), ); export const useActiveElections = ({ enabled = true }: ConditionalActivation | undefined = {}) => - useSWR(["get_active_elections"], () => - !enabled || !IS_CLIENT ? undefined : votingContractClient.get_active_elections(), + useSWR( + () => (enabled ? ["get_active_elections"] : null), + () => (!IS_CLIENT ? undefined : votingContractClient.get_active_elections()), ); export const useElection = ({ enabled = true, electionId }: BasicElectionQueryKey) => useSWR( - ["get_election", electionId], + () => (enabled ? ["get_election", electionId] : null), ([_queryKeyHead, election_id]: [string, ElectionId]) => - !enabled || !IS_CLIENT ? undefined : votingContractClient.get_election({ election_id }), + !IS_CLIENT ? undefined : votingContractClient.get_election({ election_id }), ); export const useIsVotingPeriod = ({ enabled = true, electionId }: BasicElectionQueryKey) => useSWR( - ["is_voting_period", electionId], + () => (enabled ? ["is_voting_period", electionId] : null), ([_queryKeyHead, election_id]: [string, ElectionId]) => - !enabled || !IS_CLIENT ? undefined : votingContractClient.is_voting_period({ election_id }), + !IS_CLIENT ? undefined : votingContractClient.is_voting_period({ election_id }), ); export const useElectionCandidates = ({ enabled = true, electionId }: BasicElectionQueryKey) => useSWR( - ["get_election_candidates", electionId], + () => (enabled ? ["get_election_candidates", electionId] : null), ([_queryKeyHead, election_id]: [string, ElectionId]) => - !enabled || !IS_CLIENT - ? undefined - : votingContractClient.get_election_candidates({ election_id }), + !IS_CLIENT ? undefined : votingContractClient.get_election_candidates({ election_id }), ); export const useElectionCandidateVotes = ({ @@ -55,29 +55,28 @@ export const useElectionCandidateVotes = ({ accountId, }: BasicElectionQueryKey & ByAccountId) => useSWR( - ["get_candidate_votes", electionId, accountId], + () => (enabled ? ["get_candidate_votes", electionId, accountId] : null), ([_queryKeyHead, election_id, candidate_id]: [string, ElectionId, AccountId]) => - !enabled || !IS_CLIENT + !IS_CLIENT ? undefined : votingContractClient.get_candidate_votes({ election_id, candidate_id }), ); export const useElectionVotes = ({ enabled = true, electionId }: BasicElectionQueryKey) => useSWR( - ["get_election_votes", electionId], + () => (enabled ? ["get_election_votes", electionId] : null), + ([_queryKeyHead, election_id]: [string, ElectionId]) => - !enabled || !IS_CLIENT ? undefined : votingContractClient.get_election_votes({ election_id }), + !IS_CLIENT ? undefined : votingContractClient.get_election_votes({ election_id }), ); export const useElectionVoteCount = ({ enabled = true, electionId }: BasicElectionQueryKey) => useSWR( - ["get_election_vote_count", electionId], + () => (enabled ? ["get_election_vote_count", electionId] : null), ([_queryKeyHead, election_id]: [string, ElectionId]) => - !enabled || !IS_CLIENT - ? undefined - : votingContractClient.get_election_vote_count({ election_id }), + !IS_CLIENT ? undefined : votingContractClient.get_election_vote_count({ election_id }), ); export const useVotingRoundVoterVotes = ({ @@ -86,12 +85,10 @@ export const useVotingRoundVoterVotes = ({ accountId, }: BasicElectionQueryKey & ByAccountId) => useSWR( - ["get_voter_votes", electionId, accountId], + () => (enabled ? ["get_voter_votes", electionId, accountId] : null), ([_queryKeyHead, election_id, voter]: [string, ElectionId, AccountId]) => - !enabled || !IS_CLIENT - ? undefined - : votingContractClient.get_voter_votes({ election_id, voter }), + !IS_CLIENT ? undefined : votingContractClient.get_voter_votes({ election_id, voter }), ); export const useVoterRemainingCapacity = ({ @@ -100,17 +97,20 @@ export const useVoterRemainingCapacity = ({ accountId, }: BasicElectionQueryKey & ByAccountId) => useSWR( - ["get_voter_remaining_capacity", electionId, accountId], + () => (enabled ? ["get_voter_remaining_capacity", electionId, accountId] : null), ([_queryKeyHead, election_id, voter]: [string, ElectionId, AccountId]) => - !enabled || !IS_CLIENT + !IS_CLIENT ? undefined : votingContractClient.get_voter_remaining_capacity({ election_id, voter }), ); export const useUniqueVoters = ({ enabled = true, electionId }: BasicElectionQueryKey) => - useSWR(["get_unique_voters", electionId], ([_queryKeyHead, election_id]: [string, ElectionId]) => - !enabled || !IS_CLIENT ? undefined : votingContractClient.get_unique_voters({ election_id }), + useSWR( + () => (enabled ? ["get_unique_voters", electionId] : null), + + ([_queryKeyHead, election_id]: [string, ElectionId]) => + !IS_CLIENT ? undefined : votingContractClient.get_unique_voters({ election_id }), ); export const usePotElections = ({ enabled = true, potId }: ByPotId & ConditionalActivation) => { diff --git a/src/common/contracts/metapool/liquid-staking/client.ts b/src/common/contracts/metapool/liquid-staking/client.ts index 924751374..290f4483b 100644 --- a/src/common/contracts/metapool/liquid-staking/client.ts +++ b/src/common/contracts/metapool/liquid-staking/client.ts @@ -1,12 +1,10 @@ -import { MemoryCache } from "@wpdas/naxios"; - import { METAPOOL_LIQUID_STAKING_CONTRACT_ACCOUNT_ID } from "@/common/_config"; -import { naxiosInstance } from "@/common/blockchains/near-protocol/client"; +import { contractApi } from "@/common/blockchains/near-protocol/client"; import { IndivisibleUnits } from "@/common/types"; -export const contractApi = naxiosInstance.contractApi({ +export const liquidStakingContractApi = contractApi({ contractId: METAPOOL_LIQUID_STAKING_CONTRACT_ACCOUNT_ID, - cache: new MemoryCache({ expirationTime: 600 }), }); -export const get_stnear_price = () => contractApi.view<{}, IndivisibleUnits>("get_stnear_price"); +export const get_stnear_price = () => + liquidStakingContractApi.view<{}, IndivisibleUnits>("get_stnear_price"); diff --git a/src/common/contracts/ref-finance/ref-exchange/client.ts b/src/common/contracts/ref-finance/ref-exchange/client.ts index fdb0c1b77..2e3df6e24 100644 --- a/src/common/contracts/ref-finance/ref-exchange/client.ts +++ b/src/common/contracts/ref-finance/ref-exchange/client.ts @@ -1,13 +1,10 @@ -import { MemoryCache } from "@wpdas/naxios"; - import { REF_EXCHANGE_CONTRACT_ACCOUNT_ID } from "@/common/_config"; -import { naxiosInstance } from "@/common/blockchains/near-protocol/client"; +import { contractApi } from "@/common/blockchains/near-protocol/client"; import type { AccountId } from "@/common/types"; -const contractApi = naxiosInstance.contractApi({ +const refExchangeContractApi = contractApi({ contractId: REF_EXCHANGE_CONTRACT_ACCOUNT_ID, - cache: new MemoryCache({ expirationTime: 600 }), }); export const get_whitelisted_tokens = () => - contractApi.view<{}, AccountId[]>("get_whitelisted_tokens"); + refExchangeContractApi.view<{}, AccountId[]>("get_whitelisted_tokens"); diff --git a/src/common/contracts/ref-finance/ref-exchange/hooks.ts b/src/common/contracts/ref-finance/ref-exchange/hooks.ts index f45ff8012..f7cf73428 100644 --- a/src/common/contracts/ref-finance/ref-exchange/hooks.ts +++ b/src/common/contracts/ref-finance/ref-exchange/hooks.ts @@ -7,7 +7,7 @@ import * as client from "./client"; export const useWhitelistedTokens = ({ enabled = true }: ConditionalActivation | undefined = {}) => useSWR( - ["get_whitelisted_tokens"], - () => (!enabled || !IS_CLIENT ? undefined : client.get_whitelisted_tokens()), + () => (enabled ? ["get_whitelisted_tokens"] : null), + () => (!IS_CLIENT ? undefined : client.get_whitelisted_tokens()), CONTRACT_SWR_CONFIG, ); diff --git a/src/common/contracts/social-db/client.ts b/src/common/contracts/social-db/client.ts index b58427b79..de769e608 100644 --- a/src/common/contracts/social-db/client.ts +++ b/src/common/contracts/social-db/client.ts @@ -1,13 +1,11 @@ -import { buildTransaction } from "@wpdas/naxios"; - import { SOCIAL_DB_CONTRACT_ACCOUNT_ID } from "@/common/_config"; -import { naxiosInstance } from "@/common/blockchains/near-protocol/client"; +import { contractApi } from "@/common/blockchains/near-protocol/client"; import { AccountId } from "@/common/types"; /** * NEAR Social DB Contract API */ -const nearSocialDbContractApi = naxiosInstance.contractApi({ +const nearSocialDbContractApi = contractApi({ contractId: SOCIAL_DB_CONTRACT_ACCOUNT_ID, }); @@ -107,20 +105,16 @@ type NEARSocialGetResponse = { * Get User Profile Info from NEAR Social DB * @returns */ -export const getSocialProfile = async (input: { accountId: string; useCache?: boolean }) => { +export const getSocialProfile = async (input: { accountId: string }) => { try { const response = await nearSocialDbContractApi.view< NEARSocialUserProfileInput, NEARSocialGetResponse - >( - "get", - { - args: { - keys: [`${input.accountId}/profile/**`], - }, + >("get", { + args: { + keys: [`${input.accountId}/profile/**`], }, - { useCache: input.useCache }, - ); + }); return response[input.accountId]?.profile || null; } catch (e) { @@ -135,7 +129,7 @@ export const getAccount = async (input: { accountId: string }) => { { node_id: number; permissions: {}[]; - shared_storage: any; + shared_storage: Record; storage_balance: string; used_bytes: number; } | null @@ -146,37 +140,7 @@ export const getAccount = async (input: { accountId: string }) => { return response; }; -export const getSocialData = async ({ path }: { path: string }) => { - try { - const response = await nearSocialDbContractApi.view("keys", { - args: { - keys: [path], - options: { - return_type: "BlockHeight", - values_only: true, - }, - }, - }); - - return response; - } catch (e) { - console.error("getSocialData:", e); - } -}; - -export const getPolicy = async () => { - try { - const response = await nearSocialDbContractApi.view( - "get_policy", - ); - - return response; - } catch (e) { - console.error("getPolicy:", e); - } -}; - -export const setSocialData = async ({ data }: { data: Record }) => { +export const setSocialData = async ({ data }: { data: Record }) => { try { const response = await nearSocialDbContractApi.call("set", { args: { @@ -198,8 +162,9 @@ export const createPost = async ({ content: { type: string; text: string }; }) => { try { - const buildContract = buildTransaction("set", { + const buildContract = { receiverId: SOCIAL_DB_CONTRACT_ACCOUNT_ID, + method: "set", args: { data: { [accountId]: { @@ -217,13 +182,12 @@ export const createPost = async ({ }, }, }, - }); + }; - await naxiosInstance - .contractApi() + await contractApi() .callMultiple([buildContract]) .then((data) => { - console.log(data); + console.info(data); }) .catch((error) => { console.error(error); diff --git a/src/common/contracts/social-db/hooks.ts b/src/common/contracts/social-db/hooks.ts index d85d053d9..eafeb792e 100644 --- a/src/common/contracts/social-db/hooks.ts +++ b/src/common/contracts/social-db/hooks.ts @@ -11,10 +11,10 @@ export const useSocialProfile = ({ accountId, }: ByAccountId & ConditionalActivation & LiveUpdateParams) => useSWR( - ["useSocialProfile", accountId], + () => (enabled ? ["useSocialProfile", accountId] : null), ([_queryKeyHead, account_id]) => - !enabled || !IS_CLIENT + !IS_CLIENT ? undefined : contractClient .getSocialProfile({ accountId: account_id }) diff --git a/src/common/contracts/sputnik-dao/index.ts b/src/common/contracts/sputnik-dao/index.ts deleted file mode 100644 index 505b8003f..000000000 --- a/src/common/contracts/sputnik-dao/index.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { naxiosInstance } from "@/common/blockchains/near-protocol/client"; - -export const getDaoPolicy = async (accountId: string) => { - try { - const resp = await naxiosInstance - .contractApi({ - contractId: accountId, - }) - .view("get_policy"); - - return resp; - } catch (_) { - return null; - } -}; diff --git a/src/common/contracts/sputnikdao2/client.ts b/src/common/contracts/sputnikdao2/client.ts new file mode 100644 index 000000000..e6c8f122e --- /dev/null +++ b/src/common/contracts/sputnikdao2/client.ts @@ -0,0 +1,20 @@ +import { nearProtocolClient } from "@/common/blockchains/near-protocol"; +import type { ByAccountId } from "@/common/types"; + +import type { Policy, ProposalOutput } from "./types"; + +export const get_policy = ({ accountId }: ByAccountId) => + nearProtocolClient.contractApi({ contractId: accountId }).view<{}, Policy>("get_policy"); + +export type GetProposalsArgs = { + from_index: number; + limit: number; +}; + +/** + * Returns proposals in paginated view. + */ +export const get_proposals = ({ accountId, args }: ByAccountId & { args: GetProposalsArgs }) => + nearProtocolClient + .contractApi({ contractId: accountId }) + .view("get_proposals", { args }); diff --git a/src/common/contracts/sputnikdao2/hooks.ts b/src/common/contracts/sputnikdao2/hooks.ts new file mode 100644 index 000000000..b330ee165 --- /dev/null +++ b/src/common/contracts/sputnikdao2/hooks.ts @@ -0,0 +1,107 @@ +import { useMemo } from "react"; + +import { pick } from "remeda"; +import useSWR from "swr"; + +import { IS_CLIENT } from "@/common/constants"; +import type { ByAccountId, ConditionalActivation } from "@/common/types"; + +import * as contractClient from "./client"; +import type { ProposalStatus } from "./types"; + +export const useProposals = ({ + enabled = true, + accountId, + ...params +}: ConditionalActivation & ByAccountId & contractClient.GetProposalsArgs) => + useSWR( + () => (enabled ? ["get_proposals", accountId, params] : null), + + ([_queryKeyHead, accountIdKey, paramsKey]) => + !IS_CLIENT + ? undefined + : contractClient.get_proposals({ + accountId: accountIdKey, + args: pick(paramsKey, ["from_index", "limit"]), + }), + ); + +type DaoProposalLookupParams = ByAccountId & + ConditionalActivation & { + fromIndex: number; + limit: number; + initialSearchTerm?: string; + kind?: "Vote" | "FunctionCall"; + status?: ProposalStatus; + receiverAccountIds?: string[]; + methodNames?: string[]; + }; + +export const useProposalLookup = ({ + enabled = true, + accountId, + fromIndex, + limit, + initialSearchTerm = "", + ...params +}: DaoProposalLookupParams) => { + const { + isLoading, + data, + error, + mutate: refetchData, + } = useProposals({ + enabled, + accountId, + from_index: fromIndex, + limit, + }); + + const results = useMemo(() => { + const filtered = data?.filter(({ kind, status }) => { + if (params.status === undefined || status === params.status) { + if (params.kind === undefined || (typeof kind === "string" && kind === params.kind)) { + return true; + } else if (typeof kind === "object" && params.kind in kind) { + switch (params.kind) { + case "FunctionCall": { + const { receiver_id, actions } = kind.FunctionCall; + + return ( + (params.receiverAccountIds === undefined || + params.receiverAccountIds.includes(receiver_id)) && + (params.methodNames === undefined || + (Array.isArray(params.methodNames) && + actions.some(({ method_name }) => + (params.methodNames as string[]).includes(method_name), + ))) + ); + } + + default: { + return true; + } + } + } else return false; + } else return false; + }); + + return filtered?.filter(({ description }) => + description.toLowerCase().includes(initialSearchTerm.toLowerCase()), + ); + }, [ + data, + initialSearchTerm, + params.kind, + params.methodNames, + params.receiverAccountIds, + params.status, + ]); + + return { + isProposalListLoading: isLoading, + proposals: results, + proposalsError: error, + refetchProposals: refetchData, + }; +}; diff --git a/src/common/contracts/sputnikdao2/index.ts b/src/common/contracts/sputnikdao2/index.ts new file mode 100644 index 000000000..d42bbf7e7 --- /dev/null +++ b/src/common/contracts/sputnikdao2/index.ts @@ -0,0 +1,13 @@ +import * as sputnikDaoClient from "./client"; +import * as sputnikDaoHooks from "./hooks"; +import * as sputnikDaoQueries from "./queries"; +import type { ProposalId } from "./types"; + +export type * from "./hooks"; +export * from "./types"; + +export { sputnikDaoClient, sputnikDaoHooks, sputnikDaoQueries }; + +export interface ByProposalId { + proposalId: ProposalId; +} diff --git a/src/common/contracts/sputnikdao2/queries.ts b/src/common/contracts/sputnikdao2/queries.ts new file mode 100644 index 000000000..353484cb5 --- /dev/null +++ b/src/common/contracts/sputnikdao2/queries.ts @@ -0,0 +1,48 @@ +import type { AccountId } from "@/common/types"; + +import { get_policy } from "./client"; + +export type GetPermissionsInputs = { + daoAccountId: AccountId; + accountId: AccountId; +}; + +export type GetPermissionsResult = { + canSubmitProposals: boolean; +}; + +export const getPermissions = ({ + daoAccountId, + accountId, +}: GetPermissionsInputs): Promise => + get_policy({ accountId: daoAccountId }).then((policy) => { + const roles = policy.roles.filter((role) => { + switch (role.kind) { + case "Everyone": { + return true; + } + + default: { + if ("Group" in role.kind) { + return role.kind.Group.includes(accountId); + } else if ("Member" in role.kind) { + return role.kind.Member.includes(accountId); + } else return false; + } + } + }); + + return { + canSubmitProposals: roles.some(({ permissions }) => { + const kind = "call"; + const action = "AddProposal"; + + return ( + permissions.includes(`${kind}:${action}`) || + permissions.includes(`${kind}:*`) || + permissions.includes(`*:${action}`) || + permissions.includes("*:*") + ); + }), + }; + }); diff --git a/src/common/contracts/sputnikdao2/types.ts b/src/common/contracts/sputnikdao2/types.ts new file mode 100644 index 000000000..8abd71e55 --- /dev/null +++ b/src/common/contracts/sputnikdao2/types.ts @@ -0,0 +1,188 @@ +import type { AccountId, IndivisibleUnits } from "@/common/types"; + +type Weight = { + Weight: IndivisibleUnits; +}; + +type Ratio = { + Ratio: [number, number]; +}; + +export type WeightOrRatio = Weight | Ratio; + +/** + * How the voting policy votes get weigthed. + */ +export enum WeightKind { + /** + * Using token amounts and total delegated at the moment. + */ + TokenWeight = "TokenWeight", + + /** + * Weight of the group role. Roles that don't have scoped group are not supported. + */ + RoleWeight = "RoleWeight", +} + +export type VotePolicy = { + weight_kind: WeightKind; + quorum: IndivisibleUnits; + threshold: WeightOrRatio; +}; + +type MemberRole = { + Member: IndivisibleUnits; +}; + +type GroupRole = { + Group: AccountId[]; +}; + +export type RoleKind = "Everyone" | MemberRole | GroupRole; + +export type RolePermission = { + name: string; + kind: RoleKind; + permissions: string[]; + vote_policy: Record; +}; + +export type Policy = { + /// List of roles and permissions for them in the current policy. + roles: RolePermission[]; + + /** + * Default vote policy. Used when given proposal kind doesn't have special policy. + */ + default_vote_policy: VotePolicy; + + /** + * Proposal bond. + */ + proposal_bond: IndivisibleUnits; + + /** + * Expiration period for proposals. + */ + proposal_period: number; + + /** + * Bond for claiming a bounty. + */ + bounty_bond: IndivisibleUnits; + + /** + * Period in which giving up on bounty is not punished. + */ + bounty_forgiveness_period: number; +}; + +export type ActionCall = { + method_name: string; + + /** + * Base64 encoded JSON args + */ + args: string; + + deposit: IndivisibleUnits; + gas: number; +}; + +export enum ProposalStatus { + InProgress = "InProgress", + + /** + * If quorum voted yes, this proposal is successfully approved. + */ + Approved = "Approved", + + /** + * If quorum voted no, this proposal is rejected. Bond is returned. + */ + Rejected = "Rejected", + + /** + * If quorum voted to remove (e.g. spam), this proposal is rejected and bond is not returned. + * Interfaces shouldn't show removed proposals. + */ + Removed = "Removed", + + /** + * Expired after period of time. + */ + Expired = "Expired", + + /** + * If proposal was moved to Hub or somewhere else. + */ + Moved = "Moved", + + /** + * If proposal has failed when finalizing. + * Allowed to re-finalize again to either expire or approved. + */ + Failed = "Failed", +} + +export type ProposalLog = { + block_height: number; +}; + +export type FunctionCallProposal = { + receiver_id: AccountId; + actions: ActionCall[]; +}; + +/** + * Proposal kind. + * + * Note that this is a bare minimum client binding for the original SputnikDAO `ProposalKind` enum, + * covering only a narrow set of use cases of the app. + */ +export type ProposalKind = "Vote" | { FunctionCall: FunctionCallProposal }; + +export enum Vote { + Approve = 0, + Reject = 1, + Remove = 2, +} + +export type Proposal = { + proposer: AccountId; + description: string; + + /** + * Kind of proposal with relevant information. + */ + kind: ProposalKind; + + /** + * Current status of the proposal. + */ + status: ProposalStatus; + + /** + * Count of votes per role per decision: yes / no / spam. + */ + vote_counts: Record; + + /** + * Map of who voted and how. + */ + votes: Record; + + /** + * Submission time (for voting period). + */ + submission_time: number; + + last_actions_log: ProposalLog[]; +}; + +export type ProposalId = number; + +export type ProposalOutput = Proposal & { + id: number; +}; diff --git a/src/common/contracts/tokens/fungible/client.ts b/src/common/contracts/tokens/fungible/client.ts index d1d2d5abf..f3f386697 100644 --- a/src/common/contracts/tokens/fungible/client.ts +++ b/src/common/contracts/tokens/fungible/client.ts @@ -4,13 +4,13 @@ import type { AccountId, ByAccountId, ByTokenId } from "@/common/types"; import type { FungibleTokenMetadata } from "./interfaces"; export const ft_metadata = ({ tokenId }: ByTokenId) => - nearProtocolClient.naxiosInstance + nearProtocolClient .contractApi({ contractId: tokenId }) .view<{}, FungibleTokenMetadata>("ft_metadata") .catch(() => undefined); export const ft_balance_of = ({ accountId, tokenId }: ByAccountId & ByTokenId) => - nearProtocolClient.naxiosInstance + nearProtocolClient .contractApi({ contractId: tokenId }) .view<{ account_id: AccountId }, string>("ft_balance_of", { args: { account_id: accountId }, diff --git a/src/common/contracts/tokens/fungible/hooks.ts b/src/common/contracts/tokens/fungible/hooks.ts index 2bc30aedc..82b78aa0b 100644 --- a/src/common/contracts/tokens/fungible/hooks.ts +++ b/src/common/contracts/tokens/fungible/hooks.ts @@ -16,8 +16,10 @@ export const useFtMetadata = ({ ...params }: ByTokenId & ConditionalActivation & LiveUpdateParams) => useSWR( - () => (!enabled || !IS_CLIENT ? null : ["ft_metadata", params.tokenId]), - ([_queryKeyHead, tokenId]) => ftContractClient.ft_metadata({ tokenId }).catch(() => undefined), + () => (enabled ? ["ft_metadata", params.tokenId] : null), + + ([_queryKeyHead, tokenId]) => + !IS_CLIENT ? undefined : ftContractClient.ft_metadata({ tokenId }).catch(() => undefined), { ...(live @@ -37,10 +39,12 @@ export const useFtBalanceOf = ({ ...params }: ByAccountId & ByTokenId & ConditionalActivation & LiveUpdateParams) => useSWR( - () => (!enabled || !IS_CLIENT ? null : ["ft_balance_of", params.accountId, params.tokenId]), + () => (enabled ? ["ft_balance_of", params.accountId, params.tokenId] : null), ([_queryKeyHead, accountId, tokenId]) => - ftContractClient.ft_balance_of({ accountId, tokenId }).catch(() => undefined), + !IS_CLIENT + ? undefined + : ftContractClient.ft_balance_of({ accountId, tokenId }).catch(() => undefined), { ...(live diff --git a/src/common/contracts/tokens/non-fungible/client.ts b/src/common/contracts/tokens/non-fungible/client.ts index 5157b521f..655bf36fb 100644 --- a/src/common/contracts/tokens/non-fungible/client.ts +++ b/src/common/contracts/tokens/non-fungible/client.ts @@ -15,7 +15,7 @@ export type NftTokenArgs = { * Returns NFT by token id from the given contract, if it exists. */ export const nft_token = ({ contractAccountId, tokenId }: NonFungibleTokenLookupParams) => - nearProtocolClient.naxiosInstance + nearProtocolClient .contractApi({ contractId: contractAccountId }) .view("nft_token", { args: { token_id: tokenId } }) .catch(() => undefined); @@ -24,7 +24,7 @@ export const nft_token = ({ contractAccountId, tokenId }: NonFungibleTokenLookup * Returns NFT contract metadata. */ export const nft_metadata = ({ contractAccountId }: ByContractAccountId) => - nearProtocolClient.naxiosInstance + nearProtocolClient .contractApi({ contractId: contractAccountId }) .view<{}, NonFungibleTokenContractMetadata>("nft_metadata") .catch(() => undefined); diff --git a/src/common/contracts/tokens/non-fungible/hooks.ts b/src/common/contracts/tokens/non-fungible/hooks.ts index ed5761251..a67342171 100644 --- a/src/common/contracts/tokens/non-fungible/hooks.ts +++ b/src/common/contracts/tokens/non-fungible/hooks.ts @@ -12,13 +12,14 @@ export const useToken = ({ tokenId, }: NonFungibleTokenLookupParams & ConditionalActivation) => useSWR( - () => - !enabled || !IS_CLIENT ? null : ["nftContractClient.nft_token", contractAccountId, tokenId], + () => (enabled ? ["nftContractClient.nft_token", contractAccountId, tokenId] : null), ([_queryKeyHead, account_id, token_id]) => - nftContractClient - .nft_token({ contractAccountId: account_id, tokenId: token_id }) - .catch(() => undefined), + IS_CLIENT + ? nftContractClient + .nft_token({ contractAccountId: account_id, tokenId: token_id }) + .catch(() => undefined) + : undefined, CONTRACT_SWR_CONFIG, ); diff --git a/src/common/lib/datetime.ts b/src/common/lib/datetime.ts index fe7920f6c..b5862d421 100644 --- a/src/common/lib/datetime.ts +++ b/src/common/lib/datetime.ts @@ -99,8 +99,34 @@ export const timestamp = preprocess( ); export const futureTimestamp = timestamp.refine( - (value) => value > Temporal.Now.instant().epochMilliseconds, + (value) => value >= Temporal.Now.instant().epochMilliseconds - 60_000, { message: "Cannot be in the past" }, ); export const daysFloatToMilliseconds = (daysFloat: number) => daysFloat * DAY_IN_MILLISECONDS; + +/** + * Converts an ISO date string or timestamp to a timestamp in milliseconds. + * Handles both the new ISO string format (e.g., "2025-06-10T02:32:24.026000Z") + * and legacy timestamp numbers. + */ +export const toTimestamp = (dateValue: string | number): number => { + if (typeof dateValue === "number") { + return dateValue; + } + + return new Date(dateValue).getTime(); +}; + +export const stripHtml = (html: string | null | undefined): string => { + if (!html) { + return ""; + } + + try { + const doc = new DOMParser().parseFromString(html, "text/html"); + return doc.body.textContent || doc.body.innerText || ""; + } catch (e) { + return html.replace(/<[^>]*>/g, ""); + } +}; diff --git a/src/common/lib/fetch-with-timeout.ts b/src/common/lib/fetch-with-timeout.ts new file mode 100644 index 000000000..2ce315e77 --- /dev/null +++ b/src/common/lib/fetch-with-timeout.ts @@ -0,0 +1,30 @@ +/** + * Helper function to fetch with timeout + * Prevents server timeouts by aborting slow requests + */ +export const fetchWithTimeout = async ( + url: string, + options: RequestInit = {}, + timeoutMs = 10000, +): Promise => { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), timeoutMs); + + try { + const response = await fetch(url, { + ...options, + signal: controller.signal, + }); + + clearTimeout(timeoutId); + return response; + } catch (error) { + clearTimeout(timeoutId); + + if (error instanceof Error && error.name === "AbortError") { + throw new Error(`Request timeout after ${timeoutMs}ms`); + } + + throw error; + } +}; diff --git a/src/common/lib/navigation.ts b/src/common/lib/navigation.ts index 135b7178c..e7a7573e8 100644 --- a/src/common/lib/navigation.ts +++ b/src/common/lib/navigation.ts @@ -17,12 +17,12 @@ import { useRouter } from "next/router"; * // Sets `accountId` query parameter to "root.near" * setSearchParams({ accountId: "root.near" }); * - * console.log(accountId); -> "root.near" + * console.info(accountId); -> "root.near" * * // Deletes `transactionHashes` query parameter * setSearchParams({ transactionHashes: null }); * - * console.log(transactionHashes); -> undefined + * console.info(transactionHashes); -> undefined */ export const useRouteQuery = () => { const { pathname, query, replace } = useRouter(); diff --git a/src/common/lib/protocol-config.ts b/src/common/lib/protocol-config.ts index 8c9ecc2ef..a0905dfb0 100644 --- a/src/common/lib/protocol-config.ts +++ b/src/common/lib/protocol-config.ts @@ -1,7 +1,7 @@ import { useEffect, useState } from "react"; import { Pot } from "@/common/api/indexer"; -import { naxiosInstance } from "@/common/blockchains/near-protocol/client"; +import { contractApi } from "@/common/blockchains/near-protocol/client"; export type ProtocolConfig = { basis_points: number; @@ -18,8 +18,7 @@ export const useProtocolConfig = (potDetail: Pot) => { useEffect(() => { if (configContractId && configViewMethodName) { - naxiosInstance - .contractApi({ contractId: configContractId }) + contractApi({ contractId: configContractId }) .view<{}, ProtocolConfig>(configViewMethodName) .then((config) => { setConfig(config); diff --git a/src/common/lib/string.ts b/src/common/lib/string.ts index 6c398a270..d9a8b3993 100644 --- a/src/common/lib/string.ts +++ b/src/common/lib/string.ts @@ -1,11 +1,33 @@ +export type EmptyString = `${""}`; + +export const EMPTY_STRING: EmptyString = ""; + export const truncate = (value: string, maxLength: number) => { - if (value?.length ?? 0 <= maxLength) { + if ((value?.length ?? 0) <= maxLength) { return value; } else { return value.substring(0, maxLength - 3) + "..."; } }; +/** + * Truncates HTML content by first stripping HTML tags, then truncating the plain text + * @param htmlContent - HTML string to truncate + * @param maxLength - Maximum length of the plain text + * @returns Truncated plain text with ellipsis + */ +export const truncateHtml = (htmlContent: string, maxLength: number): string => { + if (!htmlContent) return ""; + + // Create a temporary div to parse HTML and get text content + const tempDiv = document.createElement("div"); + tempDiv.innerHTML = htmlContent; + const textContent = tempDiv.textContent || ""; + + // Use the regular truncate function on the plain text + return truncate(textContent, maxLength); +}; + export const isValidHttpUrl = (value: string) => { try { return Boolean(new URL(value)); diff --git a/src/common/types.ts b/src/common/types.ts index 52ceb9eea..be8155079 100644 --- a/src/common/types.ts +++ b/src/common/types.ts @@ -61,7 +61,8 @@ export type EnvConfig = { indexer: { api: { endpointUrl: string } }; core: { - campaigns: { contract: { accountId: string } }; + namespaceRoot: { contract: ContractConfig }; + campaigns: { contract: ContractConfig }; donation: { contract: ContractConfig }; lists: { contract: ContractConfig }; potFactory: { contract: ContractConfig }; @@ -69,7 +70,7 @@ export type EnvConfig = { voting: { contract: ContractConfig }; }; - social: { app: { url: string }; contract: ContractConfig }; + social: { platformName: string; app: { url: string }; contract: ContractConfig }; deFi: { metapool: { diff --git a/src/common/ui/form/components/index.ts b/src/common/ui/form/components/index.ts index 257171f93..7bcbb01e9 100644 --- a/src/common/ui/form/components/index.ts +++ b/src/common/ui/form/components/index.ts @@ -2,3 +2,4 @@ export * from "./checkbox"; export * from "./select"; export * from "./text"; export * from "./textarea"; +export * from "./richtext"; diff --git a/src/common/ui/form/components/richtext.tsx b/src/common/ui/form/components/richtext.tsx new file mode 100644 index 000000000..72b3c8dd8 --- /dev/null +++ b/src/common/ui/form/components/richtext.tsx @@ -0,0 +1,331 @@ +import { useCallback, useEffect, useMemo, useRef } from "react"; + +import Link from "@tiptap/extension-link"; +import { EditorContent, useEditor } from "@tiptap/react"; +import StarterKit from "@tiptap/starter-kit"; +import { Bold, Heading1, Italic, Link as LinkIcon, Type } from "lucide-react"; + +import { cn } from "@/common/ui/layout/utils"; + +export interface RichTextEditorProps { + value?: string; + onChange?: (value: string) => void; + placeholder?: string; + maxLength?: number; + label?: string; + required?: boolean; + disabled?: boolean; + error?: string; + className?: string; + showCharacterCount?: boolean; + showToolbar?: boolean; +} + +export const RichTextEditor: React.FC = ({ + value, + onChange, + placeholder, + maxLength = 500, + label, + required = false, + disabled = false, + error, + className, + showCharacterCount = true, + showToolbar = true, +}) => { + const onChangeRef = useRef(onChange); + const lastValueRef = useRef(value); + const timeoutRef = useRef(); + + // Update ref when onChange changes + useEffect(() => { + onChangeRef.current = onChange; + }, [onChange]); + + // Debounced onChange handler + const debouncedOnChange = useCallback((newValue: string) => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + + timeoutRef.current = setTimeout(() => { + if (onChangeRef.current && newValue !== lastValueRef.current) { + lastValueRef.current = newValue; + onChangeRef.current(newValue); + } + }, 100); + }, []); + + // Check if the current content would exceed the limit + const wouldExceedLimit = useCallback( + (newContent: string) => { + const tempDiv = document.createElement("div"); + tempDiv.innerHTML = newContent; + const textLength = tempDiv.textContent?.length || 0; + return textLength > maxLength; + }, + [maxLength], + ); + + const editor = useEditor({ + extensions: [ + StarterKit, + Link.configure({ + openOnClick: false, + HTMLAttributes: { + class: "text-blue-600 hover:text-blue-800 underline", + }, + }), + ], + content: value || `

${placeholder ?? ""}

`, + editable: !disabled, + onUpdate: ({ editor }) => { + const html = editor.getHTML(); + const cleanHtml = html === `

${placeholder ?? ""}

` ? "" : html; + debouncedOnChange(cleanHtml); + }, + editorProps: { + handleKeyDown: (view, event) => { + // Get current text content length + const currentText = view.state.doc.textContent; + const currentLength = currentText.length; + + // Allow deletion and navigation keys + if ( + event.key === "Backspace" || + event.key === "Delete" || + event.key === "ArrowLeft" || + event.key === "ArrowRight" || + event.key === "ArrowUp" || + event.key === "ArrowDown" || + event.key === "Home" || + event.key === "End" || + event.key === "Tab" || + event.metaKey || + event.ctrlKey + ) { + return false; // Let TipTap handle these + } + + // If we're at the limit, prevent typing + if (currentLength >= maxLength) { + event.preventDefault(); + return true; // Prevent the input + } + + return false; // Allow normal input + }, + }, + }); + + // Update editor content when value prop changes (but not from internal updates) + useEffect(() => { + if (editor && value !== lastValueRef.current) { + lastValueRef.current = value; + editor.commands.setContent(value || `

${placeholder}

`); + } + }, [editor, value, placeholder]); + + // Cleanup timeout on unmount + useEffect(() => { + return () => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + }; + }, []); + + // Character count calculation (excluding HTML tags) + const characterCount = useMemo(() => { + if (!value) return 0; + // Create a temporary div to properly parse HTML and get text content + const tempDiv = document.createElement("div"); + tempDiv.innerHTML = value; + return tempDiv.textContent?.length || 0; + }, [value]); + + const isOverLimit = characterCount > maxLength; + const isAtLimit = characterCount >= maxLength; + + const addLink = useCallback(() => { + // Don't allow adding links if we're at the limit + if (isAtLimit) { + return; + } + + const url = window.prompt("Enter URL:"); + + if (url && editor) { + editor.chain().focus().setLink({ href: url }).run(); + } + }, [editor, isAtLimit]); + + const removeLink = useCallback(() => { + if (editor) { + editor.chain().focus().unsetLink().run(); + } + }, [editor]); + + if (!editor) { + return
; + } + + return ( +
+ {label && ( + + )} + +
+ {showToolbar && ( +
+ + + + +
+ + + + + +
+ + {editor.isActive("link") ? ( + + ) : ( + + )} +
+ )} + +
+ +
+
+ + {/* Character count and error */} +
+ {showCharacterCount && ( + + {characterCount} / {maxLength} characters + {isAtLimit && " (limit reached)"} + + )} + {error && {error}} +
+
+ ); +}; diff --git a/src/common/ui/form/components/text.tsx b/src/common/ui/form/components/text.tsx index 13c1f5404..c94b3c2d6 100644 --- a/src/common/ui/form/components/text.tsx +++ b/src/common/ui/form/components/text.tsx @@ -134,7 +134,8 @@ export const TextField = forwardRef( ( "focus-visible:rounded-l-none focus-visible:pl-2.5 focus-visible:outline-none": inputExtensionElement !== null && appendixElement !== null, - "focus-visible:border-inset focus-visible:border-l focus-visible:border-l-2 focus-visible:border-neutral-300": + ["focus-visible:border-inset focus-visible:border-l focus-visible:border-l-2" + + "focus-visible:border-neutral-300"]: inputExtensionElement !== null && appendixElement !== null, }, )} diff --git a/src/common/ui/form/hooks/enhanced.ts b/src/common/ui/form/hooks/enhanced.ts index ff8be2f96..79db2c32e 100644 --- a/src/common/ui/form/hooks/enhanced.ts +++ b/src/common/ui/form/hooks/enhanced.ts @@ -108,11 +108,14 @@ export const useEnhancedForm = ({ injectedEffect, }); - const isUnpopulated = - !isDeepEqual( - defaultValues, - pick(self.formState.defaultValues ?? {}, keys(defaultValues ?? {})), - ) && !self.formState.isDirty; + const currentDefaultValues = self.formState.defaultValues; + + const pickedValues = + defaultValues && currentDefaultValues + ? pick(currentDefaultValues, keys(defaultValues)) + : (currentDefaultValues ?? {}); + + const isUnpopulated = !isDeepEqual(defaultValues ?? {}, pickedValues) && !self.formState.isDirty; useEffect(() => { if (followDefaultValues && isUnpopulated) { diff --git a/src/common/ui/layout/components/LazyImage.tsx b/src/common/ui/layout/components/LazyImage.tsx new file mode 100644 index 000000000..50122d55a --- /dev/null +++ b/src/common/ui/layout/components/LazyImage.tsx @@ -0,0 +1,8 @@ +import { type ComponentType } from "react"; + +import { LazyLoadImage, type LazyLoadImageProps } from "react-lazy-load-image-component"; + +// Wrapper to satisfy React 18 JSX element typing for LazyLoadImage +export const LazyImage = LazyLoadImage as unknown as ComponentType; + +export type { LazyLoadImageProps } from "react-lazy-load-image-component"; diff --git a/src/common/ui/layout/components/atoms/accordion.tsx b/src/common/ui/layout/components/atoms/accordion.tsx index 6308d9c15..aa2febb7d 100644 --- a/src/common/ui/layout/components/atoms/accordion.tsx +++ b/src/common/ui/layout/components/atoms/accordion.tsx @@ -18,13 +18,16 @@ AccordionItem.displayName = "AccordionItem"; export type AccordionTriggerProps = React.ComponentPropsWithoutRef< typeof AccordionPrimitive.Trigger -> & { hiddenChevron?: boolean }; +> & { + hiddenChevron?: boolean; + rootClassName?: string; +}; export const AccordionTrigger = forwardRef< React.ElementRef, AccordionTriggerProps ->(({ hiddenChevron = false, className, children, ...props }, ref) => ( - +>(({ hiddenChevron = false, className, children, rootClassName, ...props }, ref) => ( + , - VariantProps {} + extends React.HTMLAttributes, VariantProps {} function Badge({ className, variant, ...props }: BadgeProps) { return
; diff --git a/src/common/ui/layout/components/atoms/button.tsx b/src/common/ui/layout/components/atoms/button.tsx index 9363be3a5..e29086b0b 100644 --- a/src/common/ui/layout/components/atoms/button.tsx +++ b/src/common/ui/layout/components/atoms/button.tsx @@ -54,7 +54,7 @@ const buttonVariants = cva( ), "standard-plain": cn( - "p-0 hover:text-[color:var(--neutral-500)]", + "focus:shadow-none hover:text-[color:var(--neutral-500)]", "disabled:text-[#a6a6a6] disabled:shadow-[0px_0px_0px_1px_rgba(15,15,15,0.15)_inset] disabled:bg-[var(--neutral-100)]", ), @@ -87,8 +87,7 @@ const buttonVariants = cva( ); export interface ButtonProps - extends React.ButtonHTMLAttributes, - VariantProps { + extends React.ButtonHTMLAttributes, VariantProps { asChild?: boolean; } diff --git a/src/common/ui/layout/components/atoms/dropdown-menu.tsx b/src/common/ui/layout/components/atoms/dropdown-menu.tsx index 18158652f..ee0f684cf 100644 --- a/src/common/ui/layout/components/atoms/dropdown-menu.tsx +++ b/src/common/ui/layout/components/atoms/dropdown-menu.tsx @@ -26,7 +26,8 @@ const DropdownMenuSubTrigger = React.forwardRef< (({ className, inset, ...props }, ref) => ( )); diff --git a/src/common/ui/layout/components/atoms/filter-chip.tsx b/src/common/ui/layout/components/atoms/filter-chip.tsx index c09ba6385..60e301071 100644 --- a/src/common/ui/layout/components/atoms/filter-chip.tsx +++ b/src/common/ui/layout/components/atoms/filter-chip.tsx @@ -55,8 +55,7 @@ const filterChipVariants = cva( ); export interface FilterChipProps - extends React.ButtonHTMLAttributes, - VariantProps { + extends React.ButtonHTMLAttributes, VariantProps { asChild?: boolean; count?: number; label?: string; diff --git a/src/common/ui/layout/components/atoms/progress.tsx b/src/common/ui/layout/components/atoms/progress.tsx index cc805d6aa..a52f8d34c 100644 --- a/src/common/ui/layout/components/atoms/progress.tsx +++ b/src/common/ui/layout/components/atoms/progress.tsx @@ -65,6 +65,7 @@ export const Progress = forwardRef< className={`absolute ${isHovered ? "bottom-[-39px]" : "bottom-[-44px]"} flex transform flex-col items-center transition-all duration-200`} style={{ left: `${clampedMinValuePercentage}%`, // Shift arrow on hover + transform: `translateX(-50%)`, // Center arrow horizontally on the exact percentage }} > { +export interface SelectTriggerProps extends React.ComponentPropsWithoutRef< + typeof SelectPrimitive.Trigger +> { iconClassName?: string; iconProps?: SelectPrimitive.SelectIconProps; } diff --git a/src/common/ui/layout/components/atoms/typography.tsx b/src/common/ui/layout/components/atoms/typography.tsx index a0221aa1d..bfb8f6e83 100644 --- a/src/common/ui/layout/components/atoms/typography.tsx +++ b/src/common/ui/layout/components/atoms/typography.tsx @@ -1,8 +1,11 @@ import { useMemo } from "react"; +import type { LabelProps } from "@radix-ui/react-label"; + import { cn } from "../../utils"; -export type LabeledIconProps = { +export type LabeledIconProps = Pick & { + bold?: boolean; caption: string | number; hint?: string; href?: string; @@ -20,15 +23,21 @@ export type LabeledIconProps = { * Combination of text and icon with better vertical alignment */ export const LabeledIcon = ({ + bold = false, caption, hint, href, + htmlFor, lineHeight = 20, positioning = "text-icon", children, classNames, }: LabeledIconProps) => { - const labelClassName = cn("mt-0.8 font-400 leading-none", classNames?.caption); + const labelClassName = cn( + "mt-0.8 leading-none", + { "font-medium": bold, "font-normal": !bold }, + classNames?.caption, + ); const labelElement = useMemo( () => @@ -36,18 +45,18 @@ export const LabeledIcon = ({ {caption} ) : ( - + {caption} ), - [caption, hint, href, labelClassName], + [caption, hint, href, htmlFor, labelClassName], ); return ( diff --git a/src/common/ui/layout/components/molecules/clipboard-copy-button.tsx b/src/common/ui/layout/components/molecules/clipboard-copy-button.tsx index c6472980e..5555059b9 100644 --- a/src/common/ui/layout/components/molecules/clipboard-copy-button.tsx +++ b/src/common/ui/layout/components/molecules/clipboard-copy-button.tsx @@ -4,6 +4,9 @@ import { CopyToClipboard } from "react-copy-to-clipboard"; import { CopyPasteIcon } from "@/common/ui/layout/svg"; +// Type assertion to fix React 18 compatibility issue +const CopyToClipboardComponent = CopyToClipboard as any; + export type ClipboardCopyButtonProps = { customIcon?: ReactElement; text: string; @@ -25,10 +28,10 @@ export const ClipboardCopyButton: React.FC = ({ custom /> ) : ( - +
{customIcon ? customIcon : }
-
+ ); }; diff --git a/src/common/ui/layout/components/molecules/social-share.tsx b/src/common/ui/layout/components/molecules/social-share.tsx index e07cf83cf..82be23cdb 100644 --- a/src/common/ui/layout/components/molecules/social-share.tsx +++ b/src/common/ui/layout/components/molecules/social-share.tsx @@ -15,6 +15,10 @@ import { useWalletUserSession } from "@/common/wallet"; import { Button } from "../atoms/button"; import { Popover, PopoverContent, PopoverTrigger } from "../atoms/popover"; +// Type assertion to fix React 18 compatibility issue +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const CopyToClipboardComponent = CopyToClipboard as any; + export const SocialsShare = ({ shareContent, variant = "icon", @@ -83,12 +87,12 @@ export const SocialsShare = ({ or
- + - +
diff --git a/src/common/ui/layout/components/templates/page-with-banner.tsx b/src/common/ui/layout/components/templates/page-with-banner.tsx index 34747f0fe..1dc9a8696 100644 --- a/src/common/ui/layout/components/templates/page-with-banner.tsx +++ b/src/common/ui/layout/components/templates/page-with-banner.tsx @@ -1,7 +1,12 @@ +import { cn } from "../../utils"; + export type PageWithBannerProps = { + className?: string; children: React.ReactNode; }; -export const PageWithBanner: React.FC = ({ children }) => ( -
{children}
+export const PageWithBanner: React.FC = ({ className, children }) => ( +
+ {children} +
); diff --git a/src/common/wallet/components.tsx b/src/common/wallet/components/providers.tsx similarity index 66% rename from src/common/wallet/components.tsx rename to src/common/wallet/components/providers.tsx index 1f2476bbc..34a24c482 100644 --- a/src/common/wallet/components.tsx +++ b/src/common/wallet/components/providers.tsx @@ -1,19 +1,15 @@ import { useCallback, useEffect, useMemo } from "react"; -import type { WalletManager } from "@wpdas/naxios/dist/types/managers/wallet-manager"; import { useRouter } from "next/router"; import { nearProtocolClient } from "@/common/blockchains/near-protocol"; import { DEBUG_ACCOUNT_ID, IS_CLIENT } from "@/common/constants"; +import { isAccountId } from "@/common/lib"; -import { useWalletUserAdapter } from "./adapters"; -import { useWalletUserMetadataStore } from "./model"; -import { isAccountId } from "../lib"; +import { useWalletUserMetadataStore } from "../model/user"; +import { useWalletUserAdapter } from "../user-adapter"; -//* There are edge cases where `walletSelector` is `undefined` in runtime for a brief moment -const isWalletSelectorApiAvailable = () => - (nearProtocolClient.walletApi.walletSelector as undefined | WalletManager["walletSelector"]) !== - undefined; +const isWalletConnectorAvailable = () => nearProtocolClient.walletApi.connector !== undefined; type WalletProviderProps = { children: React.ReactNode; @@ -41,11 +37,9 @@ const WalletProvider: React.FC = ({ children }) => { const { referrerAccountId, setReferrerAccountId } = useWalletUserMetadataStore(); const syncWalletState = useCallback(() => { - if (isWalletSelectorApiAvailable()) { + if (isWalletConnectorAvailable()) { const isWalletSignedIn = - typeof DEBUG_ACCOUNT_ID === "string" - ? true - : nearProtocolClient.walletApi.walletSelector.isSignedIn(); + typeof DEBUG_ACCOUNT_ID === "string" ? true : nearProtocolClient.walletApi.isSignedIn; const walletAccountId = typeof DEBUG_ACCOUNT_ID === "string" @@ -79,22 +73,22 @@ const WalletProvider: React.FC = ({ children }) => { if (isReady) { syncWalletState(); - nearProtocolClient.walletApi.walletSelector.on("signedIn", handleChange); - nearProtocolClient.walletApi.walletSelector.on("signedOut", handleChange); - nearProtocolClient.walletApi.walletSelector.on("accountsChanged", handleChange); - nearProtocolClient.walletApi.walletSelector.on("networkChanged", handleChange); - nearProtocolClient.walletApi.walletSelector.on("uriChanged", handleChange); - } + const connector = nearProtocolClient.walletApi.connector; - return () => { - if (isReady) { - nearProtocolClient.walletApi.walletSelector.off("signedIn", handleChange); - nearProtocolClient.walletApi.walletSelector.off("signedOut", handleChange); - nearProtocolClient.walletApi.walletSelector.off("accountsChanged", handleChange); - nearProtocolClient.walletApi.walletSelector.off("networkChanged", handleChange); - nearProtocolClient.walletApi.walletSelector.off("uriChanged", handleChange); + if (!connector) { + return () => undefined; } - }; + + connector.on("wallet:signIn", handleChange); + connector.on("wallet:signOut", handleChange); + + return () => { + connector.off("wallet:signIn", handleChange); + connector.off("wallet:signOut", handleChange); + }; + } + + return undefined; }, [syncWalletState, isReady, handleChange]); /** diff --git a/src/common/wallet/hooks.ts b/src/common/wallet/hooks.ts deleted file mode 100644 index 7fdcd08e0..000000000 --- a/src/common/wallet/hooks.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { useMemo } from "react"; - -import { prop } from "remeda"; - -import { NOOP_STRING, PUBLIC_GOODS_REGISTRY_LIST_ID } from "@/common/constants"; -import { RegistrationStatus, listsContractHooks } from "@/common/contracts/core/lists"; -import { sybilResistanceContractHooks } from "@/common/contracts/core/sybil-resistance"; -import { isAccountId } from "@/common/lib"; -import { useGlobalStoreSelector } from "@/store"; - -import { useWalletUserAdapter } from "./adapters"; -import { type WalletUserSession, useWalletUserMetadataStore } from "./model"; - -export const useWalletUserSession = (): WalletUserSession => { - const wallet = useWalletUserAdapter(); - const { referrerAccountId } = useWalletUserMetadataStore(); - const { actAsDao } = useGlobalStoreSelector(prop("nav")); - const daoAccountId = actAsDao.defaultAddress; - const isDaoAccountIdValid = useMemo(() => isAccountId(daoAccountId), [daoAccountId]); - const isDaoRepresentative = actAsDao.toggle && isDaoAccountIdValid; - - const { isLoading: isHumanVerificationStatusLoading, data: isHuman } = - sybilResistanceContractHooks.useIsHuman({ - enabled: wallet.isSignedIn, - accountId: wallet.accountId ?? NOOP_STRING, - }); - - const { isLoading: isRegistrationLoading, data: registration } = - listsContractHooks.useRegistration({ - enabled: wallet.isSignedIn, - listId: PUBLIC_GOODS_REGISTRY_LIST_ID, - accountId: (isDaoRepresentative ? daoAccountId : wallet.accountId) ?? NOOP_STRING, - }); - - const isMetadataLoading = isHumanVerificationStatusLoading || isRegistrationLoading; - - return useMemo(() => { - if (wallet.isReady && wallet.isSignedIn && wallet.accountId) { - return { - hasWalletReady: true, - accountId: wallet.accountId, - isSignedIn: true, - - ...(isDaoRepresentative - ? { isDaoRepresentative, daoAccountId } - : { isDaoRepresentative: false, daoAccountId: undefined }), - - isHuman: isHuman ?? false, - isMetadataLoading, - hasRegistrationSubmitted: registration !== undefined, - hasRegistrationApproved: registration?.status === RegistrationStatus.Approved, - registrationStatus: registration?.status, - referrerAccountId: isAccountId(referrerAccountId) ? referrerAccountId : undefined, - }; - } else if (wallet.isReady && !wallet.isSignedIn) { - return { - hasWalletReady: true, - accountId: undefined, - daoAccountId: undefined, - isSignedIn: false, - isDaoRepresentative: false, - isHuman: false, - isMetadataLoading: false, - hasRegistrationSubmitted: false, - hasRegistrationApproved: false, - registrationStatus: undefined, - referrerAccountId: undefined, - }; - } else { - return { - hasWalletReady: false, - accountId: undefined, - daoAccountId: undefined, - isSignedIn: false, - isDaoRepresentative: false, - isHuman: false, - isMetadataLoading: false, - hasRegistrationSubmitted: false, - hasRegistrationApproved: false, - registrationStatus: undefined, - referrerAccountId: undefined, - }; - } - }, [ - daoAccountId, - isDaoRepresentative, - isHuman, - isMetadataLoading, - referrerAccountId, - registration, - wallet.accountId, - wallet.isReady, - wallet.isSignedIn, - ]); -}; diff --git a/src/common/wallet/hooks/user-session.ts b/src/common/wallet/hooks/user-session.ts new file mode 100644 index 000000000..273f696dc --- /dev/null +++ b/src/common/wallet/hooks/user-session.ts @@ -0,0 +1,154 @@ +import { useCallback, useMemo } from "react"; + +import { indexer } from "@/common/api/indexer"; +import { nearProtocolClient } from "@/common/blockchains/near-protocol"; +import { NOOP_FUNCTION, NOOP_STRING, PUBLIC_GOODS_REGISTRY_LIST_ID } from "@/common/constants"; +import { RegistrationStatus, listsContractHooks } from "@/common/contracts/core/lists"; +import { sybilResistanceContractHooks } from "@/common/contracts/core/sybil-resistance"; +import { isAccountId } from "@/common/lib"; +import type { AccountId } from "@/common/types"; + +import { useWalletDaoStore } from "../model/dao"; +import { type WalletUserSession, useWalletUserMetadataStore } from "../model/user"; +import { useWalletUserAdapter } from "../user-adapter"; + +export const useWalletUserSession = (): WalletUserSession => { + const wallet = useWalletUserAdapter(); + const { referrerAccountId } = useWalletUserMetadataStore(); + const daoAuth = useWalletDaoStore(); + + const activeAccountId = useMemo( + () => (daoAuth.isActive ? daoAuth.activeAccountId : wallet.accountId), + [daoAuth.activeAccountId, daoAuth.isActive, wallet.accountId], + ); + + const validReferrerAccountId: AccountId | undefined = useMemo( + () => (isAccountId(referrerAccountId) ? referrerAccountId : undefined), + [referrerAccountId], + ); + + const { isLoading: isHumanVerificationStatusLoading, data: isHuman } = + sybilResistanceContractHooks.useIsHuman({ + enabled: activeAccountId !== undefined, + accountId: activeAccountId ?? NOOP_STRING, + }); + + const { + isLoading: isIndexedListRegistrationDataLoading, + data: indexedListRegistrations, + error: indexedListRegistrationsError, + mutate: refetchIndexedListRegistrations, + } = indexer.useAccountListRegistrations({ + enabled: activeAccountId !== undefined, + live: true, + accountId: activeAccountId ?? NOOP_STRING, + }); + + const indexedRegistration = useMemo( + () => + indexedListRegistrations?.results.find( + ({ list_id }) => list_id === PUBLIC_GOODS_REGISTRY_LIST_ID, + ), + + [indexedListRegistrations], + ); + + const { + isLoading: isRegistrationLoading, + data: registration, + mutate: refetchRegistrationData, + } = listsContractHooks.useRegistration({ + enabled: activeAccountId !== undefined && indexedListRegistrationsError !== undefined, + accountId: activeAccountId ?? NOOP_STRING, + listId: PUBLIC_GOODS_REGISTRY_LIST_ID, + }); + + const isMetadataLoading = + (isHuman === undefined && isHumanVerificationStatusLoading) || + (indexedListRegistrations === undefined && + indexedListRegistrationsError === undefined && + isIndexedListRegistrationDataLoading) || + (registration === undefined && isRegistrationLoading); + + const hasRegistrationSubmitted = useMemo( + () => indexedRegistration !== undefined || registration !== undefined, + [indexedRegistration, registration], + ); + + const registrationStatus: RegistrationStatus | undefined = useMemo(() => { + const availableStatus = indexedRegistration?.status ?? registration?.status; + + return availableStatus !== undefined ? RegistrationStatus[availableStatus] : undefined; + }, [indexedRegistration?.status, registration?.status]); + + const hasRegistrationApproved = useMemo( + () => registrationStatus === RegistrationStatus.Approved, + [registrationStatus], + ); + + const refetchRegistrationStatus = useCallback(() => { + if (indexedListRegistrationsError === undefined) { + refetchIndexedListRegistrations(); + } else refetchRegistrationData(); + }, [indexedListRegistrationsError, refetchIndexedListRegistrations, refetchRegistrationData]); + + const logout = useCallback(() => { + nearProtocolClient.walletApi + .signOut() + .then(() => { + wallet.reset(); + daoAuth.reset(); + }) + .catch(NOOP_FUNCTION); + }, [daoAuth, wallet]); + + if (wallet.isReady && wallet.isSignedIn && wallet.accountId && activeAccountId !== undefined) { + return { + hasWalletReady: true, + isSignedIn: true, + isDaoRepresentative: daoAuth.isActive, + isHuman: isHuman ?? false, + isMetadataLoading, + hasRegistrationSubmitted, + hasRegistrationApproved, + signerAccountId: wallet.accountId, + accountId: activeAccountId, + registrationStatus, + referrerAccountId: validReferrerAccountId, + refetchRegistrationStatus, + logout, + }; + } else if (wallet.isReady && !wallet.isSignedIn) { + return { + hasWalletReady: true, + isSignedIn: false, + isDaoRepresentative: false, + isHuman: false, + isMetadataLoading: false, + hasRegistrationSubmitted: false, + hasRegistrationApproved: false, + signerAccountId: undefined, + accountId: undefined, + registrationStatus: undefined, + referrerAccountId: undefined, + refetchRegistrationStatus: undefined, + logout, + }; + } else { + return { + hasWalletReady: false, + isSignedIn: false, + isDaoRepresentative: false, + isHuman: false, + isMetadataLoading: false, + hasRegistrationSubmitted: false, + hasRegistrationApproved: false, + signerAccountId: undefined, + accountId: undefined, + registrationStatus: undefined, + referrerAccountId: undefined, + refetchRegistrationStatus: undefined, + logout, + }; + } +}; diff --git a/src/common/wallet/index.ts b/src/common/wallet/index.ts index a234113be..102c022f2 100644 --- a/src/common/wallet/index.ts +++ b/src/common/wallet/index.ts @@ -1,2 +1,3 @@ -export * from "./components"; -export * from "./hooks"; +export { WalletUserSessionProvider } from "./components/providers"; +export { useWalletUserSession } from "./hooks/user-session"; +export * from "./model/dao"; diff --git a/src/common/wallet/model/dao.ts b/src/common/wallet/model/dao.ts new file mode 100644 index 000000000..e66eafe97 --- /dev/null +++ b/src/common/wallet/model/dao.ts @@ -0,0 +1,93 @@ +import { create } from "zustand"; +import { persist } from "zustand/middleware"; + +import { sputnikDaoQueries } from "@/common/contracts/sputnikdao2"; +import type { AccountId } from "@/common/types"; + +type WalletDaoAuth = { listedAccountIds: AccountId[] } & ( + | { + isActive: false; + activeAccountId: null; + } + | { + isActive: true; + activeAccountId: AccountId; + } +); + +type TryActivateParams = { + memberAccountId: AccountId; + optionIndex: number; + onError: (err: Error) => void; +}; + +type WalletDaoAuthState = WalletDaoAuth & { + reset: () => void; + listDao: (accountId: AccountId) => void; + delistDao: (accountId: AccountId) => void; + tryActivate: (params: TryActivateParams) => void; + deactivate: VoidFunction; +}; + +const initialState: WalletDaoAuth = { + listedAccountIds: [], + isActive: false, + activeAccountId: null, +}; + +export const useWalletDaoStore = create()( + persist( + (set, get) => ({ + ...initialState, + reset: () => set(initialState), + + listDao: (accountId: AccountId) => { + const listedAccountIds = get().listedAccountIds; + + if (!listedAccountIds.includes(accountId)) { + set({ listedAccountIds: [...listedAccountIds, accountId] }); + } + }, + + delistDao: (accountId: AccountId) => { + const { activeAccountId, listedAccountIds } = get(); + + if (listedAccountIds.length === 1) { + set(initialState); + } else { + set({ listedAccountIds: listedAccountIds.filter((id) => id !== accountId) }); + + if (activeAccountId === accountId) { + set({ isActive: false, activeAccountId: null }); + } + } + }, + + tryActivate: ({ memberAccountId, optionIndex, onError }) => { + const daoAccountId = get().listedAccountIds.at(optionIndex); + + if (daoAccountId === undefined) { + onError(new Error("The account ID is not listed.")); + } else { + sputnikDaoQueries + .getPermissions({ daoAccountId, accountId: memberAccountId }) + .then(({ canSubmitProposals }) => { + if (canSubmitProposals) { + set({ isActive: true, activeAccountId: daoAccountId }); + } else { + onError(new Error("Insufficient DAO permissions.")); + } + }) + .catch((err) => { + console.error(err); + onError(new Error("Unable to check your DAO permissions.")); + }); + } + }, + + deactivate: () => set({ isActive: false, activeAccountId: null }), + }), + + { name: "wallet-dao-auth" }, + ), +); diff --git a/src/common/wallet/model.ts b/src/common/wallet/model/user.ts similarity index 63% rename from src/common/wallet/model.ts rename to src/common/wallet/model/user.ts index 0aa525b38..0a886e378 100644 --- a/src/common/wallet/model.ts +++ b/src/common/wallet/model/user.ts @@ -4,50 +4,66 @@ import { persist } from "zustand/middleware"; import type { RegistrationStatus } from "@/common/contracts/core/lists"; import { AccountId } from "@/common/types"; -export type WalletUserDaoRepresentativeParams = - | { isDaoRepresentative: false; daoAccountId: undefined } - | { isDaoRepresentative: true; daoAccountId: AccountId }; - -export type WalletUserMetadata = { +type WalletUserMetadata = { referrerAccountId?: AccountId; }; -export type WalletUserSession = WalletUserMetadata & - ( +export type WalletUserSession = WalletUserMetadata & { logout: VoidFunction } & ( | { hasWalletReady: false; - accountId: undefined; - daoAccountId: undefined; isSignedIn: false; isDaoRepresentative: false; isHuman: false; isMetadataLoading: false; + signerAccountId: undefined; + accountId: undefined; registrationStatus: undefined; hasRegistrationSubmitted: false; hasRegistrationApproved: false; + refetchRegistrationStatus: undefined; } | { hasWalletReady: true; - accountId: undefined; - daoAccountId: undefined; isSignedIn: false; isDaoRepresentative: false; isHuman: false; isMetadataLoading: false; + signerAccountId: undefined; + accountId: undefined; registrationStatus: undefined; hasRegistrationSubmitted: false; hasRegistrationApproved: false; + refetchRegistrationStatus: undefined; } - | (WalletUserDaoRepresentativeParams & { + | { hasWalletReady: true; - accountId: AccountId; isSignedIn: true; + + /** + * Whether DAO authentication is enabled for a DAO + * which the user has proposal creation privileges in. + */ + isDaoRepresentative: boolean; + isHuman: boolean; isMetadataLoading: boolean; + + /** + * The account ID provided by the currently connected wallet instance. + */ + signerAccountId: AccountId; + + /** + * If `.isDaoRepresentative` is `true`, then the account ID of the currently selected DAO. + * Otherwise, the account ID provided by the currently connected wallet instance. + */ + accountId: AccountId; + registrationStatus?: RegistrationStatus; hasRegistrationSubmitted: boolean; hasRegistrationApproved: boolean; - }) + refetchRegistrationStatus: VoidFunction; + } ); interface WalletUserMetadataState extends WalletUserMetadata { @@ -63,8 +79,6 @@ export const useWalletUserMetadataStore = create()( reset: () => set({ referrerAccountId: undefined }), }), - { - name: "wallet-user-metadata", - }, + { name: "wallet-user-metadata" }, ), ); diff --git a/src/common/wallet/adapters.ts b/src/common/wallet/user-adapter.ts similarity index 93% rename from src/common/wallet/adapters.ts rename to src/common/wallet/user-adapter.ts index d4cfe3c04..cb53409d9 100644 --- a/src/common/wallet/adapters.ts +++ b/src/common/wallet/user-adapter.ts @@ -20,6 +20,7 @@ const initialWalletUserAdapterState: WalletUserAdapterState = { }; type WalletUserAdapterStore = WalletUserAdapterState & { + reset: () => void; registerInit: (isReady: boolean) => void; setAccountState: (state: WalletUserAccountState) => void; setError: (error: unknown) => void; @@ -27,6 +28,7 @@ type WalletUserAdapterStore = WalletUserAdapterState & { export const useWalletUserAdapter = create((set) => ({ ...initialWalletUserAdapterState, + reset: () => set(initialWalletUserAdapterState), registerInit: (isReady: boolean) => set(isReady ? { isReady } : initialWalletUserAdapterState), setAccountState: (newAccountState: WalletUserAccountState) => set(newAccountState), setError: (error: unknown) => set({ error }), diff --git a/src/entities/_shared/account/components/AccountHandle.tsx b/src/entities/_shared/account/components/AccountHandle.tsx deleted file mode 100644 index 24f229782..000000000 --- a/src/entities/_shared/account/components/AccountHandle.tsx +++ /dev/null @@ -1,64 +0,0 @@ -import { useMemo } from "react"; - -import Link from "next/link"; - -import { ETHEREUM_EXPLORER_ADDRESS_ENDPOINT_URL } from "@/common/constants"; -import { isEthereumAddress, truncate } from "@/common/lib"; -import type { ByAccountId } from "@/common/types"; -import { cn } from "@/common/ui/layout/utils"; -import { rootPathnames } from "@/pathnames"; - -import { AccountSummaryPopup } from "./AccountSummaryPopup"; -import { useAccountSocialProfile } from "../hooks/social-profile"; - -export type AccountHandleProps = ByAccountId & { - href?: string; - maxLength?: number; - asName?: boolean; - disabledSummaryPopup?: boolean; - className?: string; -}; - -export const AccountHandle: React.FC = ({ - accountId, - maxLength = 32, - href, - asName = false, - disabledSummaryPopup = false, - className, -}) => { - const linkHref = useMemo( - () => - isEthereumAddress(accountId) - ? `${ETHEREUM_EXPLORER_ADDRESS_ENDPOINT_URL}/${accountId}` - : href || `${rootPathnames.PROFILE}/${accountId}`, - - [accountId, href], - ); - - const { profile } = useAccountSocialProfile({ enabled: asName, accountId }); - - return ( - - - {asName && profile?.name - ? truncate(profile?.name, maxLength) - : `@${truncate(accountId, maxLength)}`} - - - ); -}; diff --git a/src/entities/_shared/account/components/card.tsx b/src/entities/_shared/account/components/card.tsx index 1384b63b2..009575054 100644 --- a/src/entities/_shared/account/components/card.tsx +++ b/src/entities/_shared/account/components/card.tsx @@ -12,7 +12,7 @@ import { AccountProfilePicture, type AccountSnapshot, } from "@/entities/_shared/account"; -import { rootPathnames } from "@/pathnames"; +import { rootPathnames } from "@/navigation"; import { AccountCardSkeleton } from "./card-skeleton"; @@ -58,11 +58,12 @@ export const AccountCard = ({ accountId, snapshot, actions }: AccountCardProps) style={{ boxShadow: rootBoxShadow }} data-testid="project-card" > - + {/* Content */}
({ resolver: zodResolver( object({ - accountId: validAccountId.refine( + accountId: nearProtocolSchemas.validAccountId.refine( (accountId) => !accountIds.includes(accountId), "Account with this ID is already listed", ), diff --git a/src/entities/_shared/account/components/AccountGroup.tsx b/src/entities/_shared/account/components/group.tsx similarity index 99% rename from src/entities/_shared/account/components/AccountGroup.tsx rename to src/entities/_shared/account/components/group.tsx index 760c5d8e4..293dad94f 100644 --- a/src/entities/_shared/account/components/AccountGroup.tsx +++ b/src/entities/_shared/account/components/group.tsx @@ -12,7 +12,7 @@ import { AccountProfilePicture, } from "@/entities/_shared/account"; -import { AccountGroupEditModal, AccountGroupEditModalProps } from "./AccountGroupEditModal"; +import { AccountGroupEditModal, AccountGroupEditModalProps } from "./group-edit-modal"; export type AccountGroupProps = Pick & ( diff --git a/src/entities/_shared/account/components/handle.tsx b/src/entities/_shared/account/components/handle.tsx new file mode 100644 index 000000000..ed444d5c0 --- /dev/null +++ b/src/entities/_shared/account/components/handle.tsx @@ -0,0 +1,101 @@ +import { useMemo } from "react"; + +import Link from "next/link"; + +import { ETHEREUM_EXPLORER_ADDRESS_ENDPOINT_URL } from "@/common/constants"; +import { isEthereumAddress, truncate } from "@/common/lib"; +import type { ByAccountId } from "@/common/types"; +import { cn } from "@/common/ui/layout/utils"; +import { rootPathnames } from "@/navigation"; + +import { AccountSummaryPopup } from "./summary-popup"; +import { useAccountSocialProfile } from "../hooks/social-profile"; + +export type AccountHandleProps = ByAccountId & { + href?: string; + + /** + * Maximum content length. `null` disables truncation. + */ + maxLength?: number | null; + + asLink?: boolean; + asName?: boolean; + disabledSummaryPopup?: boolean; + hiddenHandlePrefix?: boolean; + className?: string; +}; + +export const AccountHandle: React.FC = ({ + accountId, + maxLength = 32, + href, + asName = false, + asLink = true, + disabledSummaryPopup: isSummaryPopupDisabled = false, + hiddenHandlePrefix: isHandlePrefixHidden = false, + className, +}) => { + const linkHref = useMemo(() => { + if (asLink) { + return isEthereumAddress(accountId) + ? `${ETHEREUM_EXPLORER_ADDRESS_ENDPOINT_URL}/${accountId}` + : href || `${rootPathnames.PROFILE}/${accountId}`; + } else return null; + }, [accountId, asLink, href]); + + const { profile } = useAccountSocialProfile({ enabled: asName, accountId }); + + const { content, isTruncated } = useMemo(() => { + const isName = asName && profile?.name; + const isTruncated = maxLength !== null && (profile?.name ?? accountId).length > maxLength; + + if (maxLength === null) { + return { + content: isName ? profile.name : `${isHandlePrefixHidden ? "" : "@"}${accountId}`, + isTruncated, + }; + } else { + return { + content: isName + ? truncate(profile?.name as string, maxLength) + : truncate(`${(isHandlePrefixHidden ? "" : "@") + accountId}`, maxLength), + + isTruncated, + }; + } + }, [accountId, asName, isHandlePrefixHidden, maxLength, profile?.name]); + + return ( + + {linkHref === null ? ( + + {content} + + ) : ( + + {content} + + )} + + ); +}; diff --git a/src/entities/_shared/account/components/AccountListItem.tsx b/src/entities/_shared/account/components/list-item.tsx similarity index 53% rename from src/entities/_shared/account/components/AccountListItem.tsx rename to src/entities/_shared/account/components/list-item.tsx index 9410a6bb6..9c2076f41 100644 --- a/src/entities/_shared/account/components/AccountListItem.tsx +++ b/src/entities/_shared/account/components/list-item.tsx @@ -1,32 +1,38 @@ import { useCallback, useMemo } from "react"; -import { truncate } from "@/common/lib"; import { AccountId, ByAccountId } from "@/common/types"; import { cn } from "@/common/ui/layout/utils"; -import { AccountHandle } from "./AccountHandle"; -import { AccountSummaryPopup } from "./AccountSummaryPopup"; +import { AccountHandle, type AccountHandleProps } from "./handle"; import { AccountProfilePicture } from "./profile-images"; +import { AccountSummaryPopup } from "./summary-popup"; import { useAccountSocialProfile } from "../hooks/social-profile"; -export type AccountListItemProps = ByAccountId & { - isRounded?: boolean; - isThumbnail?: boolean; - highlightOnHover?: boolean; - statusElement?: React.ReactNode; - hideStatusOnDesktop?: boolean; - hideStatusOnMobile?: boolean; - disableHandleSummaryPopup?: boolean; - primarySlot?: React.ReactNode; - secondarySlot?: React.ReactNode; - href?: string; - onClick?: (accountId: AccountId) => void; +export type AccountListItemProps = ByAccountId & + Pick & { + isRounded?: boolean; + isThumbnail?: boolean; + highlightOnHover?: boolean; + statusElement?: React.ReactNode; + hiddenStatusOnDesktop?: boolean; + hiddenStatusOnMobile?: boolean; + disableAvatarSummaryPopup?: boolean; + disableHandleSummaryPopup?: boolean; + disableLinks?: boolean; + disableNameSummaryPopup?: boolean; + maxTextLength?: AccountHandleProps["maxLength"]; + primarySlot?: React.ReactNode; + secondarySlot?: React.ReactNode; + href?: string; + onClick?: (accountId: AccountId) => void; - classNames?: { - root?: string; - avatar?: string; + classNames?: { + root?: string; + avatar?: string; + name?: string; + handle?: string; + }; }; -}; export const AccountListItem = ({ isRounded = false, @@ -34,21 +40,26 @@ export const AccountListItem = ({ highlightOnHover = false, accountId, statusElement, - hideStatusOnDesktop = false, - hideStatusOnMobile = false, + hiddenHandlePrefix: isHandlePrefixHidden = false, + hiddenStatusOnDesktop = false, + hiddenStatusOnMobile = false, + disableAvatarSummaryPopup = false, disableHandleSummaryPopup = false, + disableLinks = false, + disableNameSummaryPopup = false, + maxTextLength, primarySlot, secondarySlot, href, onClick, classNames, }: AccountListItemProps) => { - const handleClick = useCallback((): void => void onClick?.(accountId), [accountId, onClick]); const { profile } = useAccountSocialProfile({ accountId }); + const handleClick = useCallback((): void => void onClick?.(accountId), [accountId, onClick]); const avatarElement = useMemo( () => ( - +
), - [accountId, classNames?.avatar], + [accountId, classNames?.avatar, disableAvatarSummaryPopup], ); return isThumbnail ? ( @@ -84,12 +95,19 @@ export const AccountListItem = ({ "max-w-150": Boolean(statusElement), })} > - - {truncate(profile?.name ?? accountId, 38)} - + {profile?.name === undefined ? null : ( + + )} {statusElement && ( -
+
{statusElement}
)} @@ -97,13 +115,20 @@ export const AccountListItem = ({
{statusElement && ( - + {statusElement} )} diff --git a/src/entities/_shared/account/components/profile-images.tsx b/src/entities/_shared/account/components/profile-images.tsx index 07e1bd5a3..942765068 100644 --- a/src/entities/_shared/account/components/profile-images.tsx +++ b/src/entities/_shared/account/components/profile-images.tsx @@ -1,21 +1,24 @@ -import { LazyLoadImage, LazyLoadImageProps } from "react-lazy-load-image-component"; +import type { LazyLoadImageProps } from "react-lazy-load-image-component"; -import { ByAccountId } from "@/common/types"; +import { ByAccountId, type LiveUpdateParams } from "@/common/types"; import { Avatar, AvatarImage, Skeleton } from "@/common/ui/layout/components"; +import { LazyImage } from "@/common/ui/layout/components/LazyImage"; import { cn } from "@/common/ui/layout/utils"; import { ACCOUNT_PROFILE_COVER_IMAGE_PLACEHOLDER_SRC } from "../constants"; import { useAccountSocialProfile } from "../hooks/social-profile"; -export type AccountProfilePictureProps = ByAccountId & { - className?: string; -}; +export type AccountProfilePictureProps = ByAccountId & + LiveUpdateParams & { + className?: string; + }; export const AccountProfilePicture: React.FC = ({ + live = false, accountId, className, }) => { - const { isLoading, avatar } = useAccountSocialProfile({ accountId }); + const { isLoading, avatar } = useAccountSocialProfile({ live, accountId }); return isLoading ? ( @@ -32,7 +35,8 @@ export const AccountProfilePicture: React.FC = ({ }; export type AccountProfileCoverProps = ByAccountId & - Required> & { + Required> & + LiveUpdateParams & { className?: string; }; @@ -40,11 +44,13 @@ const contentClassName = "h-full w-full object-cover transition-transform duration-500 ease-in-out hover:scale-110"; export const AccountProfileCover: React.FC = ({ + live = false, accountId, height, className, }) => { const { isLoading: isProfileDataLoading, cover } = useAccountSocialProfile({ + live, accountId, }); @@ -52,7 +58,7 @@ export const AccountProfileCover: React.FC = ({ ) : (
- = ({ - - {truncate(profile?.name ?? accountId, 32)} + 20 + ? "max-w-[140px] overflow-hidden text-ellipsis whitespace-nowrap" + : "w-fit", + classNames?.name, + )} + title={profile?.name ?? accountId} + > + {profile?.name ?? accountId} diff --git a/src/entities/_shared/account/components/AccountProfileLinktree.tsx b/src/entities/_shared/account/components/profile-linktree.tsx similarity index 100% rename from src/entities/_shared/account/components/AccountProfileLinktree.tsx rename to src/entities/_shared/account/components/profile-linktree.tsx diff --git a/src/entities/_shared/account/components/AccountProfileTags.tsx b/src/entities/_shared/account/components/profile-tags.tsx similarity index 100% rename from src/entities/_shared/account/components/AccountProfileTags.tsx rename to src/entities/_shared/account/components/profile-tags.tsx diff --git a/src/entities/_shared/account/components/AccountSummaryPopup.tsx b/src/entities/_shared/account/components/summary-popup.tsx similarity index 100% rename from src/entities/_shared/account/components/AccountSummaryPopup.tsx rename to src/entities/_shared/account/components/summary-popup.tsx diff --git a/src/entities/_shared/account/constants.ts b/src/entities/_shared/account/constants.ts index 9d3b15f06..1ebb38156 100644 --- a/src/entities/_shared/account/constants.ts +++ b/src/entities/_shared/account/constants.ts @@ -56,7 +56,7 @@ export const ACCOUNT_CATEGORY_OPTIONS: AccountCategoryOption[] = [ ]; export const ACCOUNT_REGISTRATION_STATUSES: Record< - RegistrationStatus, + RegistrationStatus | "Unregistered", { background: string; text: string; diff --git a/src/entities/_shared/account/hooks/modals.ts b/src/entities/_shared/account/hooks/modals.ts index e6adeec17..e9eb2db0a 100644 --- a/src/entities/_shared/account/hooks/modals.ts +++ b/src/entities/_shared/account/hooks/modals.ts @@ -2,10 +2,7 @@ import { useCallback } from "react"; import { useModal } from "@ebay/nice-modal-react"; -import { - AccountGroupEditModal, - AccountGroupEditModalProps, -} from "../components/AccountGroupEditModal"; +import { AccountGroupEditModal, AccountGroupEditModalProps } from "../components/group-edit-modal"; // TODO: Remove if not needed export const useAccountGroupEditModal = (params: AccountGroupEditModalProps) => { diff --git a/src/entities/_shared/account/index.ts b/src/entities/_shared/account/index.ts index 673915635..e07f4f4e6 100644 --- a/src/entities/_shared/account/index.ts +++ b/src/entities/_shared/account/index.ts @@ -1,28 +1,28 @@ export * from "./types"; export * from "./constants"; -export * from "./components/AccountFollowButton"; -export * from "./components/AccountGroup"; -export * from "./components/AccountFollowStats"; -export * from "./components/AccountHandle"; -export * from "./components/AccountListItem"; -export * from "./components/AccountProfileLink"; -export * from "./components/AccountProfileLinktree"; -export * from "./components/AccountProfileTags"; -export * from "./components/AccountSummaryPopup"; export * from "./components/card"; export * from "./components/card-skeleton"; +export * from "./components/follow-button"; +export * from "./components/follow-stats"; export * from "./components/github-repos"; +export * from "./components/group"; +export * from "./components/handle"; +export * from "./components/list-item"; export * from "./components/profile-images"; +export * from "./components/profile-link"; +export * from "./components/profile-linktree"; +export * from "./components/profile-tags"; export * from "./components/smart-contracts"; +export * from "./components/summary-popup"; export * from "./hooks/power"; export * from "./hooks/social-profile"; -export * from "./model/schemas"; +export * from "./model/effects"; export * from "./utils/linktree"; //! Only exported for backward compatibility // TODO!: Stop using the model component directly and use the `AccountGroup` integrated flow instead -export * from "./components/AccountGroupEditModal"; +export * from "./components/group-edit-modal"; diff --git a/src/entities/_shared/account/model/schemas.ts b/src/entities/_shared/account/model/effects.ts similarity index 52% rename from src/entities/_shared/account/model/schemas.ts rename to src/entities/_shared/account/model/effects.ts index fa6764510..15bc58afb 100644 --- a/src/entities/_shared/account/model/schemas.ts +++ b/src/entities/_shared/account/model/effects.ts @@ -1,25 +1,13 @@ -import { AccountView } from "near-api-js/lib/providers/provider"; -import { string } from "zod"; +import type { AccountView } from "near-api-js/lib/providers/provider"; import { NETWORK } from "@/common/_config"; -import { near, nearRpc } from "@/common/blockchains/near-protocol/client"; - -const primitive = string().min(5, "Account ID is too short"); - -export const validAccountId = primitive.refine(near.isAccountValid, { - message: `Account does not exist on ${NETWORK}`, -}); - -export const validAccountIdOrNothing = primitive - .optional() - .nullable() - .refine( - async (accountId) => - typeof accountId === "string" ? await near.isAccountValid(accountId) : true, - - { message: `Account does not exist on ${NETWORK}` }, - ); +// eslint-disable-next-line @typescript-eslint/no-unused-vars +import { nearProtocolSchemas } from "@/common/blockchains/near-protocol"; +import { nearRpc } from "@/common/blockchains/near-protocol/client"; +/** + * @deprecated use {@link nearProtocolSchemas.validAccountId} for form field validation instead! + */ export const validateAccountId = (accountId: string): Promise => { // Check if the account ID is at least 5 characters long if (accountId.length < 5) { diff --git a/src/entities/_shared/token/components/selector.tsx b/src/entities/_shared/token/components/selector.tsx index 1dfccbb95..3f1ff0c6f 100644 --- a/src/entities/_shared/token/components/selector.tsx +++ b/src/entities/_shared/token/components/selector.tsx @@ -61,17 +61,22 @@ export type TokenSelectorProps = Pick & (ControlledSelectFieldProps | UncontrolledSelectFieldProps) & { hideBalances?: boolean; hideZeroBalanceOptions?: boolean; + showBalanceOnlyForNative?: boolean; }; export const TokenSelector: React.FC = ({ hideBalances = false, hideZeroBalanceOptions = false, + showBalanceOnlyForNative = false, ...props }) => { const { data: tokenAllowlist } = useFungibleTokenAllowlist({ enabled: FEATURE_REGISTRY.FtDonation.isEnabled, }); + const shouldShowBalanceForNative = !hideBalances; + const shouldShowBalanceForOthers = !hideBalances && !showBalanceOnlyForNative; + return ( // TODO: Move FormField wrapper from target parent layouts to here //* But do not forget to account for ability to use this component without forms @@ -84,13 +89,13 @@ export const TokenSelector: React.FC = ({ }} {...props} > - + {tokenAllowlist.map((tokenAccountId) => ( ))} diff --git a/src/entities/_shared/token/hooks/fungible.ts b/src/entities/_shared/token/hooks/fungible.ts index dfc6b55e7..9a3eee210 100644 --- a/src/entities/_shared/token/hooks/fungible.ts +++ b/src/entities/_shared/token/hooks/fungible.ts @@ -131,16 +131,16 @@ export const useFungibleToken = ({ usdPrice: oneTokenUsdPrice ? Big(oneTokenUsdPrice) : undefined, balance: accountSummary?.amount - ? indivisibleUnitsToBigNum(accountSummary.amount, ntMetadata.decimals) + ? indivisibleUnitsToBigNum(accountSummary.amount.toString(), ntMetadata.decimals) : undefined, balanceFloat: accountSummary?.amount - ? indivisibleUnitsToFloat(accountSummary.amount, ntMetadata.decimals) + ? indivisibleUnitsToFloat(accountSummary.amount.toString(), ntMetadata.decimals) : undefined, balanceUsd: accountSummary?.amount && oneTokenUsdPrice - ? Big(accountSummary.amount).mul(oneTokenUsdPrice) + ? Big(accountSummary.amount.toString()).mul(oneTokenUsdPrice) : undefined, }, }; diff --git a/src/entities/campaign/components/CampaignBanner.tsx b/src/entities/campaign/components/CampaignBanner.tsx index 9bfd192ea..d8cefd56c 100644 --- a/src/entities/campaign/components/CampaignBanner.tsx +++ b/src/entities/campaign/components/CampaignBanner.tsx @@ -1,17 +1,19 @@ import { useMemo } from "react"; import { BadgeCheck, CircleAlert } from "lucide-react"; -import { LazyLoadImage } from "react-lazy-load-image-component"; import { isNonNullish, isNullish } from "remeda"; import { Temporal } from "temporal-polyfill"; import { PLATFORM_NAME } from "@/common/_config"; +import { V1CampaignsRetrieveStatus } from "@/common/api/indexer"; import { NATIVE_TOKEN_ID, PLATFORM_TWITTER_ACCOUNT_ID } from "@/common/constants"; import { campaignsContractHooks } from "@/common/contracts/core/campaigns"; import { indivisibleUnitsToFloat } from "@/common/lib"; +import { toTimestamp } from "@/common/lib/datetime"; import getTimePassed from "@/common/lib/getTimePassed"; import type { ByCampaignId } from "@/common/types"; import { Button, SocialsShare, Spinner } from "@/common/ui/layout/components"; +import { LazyImage } from "@/common/ui/layout/components/LazyImage"; import { BadgeIcon } from "@/common/ui/layout/svg/BadgeIcon"; import { cn } from "@/common/ui/layout/utils"; import { useWalletUserSession } from "@/common/wallet"; @@ -21,25 +23,33 @@ import { DonateToCampaign } from "@/features/donation"; import { CampaignProgressBar } from "./CampaignProgressBar"; import { useCampaignForm } from "../hooks/forms"; +import { mapContractCampaignToIndexerFormat } from "../utils/contract-campaign"; export type CampaignBannerProps = ByCampaignId & {}; export const CampaignBanner: React.FC = ({ campaignId }) => { const viewer = useWalletUserSession(); - const { - isLoading: isCampaignLoading, - data: campaign, - error: campaignLoadingError, - } = campaignsContractHooks.useCampaign({ campaignId }); + const { data: contractCampaign, isLoading: isContractLoading } = + campaignsContractHooks.useCampaign({ + campaignId, + enabled: true, + }); + + const campaign = useMemo( + () => (contractCampaign ? mapContractCampaignToIndexerFormat(contractCampaign) : undefined), + [contractCampaign], + ); + + const isCampaignLoading = isContractLoading; - const { data: token } = useFungibleToken({ tokenId: campaign?.ft_id ?? NATIVE_TOKEN_ID }); + const { data: token } = useFungibleToken({ tokenId: campaign?.token.account ?? NATIVE_TOKEN_ID }); const raisedAmountFloat = useMemo( () => token === undefined || campaign === undefined ? 0 - : indivisibleUnitsToFloat(campaign.total_raised_amount, token.metadata.decimals), + : indivisibleUnitsToFloat(campaign?.net_raised_amount ?? "0", token.metadata.decimals), [campaign, token], ); @@ -53,20 +63,17 @@ export const CampaignBanner: React.FC = ({ campaignId }) => [campaign?.min_amount, token], ); - const { data: hasEscrowedDonations } = campaignsContractHooks.useHasEscrowedDonationsToProcess({ - campaignId, - enabled: isNonNullish(campaign?.min_amount) && raisedAmountFloat >= minAmountFloat, - }); + const { data: hasEscrowedDonations, isLoading: isHasEscrowedDonationsLoading } = + campaignsContractHooks.useHasEscrowedDonationsToProcess({ + campaignId, + enabled: true, // Always enable the hook + }); - const { data: isDonationRefundsProcessed } = campaignsContractHooks.useIsDonationRefundsProcessed( - { + const { data: isDonationRefundsProcessed, isLoading: isDonationRefundsProcessedLoading } = + campaignsContractHooks.useIsDonationRefundsProcessed({ campaignId, - enabled: - !!campaign?.end_ms && - campaign?.end_ms < Temporal.Now.instant().epochMilliseconds && - raisedAmountFloat < minAmountFloat, - }, - ); + enabled: true, // Always enable the hook + }); const { handleProcessEscrowedDonations, handleDonationsRefund } = useCampaignForm({ campaignId }); @@ -79,181 +86,210 @@ export const CampaignBanner: React.FC = ({ campaignId }) => [raisedAmountFloat, token?.usdPrice], ); - if (campaignLoadingError) { - return

Error Loading Campaign

; - } - - const isStarted = getTimePassed(Number(campaign?.start_ms), true)?.includes("-"); + const isStarted = getTimePassed(toTimestamp(campaign?.start_at ?? 0), true)?.includes("-"); - const isEnded = campaign?.end_ms - ? getTimePassed(Number(campaign?.end_ms), false, true)?.includes("-") + const isEnded = campaign?.end_at + ? getTimePassed(toTimestamp(campaign?.end_at), false, true)?.includes("-") : false; + // Check if the hooks are still loading + const isProcessingHooksLoading = + isHasEscrowedDonationsLoading || isDonationRefundsProcessedLoading; + return isCampaignLoading ? (
) : ( -
-
-
- -
{" "} -
-

{campaign?.name}

- -
-
-
-

FOR

- - -
+
+
+
+
+ +
{" "} +
+

{campaign?.name}

-
- {" "} -
+
+
+
+

FOR

+ + +
-
-

ORGANIZED BY

- -
-
+
+ {" "} +
- {campaign?.owner === campaign?.recipient && ( -
- - OFFICIAL +
+ ORGANIZED BY + +
- )} + + {campaign?.owner?.id === campaign?.recipient?.id && ( +
+ + OFFICIAL +
+ )} +
-
-

{campaign?.description}

-
+
{ + // Prevent navigation when clicking on links + if (event.target instanceof HTMLAnchorElement) { + event.stopPropagation(); + } + }} + /> +
-
-
-

- {"TOTAL AMOUNT RAISED"} -

+
+
+

+ {"TOTAL AMOUNT RAISED"} +

-
-

- {`${raisedAmountFloat} ${token?.metadata.symbol ?? ""}`} -

+
+

+ {`${raisedAmountFloat} ${token?.metadata.symbol ?? ""}`} +

- {raisedAmountUsdApproximation && ( -

{raisedAmountUsdApproximation}

- )} + {raisedAmountUsdApproximation && ( +

{raisedAmountUsdApproximation}

+ )} +
-
- - -
- {viewer.isSignedIn && hasEscrowedDonations && ( -
- - -
- - -
-

Campaign Successful

+ -

- The Minimum Target of the Campaign has been successfully reach and the Donations - can be processed. -

+
+ {viewer.isSignedIn && + !isProcessingHooksLoading && + hasEscrowedDonations && + isNonNullish(campaign?.min_amount) && + raisedAmountFloat >= minAmountFloat && ( +
+ + +
+ + +
+

Campaign Successful

+ +

+ The Minimum Target of the Campaign has been successfully reach and the + Donations can be processed. +

+
+
-
-
- )} - - {viewer.isSignedIn && - isDonationRefundsProcessed && - campaign?.end_ms && - campaign?.end_ms < Temporal.Now.instant().epochMilliseconds && - raisedAmountFloat < minAmountFloat && ( -
- - -
- - -
-

Campaign Ended

- -

- {`The campaign has finished and did not meet its minimum goal of ${ - minAmountFloat - } ${ - token?.metadata.symbol ?? "" - }. Initiate the Reverse Process to refund donors.`} -

+ )} + + {viewer.isSignedIn && + !isProcessingHooksLoading && + isDonationRefundsProcessed && + campaign?.end_at && + toTimestamp(campaign?.end_at ?? 0) < Temporal.Now.instant().epochMilliseconds && + raisedAmountFloat < minAmountFloat && ( +
+ + +
+ + +
+

Campaign Ended

+ +

+ {`The campaign has finished and did not meet its minimum goal of ${ + minAmountFloat + } ${ + token?.metadata.symbol ?? "" + }. Initiate the Reverse Process to refund donors.`} +

+
+ )} + + {!isProcessingHooksLoading && + (!hasEscrowedDonations || + !isNonNullish(campaign?.min_amount) || + raisedAmountFloat < minAmountFloat) && + (!isDonationRefundsProcessed || + !campaign?.end_at || + toTimestamp(campaign?.end_at ?? 0) >= Temporal.Now.instant().epochMilliseconds || + raisedAmountFloat >= minAmountFloat) && ( + <> + + + + + )} + + {isProcessingHooksLoading && ( +
+
)} - - {!hasEscrowedDonations && !isDonationRefundsProcessed && ( - <> - - - - - )} +
diff --git a/src/entities/campaign/components/CampaignCard.tsx b/src/entities/campaign/components/CampaignCard.tsx index 4e3c1e808..6937f4d24 100644 --- a/src/entities/campaign/components/CampaignCard.tsx +++ b/src/entities/campaign/components/CampaignCard.tsx @@ -1,10 +1,11 @@ import Link from "next/link"; -import { LazyLoadImage } from "react-lazy-load-image-component"; +import { Campaign, V1CampaignsRetrieveStatus } from "@/common/api/indexer"; import { NATIVE_TOKEN_ID } from "@/common/constants"; -import { Campaign } from "@/common/contracts/core/campaigns"; -import { truncate } from "@/common/lib"; +import { truncateHtml } from "@/common/lib"; +import { toTimestamp } from "@/common/lib/datetime"; import getTimePassed from "@/common/lib/getTimePassed"; +import { LazyImage } from "@/common/ui/layout/components/LazyImage"; import { BadgeIcon } from "@/common/ui/layout/svg/BadgeIcon"; import { cn } from "@/common/ui/layout/utils"; import { AccountProfileLink } from "@/entities/_shared/account"; @@ -13,11 +14,9 @@ import { DonateToCampaign } from "@/features/donation"; import { CampaignProgressBar } from "./CampaignProgressBar"; export const CampaignCard = ({ data }: { data: Campaign }) => { - const isStarted = getTimePassed(Number(data.start_ms), true)?.includes("-"); + const isStarted = getTimePassed(toTimestamp(data.start_at), true)?.includes("-"); - const isEnded = data?.end_ms - ? getTimePassed(Number(data?.end_ms), false, true)?.includes("-") - : false; + const isEnded = getTimePassed(toTimestamp(data.end_at ?? 0), false, true)?.includes("-"); return (
{ "transition-all duration-500 hover:shadow-[0_6px_10px_rgba(0,0,0,0.2)]", )} > - +
- {

{data.name}

- {data?.owner === data?.recipient && ( + {data?.owner?.id === data?.recipient?.id && (
OFFICIAL @@ -55,31 +54,49 @@ export const CampaignCard = ({ data }: { data: Campaign }) => {
e.stopPropagation()}>
-
-

{data.description ? truncate(data.description, 160) : ""}

+
+
{ + // Prevent navigation when clicking on links + if (event.target instanceof HTMLAnchorElement) { + event.stopPropagation(); + } + }} + />
diff --git a/src/entities/campaign/components/CampaignCardSkeleton.tsx b/src/entities/campaign/components/CampaignCardSkeleton.tsx new file mode 100644 index 000000000..9505ed270 --- /dev/null +++ b/src/entities/campaign/components/CampaignCardSkeleton.tsx @@ -0,0 +1,57 @@ +import { Skeleton } from "@/common/ui/layout/components"; +import { cn } from "@/common/ui/layout/utils"; + +export const CampaignCardSkeleton = () => { + return ( +
+ {/* Cover Image Skeleton */} +
+ + {/* Title overlay skeleton */} +
+ +
+
+ + {/* Content Skeleton */} +
+ {/* FOR section skeleton */} +
+ +
+ + +
+
+ + {/* Description skeleton */} +
+ + + +
+ + {/* Progress bar skeleton */} +
+
+ + +
+ +
+ + +
+
+ + {/* Donate button skeleton */} + +
+
+ ); +}; diff --git a/src/entities/campaign/components/CampaignCarouselItem.tsx b/src/entities/campaign/components/CampaignCarouselItem.tsx index 883e971f2..70255d07c 100644 --- a/src/entities/campaign/components/CampaignCarouselItem.tsx +++ b/src/entities/campaign/components/CampaignCarouselItem.tsx @@ -1,11 +1,10 @@ import Link from "next/link"; -import { LazyLoadImage } from "react-lazy-load-image-component"; +import { Campaign, V1CampaignsRetrieveStatus } from "@/common/api/indexer"; import { NATIVE_TOKEN_ID } from "@/common/constants"; -import { Campaign } from "@/common/contracts/core/campaigns"; -import { truncate } from "@/common/lib"; -import getTimePassed from "@/common/lib/getTimePassed"; +import { toTimestamp } from "@/common/lib/datetime"; import { CarouselItem } from "@/common/ui/layout/components"; +import { LazyImage } from "@/common/ui/layout/components/LazyImage"; import { BadgeIcon } from "@/common/ui/layout/svg/BadgeIcon"; import { AccountProfileLink } from "@/entities/_shared/account"; import { DonateToCampaign } from "@/features/donation"; @@ -13,23 +12,18 @@ import { DonateToCampaign } from "@/features/donation"; import { CampaignProgressBar } from "./CampaignProgressBar"; export const CampaignCarouselItem = ({ data }: { data: Campaign }) => { - const isStarted = getTimePassed(Number(data.start_ms), true)?.includes("-"); - - const isEnded = data?.end_ms - ? getTimePassed(Number(data?.end_ms), false, true)?.includes("-") - : false; - return ( - +
- {

FOR

- +
{" "}

ORGANIZED BY

- +
- {data?.owner === data?.recipient && ( + {data?.owner?.id === data?.recipient?.id && (
OFFICIAL @@ -62,23 +56,41 @@ export const CampaignCarouselItem = ({ data }: { data: Campaign }) => {

- {data?.description ? truncate(data.description, 100) : ""} +

{ + // Prevent navigation when clicking on links + if (event.target instanceof HTMLAnchorElement) { + event.stopPropagation(); + } + }} + />

diff --git a/src/entities/campaign/components/CampaignDonorsTable.tsx b/src/entities/campaign/components/CampaignDonorsTable.tsx index 5ff0d2cc1..5674c1954 100644 --- a/src/entities/campaign/components/CampaignDonorsTable.tsx +++ b/src/entities/campaign/components/CampaignDonorsTable.tsx @@ -5,13 +5,13 @@ import Link from "next/link"; import { NATIVE_TOKEN_DECIMALS, NATIVE_TOKEN_ID } from "@/common/constants"; import { CampaignDonation, campaignsContractHooks } from "@/common/contracts/core/campaigns"; -import { indivisibleUnitsToFloat, oldToRecent } from "@/common/lib"; +import { indivisibleUnitsToFloat, oldToRecent, truncate } from "@/common/lib"; import getTimePassed from "@/common/lib/getTimePassed"; import type { ByCampaignId } from "@/common/types"; import { DataTable } from "@/common/ui/layout/components"; import { AccountProfilePicture } from "@/entities/_shared/account"; import { TokenIcon, useFungibleToken } from "@/entities/_shared/token"; -import { rootPathnames } from "@/pathnames"; +import { rootPathnames } from "@/navigation"; export type CampaignDonorsTableProps = ByCampaignId & {}; @@ -30,23 +30,46 @@ export const CampaignDonorsTable: React.FC = ({ campai { header: "Donor", accessorKey: "donor_id", - cell: ({ row }) => ( - - - {row.original.donor_id} + cell: ({ row }) => { + const donorId = row.original.donor_id; + const isIntent = donorId.includes("potluck_intents.near"); - {row.original?.returned_at_ms && ( -

- Refunded -

- )} - - ), + const explorerUrl = isIntent + ? `https://nearblocks.io/address/${donorId}` + : `${rootPathnames.PROFILE}/${donorId}`; + + const content = ( + <> + + {isIntent ? truncate(donorId, 25) : donorId} + + {isIntent && ( + + Intent + + )} + + {row.original?.returned_at_ms && ( +

+ Refunded +

+ )} + + ); + + return ( +
+ + {content} + +
+ ); + }, }, { diff --git a/src/entities/campaign/components/CampaignFinishModal.tsx b/src/entities/campaign/components/CampaignFinishModal.tsx index 1fe435664..6c9d5ada1 100644 --- a/src/entities/campaign/components/CampaignFinishModal.tsx +++ b/src/entities/campaign/components/CampaignFinishModal.tsx @@ -6,7 +6,7 @@ import { useRouter } from "next/router"; import { Button, DialogContent, DialogDescription } from "@/common/ui/layout/components"; import SuccessRedIcon from "@/common/ui/layout/svg/success-red-icon"; -import { dispatch } from "@/store"; +import { useDispatch } from "@/store/hooks"; import { useCampaignActionState } from "../models"; import { SuccessCampaignModal } from "./SuccessCampaignModal"; @@ -15,6 +15,7 @@ import { CampaignEnumType } from "../types"; export const CampaignFinishModal = create(() => { const self = useModal(); const { push } = useRouter(); + const dispatch = useDispatch(); const { type, @@ -60,7 +61,7 @@ export const CampaignFinishModal = create(() => { onClose={close} header={header} description={description} - onViewCampaign={() => push(`/campaign/${data?.id}/leaderboard`)} + onViewCampaign={() => push(`/campaign/${data?.id}`)} /> ); }); diff --git a/src/entities/campaign/components/CampaignProgressBar.tsx b/src/entities/campaign/components/CampaignProgressBar.tsx index 15fa10433..424750244 100644 --- a/src/entities/campaign/components/CampaignProgressBar.tsx +++ b/src/entities/campaign/components/CampaignProgressBar.tsx @@ -4,6 +4,7 @@ import { Big } from "big.js"; import { TimerIcon } from "lucide-react"; import { isNullish } from "remeda"; +import { V1CampaignsRetrieveStatus } from "@/common/api/indexer"; import type { Campaign } from "@/common/contracts/core/campaigns"; import { indivisibleUnitsToFloat } from "@/common/lib"; import getTimePassed from "@/common/lib/getTimePassed"; @@ -16,9 +17,9 @@ export type CampaignProgressBarProps = ByTokenId & { amount: Campaign["total_raised_amount"]; minAmount: Campaign["min_amount"]; endDate?: number; - isStarted: boolean; startDate: number; isEscrowBalanceEmpty: boolean; + status: V1CampaignsRetrieveStatus; }; export const CampaignProgressBar: React.FC = ({ @@ -27,8 +28,8 @@ export const CampaignProgressBar: React.FC = ({ minAmount, amount, endDate, + status, isEscrowBalanceEmpty, - isStarted, startDate, }) => { const { data: token } = useFungibleToken({ tokenId }); @@ -57,6 +58,12 @@ export const CampaignProgressBar: React.FC = ({ [raisedAmountFloat, targetAmountFloat], ); + // Exact progress used for geometry; rounded percentage used for indicator value + const progressExact = useMemo( + () => (targetAmountFloat ? (raisedAmountFloat / targetAmountFloat) * 100 : 0), + [raisedAmountFloat, targetAmountFloat], + ); + const progressPercentage = useMemo( () => Math.min( @@ -103,19 +110,19 @@ export const CampaignProgressBar: React.FC = ({ }, [raisedAmountFloat, minAmountFloat, isTargetMet]); const timeLeft = endDate ? getTimePassed(endDate, false, true) : null; - const isTimeUp = timeLeft?.includes("-"); + // const isTimeUp = timeLeft?.includes("-"); const statusText = useMemo(() => { - if ((isTargetMet && endDate && endDate < Date.now()) || isTimeUp) { + if (status === "ended" && endDate) { return endDate ? `ENDED (${getTimePassed(endDate, false)} ago)` : "ENDED"; - } else if (isStarted) { + } else if (status === "upcoming") { return `Starts in ${getTimePassed(startDate, false, true)}`; } else if (timeLeft) { return `${timeLeft} left`; } else { return "ONGOING"; } - }, [isTargetMet, endDate, isTimeUp, isStarted, timeLeft, startDate]); + }, [status, endDate, timeLeft, startDate]); const amountDisplay = useMemo( () => ( @@ -132,7 +139,7 @@ export const CampaignProgressBar: React.FC = ({ ); const titleContent = useMemo(() => { - if (isTimeUp) { + if (status === "ended") { let message; if (raisedAmountFloat && !isTargetMet && raisedAmountFloat < minAmountFloat) { @@ -182,7 +189,6 @@ export const CampaignProgressBar: React.FC = ({ } }, [ amountDisplay, - isTimeUp, isTargetMet, raisedAmountFloat, minAmountFloat, @@ -202,16 +208,28 @@ export const CampaignProgressBar: React.FC = ({ minArrowColor={minArrowColor} baseColor={baseColor} minAmount={`${minAmountFloat} ${token?.metadata.symbol ?? ""}`} - minValuePercentage={ - minAmountFloat - ? Math.floor( - Big(minAmountFloat) - .div(targetAmountFloat || 1) - .mul(100) - .toNumber(), - ) - : undefined - } + minValuePercentage={((): number | undefined => { + if (!minAmountFloat || !targetAmountFloat) return undefined; + + // Compute geometric min arrow position + const rawMinPercent = (minAmountFloat / targetAmountFloat) * 100; + const LEFT_PAD_PCT = 3; + const RIGHT_PAD_PCT = 3; + + const clampedMinPercent = Math.max( + LEFT_PAD_PCT, + Math.min(100 - RIGHT_PAD_PCT, rawMinPercent), + ); + + const PASSED_DELTA = 0.5; + + const minArrowPercent = + progressExact >= clampedMinPercent + ? Math.min(clampedMinPercent, Math.max(LEFT_PAD_PCT, progressExact - PASSED_DELTA)) + : clampedMinPercent; + + return minArrowPercent; + })()} value={progressPercentage} bgColor={color} /> @@ -228,7 +246,7 @@ export const CampaignProgressBar: React.FC = ({

- {progressPercentage}% + {progressExact > 100 ? "> 100%" : `${progressPercentage}%`}

diff --git a/src/entities/campaign/components/CampaignSettings.tsx b/src/entities/campaign/components/CampaignSettings.tsx index 54764b67d..f8bce861f 100644 --- a/src/entities/campaign/components/CampaignSettings.tsx +++ b/src/entities/campaign/components/CampaignSettings.tsx @@ -7,6 +7,7 @@ import { Temporal } from "temporal-polyfill"; import { NATIVE_TOKEN_ID } from "@/common/constants"; import { campaignsContractHooks } from "@/common/contracts/core/campaigns"; import { indivisibleUnitsToFloat } from "@/common/lib"; +import { toTimestamp } from "@/common/lib/datetime"; import type { ByCampaignId } from "@/common/types"; import { Skeleton, Spinner } from "@/common/ui/layout/components"; import { useWalletUserSession } from "@/common/wallet"; @@ -14,9 +15,11 @@ import { AccountProfilePicture } from "@/entities/_shared/account"; import { TokenIcon, useFungibleToken } from "@/entities/_shared/token"; import { CampaignEditor } from "./editor"; +import { mapContractCampaignToIndexerFormat } from "../utils/contract-campaign"; -const formatTime = (timestamp: number) => - new Date(timestamp).toLocaleString("en-US", { +const formatTime = (dateValue: string | number) => { + const date = typeof dateValue === "string" ? new Date(dateValue) : new Date(dateValue); + return date.toLocaleString("en-US", { year: "numeric", month: "long", day: "numeric", @@ -24,6 +27,7 @@ const formatTime = (timestamp: number) => minute: "2-digit", hour12: true, }); +}; const CampaignSettingsBarCard = ({ title, @@ -55,13 +59,20 @@ export const CampaignSettings: React.FC = ({ campaignId } const [openEditCampaign, setOpenEditCampaign] = useState(false); const closeEditor = useCallback(() => setOpenEditCampaign(false), []); - const { - isLoading: isCampaignLoading, - data: campaign, - error: campaignLoadingError, - } = campaignsContractHooks.useCampaign({ campaignId }); + const { data: contractCampaign, isLoading: isCampaignLoading } = + campaignsContractHooks.useCampaign({ + campaignId, + enabled: true, + }); + + const campaign = useMemo( + () => (contractCampaign ? mapContractCampaignToIndexerFormat(contractCampaign) : undefined), + [contractCampaign], + ); - const { data: token } = useFungibleToken({ tokenId: campaign?.ft_id ?? NATIVE_TOKEN_ID }); + const { data: token } = useFungibleToken({ + tokenId: campaign?.token?.account ?? NATIVE_TOKEN_ID, + }); const minAmountFloat = useMemo( () => @@ -91,11 +102,11 @@ export const CampaignSettings: React.FC = ({ campaignId } ); const tokenIcon = useMemo( - () => , - [campaign?.ft_id], + () => , + [campaign?.token?.account], ); - if (campaign === undefined && campaignLoadingError) + if (campaign === undefined && !isCampaignLoading) return (

This Campaign does not exist

@@ -115,11 +126,11 @@ export const CampaignSettings: React.FC = ({ campaignId } - -

{campaign?.owner}

+ +

{campaign?.owner?.id}

@@ -128,22 +139,23 @@ export const CampaignSettings: React.FC = ({ campaignId } -

{campaign?.recipient}

+

{campaign?.recipient?.id}

{viewer.isSignedIn && - viewer.accountId === campaign?.owner && - (!campaign?.end_ms || Temporal.Now.instant().epochMilliseconds < campaign.end_ms) && ( + viewer.accountId === campaign?.owner?.id && + (!campaign?.end_at || + Temporal.Now.instant().epochMilliseconds < toTimestamp(campaign.end_at)) && (

setOpenEditCampaign(!openEditCampaign)} @@ -161,7 +173,18 @@ export const CampaignSettings: React.FC = ({ campaignId }

{campaign?.name}

-

{campaign?.description}

+
{ + // Prevent navigation when clicking on links + if (event.target instanceof HTMLAnchorElement) { + event.stopPropagation(); + } + }} + />
@@ -175,8 +198,8 @@ export const CampaignSettings: React.FC = ({ campaignId } ) : ( @@ -215,6 +238,10 @@ export const CampaignSettings: React.FC = ({ campaignId } : "N/A" }`} /> +
) : ( diff --git a/src/entities/campaign/components/CampaignsList.tsx b/src/entities/campaign/components/CampaignsList.tsx index 034d1b228..f8925145a 100644 --- a/src/entities/campaign/components/CampaignsList.tsx +++ b/src/entities/campaign/components/CampaignsList.tsx @@ -1,16 +1,28 @@ -import { useEffect, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; -import { Campaign } from "@/common/contracts/core/campaigns"; -import { SearchBar, SortSelect, Spinner } from "@/common/ui/layout/components"; +import { Campaign } from "@/common/api/indexer"; +import { + Filter, + Pagination, + PaginationContent, + PaginationEllipsis, + PaginationItem, + PaginationLink, + PaginationNext, + PaginationPrevious, + SearchBar, + SortSelect, +} from "@/common/ui/layout/components"; import { CampaignCard } from "./CampaignCard"; +import { CampaignCardSkeleton } from "./CampaignCardSkeleton"; import { useAllCampaignLists } from "../hooks/useCampaigns"; export const CampaignsList = () => { const [search, setSearch] = useState(""); const [filteredCampaigns, setFilteredCampaigns] = useState([]); - const { buttons, campaigns, loading, currentTab } = useAllCampaignLists(); + const { buttons, campaigns, loading, currentTab, tagsList, pagination } = useAllCampaignLists(); const SORT_LIST_PROJECTS = [ { label: "Newest", value: "recent" }, @@ -22,12 +34,12 @@ export const CampaignsList = () => { switch (sortType) { case "recent": - projects.sort((a, b) => new Date(b.start_ms).getTime() - new Date(a.start_ms).getTime()); + projects.sort((a, b) => new Date(b.start_at).getTime() - new Date(a.start_at).getTime()); setFilteredCampaigns(projects); break; case "older": - projects.sort((a, b) => new Date(a.start_ms).getTime() - new Date(b.start_ms).getTime()); + projects.sort((a, b) => new Date(a.start_at).getTime() - new Date(b.start_at).getTime()); setFilteredCampaigns(projects); break; @@ -45,11 +57,40 @@ export const CampaignsList = () => { setFilteredCampaigns(filtered); }, [search, campaigns]); - return loading ? ( -
- -
- ) : ( + const content = useMemo(() => { + if (loading) { + return ( +
+ {Array.from({ length: 6 }, (_, index) => ( + + ))} +
+ ); + } + + if (!filteredCampaigns || filteredCampaigns.length === 0) { + return ( +
+ +
+

No Campaign found

+
+
+ ); + } + + return ( +
+ {filteredCampaigns + .filter((campaign) => campaign?.on_chain_id !== 14) + .map((campaign) => ( + + ))} +
+ ); + }, [loading, filteredCampaigns]); + + return (
{buttons.map( @@ -70,24 +111,94 @@ export const CampaignsList = () => { placeholder="Search Campaigns" onChange={(e) => setSearch(e.target.value.toLowerCase())} /> +
-
- {filteredCampaigns.length ? ( -
- {filteredCampaigns - ?.filter((campaign) => campaign?.id !== 14) - .map((campaign) => )} -
- ) : ( -
- -
-

No Campaign found

-
+
{content}
+ + {/* Pagination - Only show for ALL_CAMPAIGNS */} + {currentTab === "ALL_CAMPAIGNS" && + pagination.totalPages > 1 && + !loading && + filteredCampaigns.length > 0 && ( +
+ + + + { + e.preventDefault(); + + if (pagination.hasPreviousPage) { + pagination.setCurrentPage(pagination.currentPage - 1); + window.scrollTo({ top: 0, behavior: "smooth" }); + } + }} + className={ + !pagination.hasPreviousPage + ? "pointer-events-none opacity-50" + : "cursor-pointer" + } + /> + + + {/* Page numbers */} + {Array.from({ length: Math.min(5, pagination.totalPages) }, (_, i) => { + let pageNum: number; + + if (pagination.totalPages <= 5) { + pageNum = i + 1; + } else if (pagination.currentPage <= 3) { + pageNum = i + 1; + } else if (pagination.currentPage >= pagination.totalPages - 2) { + pageNum = pagination.totalPages - 4 + i; + } else { + pageNum = pagination.currentPage - 2 + i; + } + + return ( + + { + e.preventDefault(); + pagination.setCurrentPage(pageNum); + window.scrollTo({ top: 0, behavior: "smooth" }); + }} + isActive={pagination.currentPage === pageNum} + className="cursor-pointer" + > + {pageNum} + + + ); + })} + + {pagination.totalPages > 5 && + pagination.currentPage < pagination.totalPages - 2 && ( + + + + )} + + + { + e.preventDefault(); + + if (pagination.hasNextPage) { + pagination.setCurrentPage(pagination.currentPage + 1); + window.scrollTo({ top: 0, behavior: "smooth" }); + } + }} + className={ + !pagination.hasNextPage ? "pointer-events-none opacity-50" : "cursor-pointer" + } + /> + + +
)} -
); }; diff --git a/src/entities/campaign/components/editor.tsx b/src/entities/campaign/components/editor.tsx index 927a56c62..dd9a979c4 100644 --- a/src/entities/campaign/components/editor.tsx +++ b/src/entities/campaign/components/editor.tsx @@ -5,15 +5,21 @@ import { useRouter } from "next/router"; import { isNonNullish } from "remeda"; import { Temporal } from "temporal-polyfill"; +import { Campaign } from "@/common/api/indexer"; import { NATIVE_TOKEN_ID } from "@/common/constants"; -import { Campaign } from "@/common/contracts/core/campaigns"; import { indivisibleUnitsToFloat, parseNumber } from "@/common/lib"; +import { toTimestamp } from "@/common/lib/datetime"; import { pinataHooks } from "@/common/services/pinata"; import { CampaignId } from "@/common/types"; import { TextAreaField, TextField } from "@/common/ui/form/components"; +import { RichTextEditor } from "@/common/ui/form/components/richtext"; import { Button, Form, FormField, Switch } from "@/common/ui/layout/components"; import { cn } from "@/common/ui/layout/utils"; import { useWalletUserSession } from "@/common/wallet"; +import { + ACCOUNT_PROFILE_DESCRIPTION_MAX_LENGTH, + useAccountSocialProfile, +} from "@/entities/_shared/account"; import { TokenSelector, useFungibleToken } from "@/entities/_shared/token"; import { useCampaignForm } from "../hooks/forms"; @@ -29,28 +35,156 @@ export const CampaignEditor = ({ existingData, campaignId, close }: CampaignEdit const walletUser = useWalletUserSession(); const { back } = useRouter(); const [avoidFee, setAvoidFee] = useState(false); + const [recipientType, setRecipientType] = useState<"yourself" | "someone_else">("yourself"); const isUpdate = campaignId !== undefined; - const { form, handleCoverImageUploadResult, onSubmit, watch, isDisabled } = useCampaignForm({ - campaignId, - ftId: existingData?.ft_id ?? NATIVE_TOKEN_ID, - onUpdateSuccess: close, + // Minimum datetime for start date (current time + 1 minute buffer) + // Initialize with empty string to avoid SSR/SSG issues with Temporal.Now.timeZoneId() + const [minStartDateTime, setMinStartDateTime] = useState(""); + + // Track when project fields should be shown (prevent disappearing after being shown) + const [showProjectFields, setShowProjectFields] = useState(false); + + const { form, handleCoverImageUploadResult, onSubmit, watch, isDisabled, handleDeleteCampaign } = + useCampaignForm({ + campaignId, + ftId: existingData?.token?.account ?? NATIVE_TOKEN_ID, + onUpdateSuccess: close, + }); + + const { profile, isLoading: isProfileLoading } = useAccountSocialProfile({ + accountId: walletUser?.accountId ?? "", }); + const [ftId, targetAmount, minAmount, maxAmount, coverImageUrl, description, recipient] = + form.watch([ + "ft_id", + "target_amount", + "min_amount", + "max_amount", + "cover_image_url", + "description", + "recipient", + ]); + + // Set initial recipient when component mounts (only for create mode) + useEffect(() => { + if (!isUpdate && recipientType === "yourself" && walletUser?.accountId) { + form.setValue("recipient", walletUser.accountId); + } + }, [recipientType, walletUser?.accountId, form, isUpdate]); + + // Validate and auto-correct start date if it's in the past + const handleStartDateChange = (value: string) => { + if (!value) { + form.setValue("start_ms", undefined, { shouldValidate: false }); + return; + } + + const selectedTime = Temporal.PlainDateTime.from(value) + .toZonedDateTime(Temporal.Now.timeZoneId()) + .toInstant().epochMilliseconds; + + const minTime = Temporal.Now.instant().add({ minutes: 1 }).epochMilliseconds; + + // Only validate if end_ms has been touched or has a value + // This prevents showing validation errors on untouched end_ms field + const endMsValue = form.getValues("end_ms"); + const endMsTouched = form.formState.touchedFields.end_ms; + const shouldValidate = endMsTouched === true || endMsValue !== undefined; + + if (selectedTime < minTime) { + // Auto-correct to minimum valid time (silently) + const correctedTime = Temporal.Now.instant().add({ minutes: 1 }).epochMilliseconds; + form.setValue("start_ms", correctedTime, { shouldValidate, shouldDirty: true }); + } else { + form.setValue("start_ms", selectedTime, { shouldValidate, shouldDirty: true }); + } + }; + + // Validate and auto-correct end date if it's before start date + const handleEndDateChange = (value: string) => { + if (!value) { + form.setValue("end_ms", undefined, { shouldValidate: false }); + return; + } + + const selectedTime = Temporal.PlainDateTime.from(value) + .toZonedDateTime(Temporal.Now.timeZoneId()) + .toInstant().epochMilliseconds; + + const startMs = form.getValues("start_ms"); + + const minTime = startMs + ? (startMs as number) + 60000 // At least 1 minute after start + : Temporal.Now.instant().add({ minutes: 1 }).epochMilliseconds; + + // Only validate if start_ms has been touched or has a value + // This prevents showing validation errors on untouched start_ms field + const startMsTouched = form.formState.touchedFields.start_ms; + const shouldValidate = startMsTouched === true || startMs !== undefined; + + if (selectedTime < minTime) { + // Auto-correct to minimum valid time (silently) + form.setValue("end_ms", minTime, { shouldValidate, shouldDirty: true }); + } else { + form.setValue("end_ms", selectedTime, { shouldValidate, shouldDirty: true }); + } + }; + + // Keep the min attribute on datetime inputs up to date (client-side only). + useEffect(() => { + const updateMinDateTime = () => { + const newMin = Temporal.Now.instant() + .add({ minutes: 1 }) + .toZonedDateTimeISO(Temporal.Now.timeZoneId()) + .toPlainDateTime() + .toString({ smallestUnit: "minute" }); + + setMinStartDateTime(newMin); + }; + + // Set initial value immediately on mount (client-side only) + updateMinDateTime(); + + // Then update every 60 seconds + const interval = setInterval(updateMinDateTime, 60000); + + return () => clearInterval(interval); + }, []); + + // "Set to current" — sets start date to right now + const handleStartNow = () => { + const startEpoch = Temporal.Now.instant().add({ minutes: 1 }).epochMilliseconds; + form.setValue("start_ms", startEpoch, { shouldDirty: true, shouldValidate: true }); + }; + + // Track project fields visibility to prevent them from disappearing + useEffect(() => { + const shouldShow = + !isUpdate && + !isProfileLoading && + !profile && + process.env.NEXT_PUBLIC_ENV !== "test" && + walletUser?.accountId && + walletUser?.accountId === recipient; + + // Hide fields if profile exists (user already has NEAR Social account) + const shouldHide = !isProfileLoading && profile; + + if (shouldHide && showProjectFields) { + setShowProjectFields(false); + } else if (shouldShow && !showProjectFields) { + setShowProjectFields(true); + } + }, [isUpdate, isProfileLoading, profile, walletUser?.accountId, recipient, showProjectFields]); + const { handleFileInputChange, isPending: isBannerUploadPending } = pinataHooks.useFileUpload({ onSuccess: handleCoverImageUploadResult, }); - const [ftId, targetAmount, minAmount, maxAmount, coverImageUrl] = form.watch([ - "ft_id", - "target_amount", - "min_amount", - "max_amount", - "cover_image_url", - ]); - const { data: token } = useFungibleToken({ - tokenId: existingData?.ft_id ?? ftId ?? NATIVE_TOKEN_ID, + tokenId: existingData?.token?.account ?? ftId ?? NATIVE_TOKEN_ID, balanceCheckAccountId: walletUser?.accountId, }); @@ -72,12 +206,56 @@ export const CampaignEditor = ({ existingData, campaignId, close }: CampaignEdit } else return null; }, [existingData, token]); + const fieldErrorMessages = useMemo(() => { + const errors = form.formState.errors; + + const fields: [string, string][] = [ + ["name", "Campaign Name"], + ["description", "Description"], + ["target_amount", "Target Amount"], + ["min_amount", "Minimum Target Amount"], + ["max_amount", "Maximum Target Amount"], + ["start_ms", "Start Date"], + ["end_ms", "End Date"], + ["recipient", "Recipient"], + ["cover_image_url", "Cover Image URL"], + ["referral_fee_basis_points", "Referral Fee"], + ["creator_fee_basis_points", "Creator Fee"], + ["ft_id", "Token"], + ]; + + const messages: string[] = []; + + for (const [key, label] of fields) { + const error = errors[key as keyof typeof errors]; + + if (error?.message) { + messages.push(`${label}: ${String(error.message)}`); + } + } + + return messages; + }, [ + form.formState.errors.name, + form.formState.errors.description, + form.formState.errors.target_amount, + form.formState.errors.min_amount, + form.formState.errors.max_amount, + form.formState.errors.start_ms, + form.formState.errors.end_ms, + form.formState.errors.recipient, + form.formState.errors.cover_image_url, + form.formState.errors.referral_fee_basis_points, + form.formState.errors.creator_fee_basis_points, + form.formState.errors.ft_id, + ]); + // TODO: Use `useEnhancedForm` for form setup instead, this effect is called upon EVERY RENDER, // TODO: which impacts UX and performance SUBSTANTIALLY! useEffect(() => { if (isUpdate && existingData && !form.formState.isDirty) { - if (isNonNullish(existingData.ft_id) && ftId !== existingData.ft_id) { - form.setValue("ft_id", existingData.ft_id); + if (isNonNullish(existingData.token?.account) && ftId !== existingData.token?.account) { + form.setValue("ft_id", existingData.token?.account); } if (token !== undefined) { @@ -98,19 +276,19 @@ export const CampaignEditor = ({ existingData, campaignId, close }: CampaignEdit form.setValue("cover_image_url", existingData.cover_image_url); } - form.setValue("recipient", existingData?.recipient); - form.setValue("name", existingData?.name); - form.setValue("description", existingData.description); + form.setValue("recipient", existingData?.recipient?.id ?? ""); + form.setValue("name", existingData?.name ?? ""); + form.setValue("description", existingData?.description ?? ""); if ( - existingData?.start_ms && - existingData?.start_ms > Temporal.Now.instant().epochMilliseconds + existingData?.start_at && + toTimestamp(existingData?.start_at) > Temporal.Now.instant().epochMilliseconds ) { - form.setValue("start_ms", existingData?.start_ms); + form.setValue("start_ms", toTimestamp(existingData?.start_at)); } - if (existingData?.end_ms) { - form.setValue("end_ms", existingData?.end_ms); + if (existingData?.end_at) { + form.setValue("end_ms", toTimestamp(existingData?.end_at)); } if (existingData.allow_fee_avoidance) { @@ -193,22 +371,83 @@ export const CampaignEditor = ({ existingData, campaignId, close }: CampaignEdit Time-limited Campaign: Has a specified end date—concludes on the set date. + +
  • + Campaign Deletion: Campaigns can only be deleted before they start. + Once a campaign has started, it cannot be deleted. +
  • { - e.preventDefault(); - + onSubmit={form.handleSubmit((values) => { onSubmit({ - ...form.getValues(), + ...values, allow_fee_avoidance: avoidFee, }); - }} + })} > -
    +
    + {showProjectFields && ( +
    +
    +
    +
    + + + +
    +

    Project Details

    +
    +

    + Please note that you do not have a project yet, that is why you're required + to input your project details now. +

    +
    + +
    + ( + + )} + /> + ( + + )} + /> +
    +
    + )}

    {"Upload Campaign Image"} {"(Optional)"} @@ -254,20 +493,107 @@ export const CampaignEditor = ({ existingData, campaignId, close }: CampaignEdit

    - ( - - )} - /> + {!isUpdate ? ( + // Create mode - show recipient selection +
    + + +
    + + + +
    + + {recipientType === "someone_else" && ( + ( + + )} + /> + )} +
    + ) : ( + // Update mode - show simple recipient field + ( + + )} + /> + )} ( - ( + form.setValue("description", value, { shouldValidate: true })} + maxLength={250} label="Campaign Description" - required + error={form.formState.errors.description?.message} className="mt-8" - placeholder="Type description" - maxLength={250} - rows={4} /> )} /> @@ -388,37 +713,50 @@ export const CampaignEditor = ({ existingData, campaignId, close }: CampaignEdit )} > {!campaignId ? ( - ( - - )} - /> +
    + ( + handleStartDateChange(e.target.value)} + type="datetime-local" + /> + )} + /> + + +
    ) : ( - existingData?.start_ms && - existingData?.start_ms > Temporal.Now.instant().epochMilliseconds && ( + existingData?.start_at && + toTimestamp(existingData?.start_at) > Temporal.Now.instant().epochMilliseconds && ( ( + render={({ field: { value, onChange: _onChange, ...field } }) => ( handleStartDateChange(e.target.value)} classNames={{ root: "lg:w-90 md:w-90 mb-8 md:mb-0" }} type="datetime-local" /> @@ -438,24 +777,37 @@ export const CampaignEditor = ({ existingData, campaignId, close }: CampaignEdit ( - - )} + render={({ field: { value, onChange: _onChange, ...field } }) => { + const startMs = form.watch("start_ms"); + + const endMin = startMs + ? Temporal.Instant.fromEpochMilliseconds((startMs as number) + 60000) + .toZonedDateTimeISO(Temporal.Now.timeZoneId()) + .toPlainDateTime() + .toString({ smallestUnit: "minute" }) + : minStartDateTime; + + return ( + handleEndDateChange(e.target.value)} + classNames={{ root: "lg:w-90 md:w-90" }} + type="datetime-local" + /> + ); + }} />
    @@ -509,7 +861,7 @@ export const CampaignEditor = ({ existingData, campaignId, close }: CampaignEdit )} >
    -

    Bypass Fees

    +

    Fee Exemption

    If enabled, donors may be able to bypass certain fees @@ -520,17 +872,47 @@ export const CampaignEditor = ({ existingData, campaignId, close }: CampaignEdit

    - - - +
    + {!form.formState.isSubmitting && isDisabled && ( +
    + {fieldErrorMessages.length > 0 ? ( + fieldErrorMessages.map((msg, i) => ( +

    + {msg} +

    + )) + ) : ( +

    Please fill in all required fields

    + )} +
    + )} + +
    + +
    + {isUpdate && + existingData?.start_at && + toTimestamp(existingData.start_at) > Temporal.Now.instant().epochMilliseconds && ( + + )} + + +
    diff --git a/src/entities/campaign/hooks/forms.ts b/src/entities/campaign/hooks/forms.ts index 74530ec33..700bda36f 100644 --- a/src/entities/campaign/hooks/forms.ts +++ b/src/entities/campaign/hooks/forms.ts @@ -1,11 +1,14 @@ -import { useCallback, useEffect, useMemo } from "react"; +import { useCallback, useEffect, useMemo, useRef } from "react"; import { zodResolver } from "@hookform/resolvers/zod"; import { useRouter } from "next/router"; import { SubmitHandler, useForm, useWatch } from "react-hook-form"; +import { isDeepEqual } from "remeda"; +import { syncApi } from "@/common/api/indexer/sync"; import { NATIVE_TOKEN_DECIMALS, NATIVE_TOKEN_ID } from "@/common/constants"; import { campaignsContractClient } from "@/common/contracts/core/campaigns"; +import type { Campaign } from "@/common/contracts/core/campaigns/interfaces"; import { feePercentsToBasisPoints } from "@/common/contracts/core/utils"; import { floatToIndivisible, parseNumber } from "@/common/lib"; import type { FileUploadResult } from "@/common/services/pinata"; @@ -13,11 +16,12 @@ import { type ByCampaignId, type FromSchema, type TokenId } from "@/common/types import { toast } from "@/common/ui/layout/hooks"; import { useWalletUserSession } from "@/common/wallet"; import { useFungibleToken } from "@/entities/_shared/token"; -import { routeSelectors } from "@/pathnames"; -import { dispatch } from "@/store"; +import { routeSelectors } from "@/navigation"; +import { useDispatch } from "@/store/hooks"; import { createCampaignSchema, updateCampaignSchema } from "../models/schema"; import { CampaignEnumType } from "../types"; +import { parseContractError } from "../utils"; export type CampaignFormParams = Partial & { ftId?: TokenId; @@ -25,12 +29,16 @@ export type CampaignFormParams = Partial & { }; export const useCampaignForm = ({ campaignId, ftId, onUpdateSuccess }: CampaignFormParams) => { + const dispatch = useDispatch(); const viewer = useWalletUserSession(); const router = useRouter(); const isNewCampaign = campaignId === undefined; const schema = isNewCampaign ? createCampaignSchema : updateCampaignSchema; - type Values = FromSchema; + type Values = FromSchema & { + project_name?: string; + project_description?: string; + }; const self = useForm({ resolver: zodResolver(schema), @@ -70,12 +78,13 @@ export const useCampaignForm = ({ campaignId, ftId, onUpdateSuccess }: CampaignF const isDisabled = useMemo( () => - !self.formState.isDirty || + (!isNewCampaign && !self.formState.isDirty) || !self.formState.isValid || self.formState.isSubmitting || (values.ft_id !== NATIVE_TOKEN_ID && !isTokenDataLoading && token === undefined), [ + isNewCampaign, isTokenDataLoading, self.formState.isDirty, self.formState.isSubmitting, @@ -85,8 +94,15 @@ export const useCampaignForm = ({ campaignId, ftId, onUpdateSuccess }: CampaignF ], ); + // Track previous cross-field errors to prevent infinite loops + const prevCrossFieldErrorsRef = useRef>({}); + useEffect(() => { - const errors: Record = {}; + const errors: Record = { + min_amount: undefined, + max_amount: undefined, + target_amount: undefined, + }; // Validate min_amount vs max_amount if (parsedMinAmount && parsedMaxAmount && parsedMinAmount > parsedMaxAmount) { @@ -121,8 +137,15 @@ export const useCampaignForm = ({ campaignId, ftId, onUpdateSuccess }: CampaignF }; } + // Only update if errors have changed to prevent infinite loops + if (isDeepEqual(prevCrossFieldErrorsRef.current, errors)) { + return; + } + + prevCrossFieldErrorsRef.current = errors; + // Clear errors only for fields that are now valid - ["min_amount", "max_amount", "target_amount"].forEach((field) => { + (["min_amount", "max_amount", "target_amount"] as const).forEach((field) => { if (!errors[field]) { self.clearErrors(field as keyof Values); } @@ -130,23 +153,59 @@ export const useCampaignForm = ({ campaignId, ftId, onUpdateSuccess }: CampaignF // Set all collected errors Object.entries(errors).forEach(([field, error]) => { - self.setError(field as keyof Values, error); + if (error) { + self.setError(field as keyof Values, error); + } }); - }, [values, self, parsedMinAmount, parsedMaxAmount, parsedTargetAmount]); + }, [parsedMinAmount, parsedMaxAmount, parsedTargetAmount, self]); const timeToMilliseconds = (time: number) => { return new Date(time).getTime(); }; - const handleDeleteCampaign = () => { + const formatFullDateTime = (timestampMs: number) => { + const date = new Date(timestampMs); + const day = date.getDate(); + + let suffix = "th"; + if (day % 10 === 1 && day !== 11) suffix = "st"; + else if (day % 10 === 2 && day !== 12) suffix = "nd"; + else if (day % 10 === 3 && day !== 13) suffix = "rd"; + + const month = date.toLocaleString("en-US", { month: "long" }); + const year = date.getFullYear(); + const time = date.toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit" }); + + return `${day}${suffix} ${month} ${year}, ${time}`; + }; + + const handleDeleteCampaign = async () => { if (!isNewCampaign) { - campaignsContractClient.delete_campaign({ args: { campaign_id: campaignId } }); + try { + const { txHash } = await campaignsContractClient.delete_campaign({ + args: { campaign_id: campaignId }, + }); - dispatch.campaignEditor.updateCampaignModalState({ - header: "Campaign Deleted Successfully", - description: "You can now proceed to close this window", - type: CampaignEnumType.DELETE_CAMPAIGN, - }); + // Sync deletion to indexer database + if (txHash && viewer.accountId) { + await syncApi.campaignDelete(campaignId, txHash, viewer.accountId).catch(console.warn); + } + + dispatch.campaignEditor.updateCampaignModalState({ + header: "Campaign Deleted Successfully", + description: "You can now proceed to close this window", + type: CampaignEnumType.DELETE_CAMPAIGN, + }); + + router.push("/campaigns"); + } catch (error) { + console.error("Failed to delete campaign:", error); + + toast({ + title: "Failed to delete campaign. Please try again later.", + variant: "destructive", + }); + } } }; @@ -156,7 +215,14 @@ export const useCampaignForm = ({ campaignId, ftId, onUpdateSuccess }: CampaignF .process_escrowed_donations_batch({ args: { campaign_id: campaignId }, }) - .then(() => { + .then(async ({ txHash }) => { + // Sync unescrow to indexer database + if (txHash && viewer.accountId) { + await syncApi + .campaignUnescrow(campaignId, txHash, viewer.accountId) + .catch(console.warn); + } + return toast({ title: "Successfully processed escrowed donations", }); @@ -178,7 +244,12 @@ export const useCampaignForm = ({ campaignId, ftId, onUpdateSuccess }: CampaignF .process_refunds_batch({ args: { campaign_id: campaignId }, }) - .then(() => { + .then(async ({ txHash }) => { + // Sync refunds to indexer database + if (txHash && viewer.accountId) { + await syncApi.campaignRefund(campaignId, txHash, viewer.accountId).catch(console.warn); + } + return toast({ title: "Successfully processed donation refunds", }); @@ -207,7 +278,10 @@ export const useCampaignForm = ({ campaignId, ftId, onUpdateSuccess }: CampaignF parseNumber(values.target_amount ?? 0), token?.metadata.decimals ?? NATIVE_TOKEN_DECIMALS, ), - + ...(isNewCampaign && values.project_name ? { project_name: values.project_name } : {}), + ...(isNewCampaign && values.project_description + ? { project_description: values.project_description } + : {}), ...(values.cover_image_url ? { cover_image_url: values.cover_image_url, @@ -232,7 +306,7 @@ export const useCampaignForm = ({ campaignId, ftId, onUpdateSuccess }: CampaignF } : {}), - ...(values?.allow_fee_avoidance && { + ...(values?.allow_fee_avoidance !== undefined && { allow_fee_avoidance: values.allow_fee_avoidance, }), ...(values?.referral_fee_basis_points && { @@ -249,7 +323,7 @@ export const useCampaignForm = ({ campaignId, ftId, onUpdateSuccess }: CampaignF end_ms: timeToMilliseconds(values.end_ms), }), ...(campaignId ? {} : { owner: viewer.accountId as string }), - ...(campaignId ? {} : { recipient: values.recipient }), + ...(campaignId ? {} : { recipient: values.recipient ?? undefined }), }; if (campaignId) { @@ -257,14 +331,23 @@ export const useCampaignForm = ({ campaignId, ftId, onUpdateSuccess }: CampaignF .update_campaign({ args: { ...args, campaign_id: campaignId }, }) - .then(() => { + .then(async () => { + // Sync campaign to database + await syncApi.campaign(campaignId).catch(console.warn); + self.reset(values, { keepErrors: false }); toast({ title: `You’ve successfully updated this campaign`, + description: (() => { + const startMs = values.start_ms ? timeToMilliseconds(values.start_ms) : undefined; + + if (startMs && startMs > Date.now()) { + return `Campaign starts on ${formatFullDateTime(startMs)}.`; + } - description: - "If you are not a member of the project, the campaign will be considered unofficial until it has been approved by the project.", + return "Campaign is live."; + })(), }); onUpdateSuccess?.(); @@ -272,29 +355,67 @@ export const useCampaignForm = ({ campaignId, ftId, onUpdateSuccess }: CampaignF .catch((error) => { console.error("Failed to update Campaign:", error); + const parsedError = parseContractError(error); + toast({ - description: "Failed to update Campaign.", + title: parsedError.title, + description: parsedError.hint + ? `${parsedError.message} ${parsedError.hint}` + : parsedError.message, variant: "destructive", }); }); } else { campaignsContractClient .create_campaign({ args }) - .then((newCampaign) => { + .then(async (newCampaign) => { + const startMs = values.start_ms ? timeToMilliseconds(values.start_ms) : undefined; + + // Sync new campaign to database + if ( + newCampaign && + typeof newCampaign === "object" && + "id" in newCampaign && + newCampaign.id + ) { + await syncApi.campaign((newCampaign as Campaign).id).catch(console.warn); + } + toast({ title: `You’ve successfully created a campaign for ${values.name}.`, + description: (() => { + if (startMs && startMs > Date.now()) { + return `Campaign starts on ${formatFullDateTime(startMs)}.`; + } - description: - "If you are not a member of the project, the campaign will be considered unofficial until it has been approved by the project.", + return "Campaign is live."; + })(), }); - router.push(routeSelectors.CAMPAIGN_BY_ID(newCampaign.id)); + // Fix: Ensure newCampaign has an id before accessing it + console.log(newCampaign); + + if ( + newCampaign && + typeof newCampaign === "object" && + "id" in newCampaign && + newCampaign.id + ) { + router.push(routeSelectors.CAMPAIGN_BY_ID((newCampaign as Campaign).id)); + } else { + router.push(`/campaigns`); + } }) .catch((error) => { console.error("Failed to create Campaign:", error); + const parsedError = parseContractError(error); + toast({ - title: "Failed to create Campaign.", + title: parsedError.title, + description: parsedError.hint + ? `${parsedError.message} ${parsedError.hint}` + : parsedError.message, variant: "destructive", }); }); diff --git a/src/entities/campaign/hooks/redirects.ts b/src/entities/campaign/hooks/redirects.ts index c9d3d7447..c32be222d 100644 --- a/src/entities/campaign/hooks/redirects.ts +++ b/src/entities/campaign/hooks/redirects.ts @@ -3,11 +3,12 @@ import { useEffect } from "react"; import { useModal } from "@ebay/nice-modal-react"; import { useRouteQuery } from "@/common/lib"; -import { dispatch } from "@/store"; +import { useDispatch } from "@/store/hooks"; import { CampaignFinishModal } from "../components/CampaignFinishModal"; export const useCampaignCreateOrUpdateRedirect = () => { + const dispatch = useDispatch(); const resultModal = useModal(CampaignFinishModal); const { @@ -28,5 +29,11 @@ export const useCampaignCreateOrUpdateRedirect = () => { setSearchParams({ transactionHashes: null }); }); } - }, [isTransactionOutcomeDetected, transactionHash, setSearchParams, resultModal]); + }, [ + isTransactionOutcomeDetected, + transactionHash, + setSearchParams, + resultModal, + dispatch.campaignEditor, + ]); }; diff --git a/src/entities/campaign/hooks/useCampaigns.ts b/src/entities/campaign/hooks/useCampaigns.ts index 2efd76078..24bb302b3 100644 --- a/src/entities/campaign/hooks/useCampaigns.ts +++ b/src/entities/campaign/hooks/useCampaigns.ts @@ -1,9 +1,12 @@ -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useMemo, useState } from "react"; +import { V1CampaignsRetrieveStatus, indexer } from "@/common/api/indexer"; import { NOOP_STRING } from "@/common/constants"; -import { Campaign, campaignsContractHooks } from "@/common/contracts/core/campaigns"; +import { Group, GroupType } from "@/common/ui/layout/components"; import { useWalletUserSession } from "@/common/wallet"; +import { CAMPAIGN_STATUS_OPTIONS } from "../utils/constants"; + enum CampaignTab { ALL_CAMPAIGNS = "ALL_CAMPAIGNS", MY_CAMPAIGNS = "MY_CAMPAIGNS", @@ -12,52 +15,83 @@ enum CampaignTab { export const useAllCampaignLists = () => { const viewer = useWalletUserSession(); const [currentTab, setCurrentTab] = useState(CampaignTab.ALL_CAMPAIGNS); - const [filteredCampaigns, setFilteredCampaigns] = useState([]); - - const { data: allCampaigns, isLoading: isAllCampaignsLoading } = - campaignsContractHooks.useCampaigns(); + const [statusFilter, setsStatusFilter] = useState("all"); + const [currentPage, setCurrentPage] = useState(1); - const { data: ownerCampaigns, isLoading: loadingOwnerCampaigns } = - campaignsContractHooks.useOwnedCampaigns({ - accountId: viewer.accountId ?? NOOP_STRING, - }); + // Only paginate for ALL_CAMPAIGNS, use large page_size for MY_CAMPAIGNS + const pageSize = currentTab === CampaignTab.ALL_CAMPAIGNS ? 21 : 300; - const fetchAllCampaigns = useCallback(() => { - setCurrentTab(CampaignTab.ALL_CAMPAIGNS); - setFilteredCampaigns(allCampaigns || []); - }, [allCampaigns]); + const { data: campaignsData, isLoading: isCampaignsLoading } = indexer.useCampaigns({ + page: currentTab === CampaignTab.ALL_CAMPAIGNS ? currentPage : 1, + page_size: pageSize, + ...(currentTab === CampaignTab.MY_CAMPAIGNS && { owner: viewer.accountId ?? NOOP_STRING }), + ...(statusFilter !== "all" && { status: statusFilter as V1CampaignsRetrieveStatus }), + }); - const fetchMyCampaigns = useCallback(() => { - if (!viewer.isSignedIn) return; - setCurrentTab(CampaignTab.MY_CAMPAIGNS); - setFilteredCampaigns(ownerCampaigns || []); - }, [ownerCampaigns, viewer?.isSignedIn]); + // Reset to page 1 when tab or filter changes + const handleTabChange = (tab: CampaignTab) => { + setCurrentTab(tab); + setCurrentPage(1); + }; - useEffect(() => { - fetchAllCampaigns(); - }, [fetchAllCampaigns]); + const handleStatusFilterChange = (value: string) => { + setsStatusFilter(value); + setCurrentPage(1); + }; - const actions = useMemo( + const buttons = useMemo( () => [ { label: "All Campaigns", type: CampaignTab.ALL_CAMPAIGNS, - onClick: fetchAllCampaigns, + onClick: () => handleTabChange(CampaignTab.ALL_CAMPAIGNS), }, { label: "My Campaigns", type: CampaignTab.MY_CAMPAIGNS, - onClick: fetchMyCampaigns, + onClick: () => handleTabChange(CampaignTab.MY_CAMPAIGNS), condition: viewer.isSignedIn, }, ], - [fetchAllCampaigns, fetchMyCampaigns, viewer.isSignedIn], + [viewer.isSignedIn], ); + const tagsList: Group[] = [ + { + label: "Status", + options: CAMPAIGN_STATUS_OPTIONS, + type: GroupType.single, + props: { + value: statusFilter, + onValueChange: handleStatusFilterChange, + }, + }, + ]; + + // Calculate pagination metadata + const totalCount = campaignsData?.count ?? 0; + + const totalPages = + currentTab === CampaignTab.ALL_CAMPAIGNS ? Math.ceil(totalCount / pageSize) : 1; + + const hasNextPage = currentTab === CampaignTab.ALL_CAMPAIGNS && !!campaignsData?.next; + + const hasPreviousPage = currentTab === CampaignTab.ALL_CAMPAIGNS && !!campaignsData?.previous; + return { - buttons: actions, + buttons, + tagsList, currentTab, - campaigns: filteredCampaigns, - loading: isAllCampaignsLoading || loadingOwnerCampaigns, + campaigns: campaignsData?.results || [], + loading: isCampaignsLoading, + pagination: { + currentPage, + totalPages, + totalCount, + hasNextPage, + hasPreviousPage, + setCurrentPage, + pageSize, + }, }; }; diff --git a/src/entities/campaign/models/effects.ts b/src/entities/campaign/models/effects.ts index 6ed5148a5..caa4677a9 100644 --- a/src/entities/campaign/models/effects.ts +++ b/src/entities/campaign/models/effects.ts @@ -1,12 +1,15 @@ import { ExecutionStatusBasic } from "near-api-js/lib/providers/provider"; import { nearProtocolClient } from "@/common/blockchains/near-protocol"; -import { AppDispatcher } from "@/store"; +import { type AppDispatcher } from "@/store"; import { CampaignEnumType } from "../types"; export const effects = (dispatch: AppDispatcher) => ({ - handleCampaignContractActions: async (transactionHash: string): Promise => { + resetState: () => { + dispatch.campaignEditor.reset(); + }, + async handleCampaignContractActions(transactionHash: string): Promise { const { accountId: owner_account_id } = nearProtocolClient.walletApi; if (owner_account_id) { diff --git a/src/entities/campaign/models/index.ts b/src/entities/campaign/models/index.ts index da67bb411..c0143abd6 100644 --- a/src/entities/campaign/models/index.ts +++ b/src/entities/campaign/models/index.ts @@ -2,8 +2,8 @@ import { createModel } from "@rematch/core"; import { mergeAll, prop } from "remeda"; import { Campaign } from "@/common/contracts/core/campaigns"; -import { useGlobalStoreSelector } from "@/store"; -import { AppModel } from "@/store/models"; +import { useGlobalStoreSelector } from "@/store/hooks"; +import { type AppModel } from "@/store/models"; import { CampaignEditorState, CampaignEnumType } from "../types"; import { effects } from "./effects"; @@ -16,8 +16,19 @@ const campaignEditorStateDefaults: CampaignEditorState = { modalTextState: { header: "", description: "" }, }; -const handleCampaign = (state: CampaignEditorState, stateUpdate?: Partial) => - mergeAll([state, stateUpdate ?? {}]); +const handleCampaign = ( + state: CampaignEditorState, + stateUpdate?: Partial, +): CampaignEditorState => + mergeAll([ + state, + { + ...stateUpdate, + type: stateUpdate?.type ?? state.type, + finalOutcome: stateUpdate?.finalOutcome ?? state.finalOutcome, + modalTextState: stateUpdate?.modalTextState ?? state.modalTextState, + }, + ]) as CampaignEditorState; export const useCampaignActionState = () => useGlobalStoreSelector(prop(campaignModelKey)); diff --git a/src/entities/campaign/models/schema.ts b/src/entities/campaign/models/schema.ts index 23eaa3c86..6641be17c 100644 --- a/src/entities/campaign/models/schema.ts +++ b/src/entities/campaign/models/schema.ts @@ -1,6 +1,6 @@ import { literal, preprocess, string, z } from "zod"; -import { near } from "@/common/blockchains/near-protocol/client"; +import { nearProtocolSchemas } from "@/common/blockchains/near-protocol"; import { NATIVE_TOKEN_ID } from "@/common/constants"; import { feeBasisPointsToPercents } from "@/common/contracts/core/utils"; import { futureTimestamp, safePositiveNumber } from "@/common/lib"; @@ -65,7 +65,7 @@ const baseSchema = z.object({ .min(3, "Name must be at least 3 characters") .max(100, "Name must be less than 100 characters"), - description: z.string().max(250, "Description must be less than 250 characters").optional(), + description: z.string().max(500, "Description must be less than 500 characters").optional(), ft_id: ftIdSchema, target_amount: positiveNumberParser.describe("Target Amount of the campaign"), min_amount: positiveNumberParser.optional().describe("Minimum Amount of the Campaign"), @@ -92,10 +92,9 @@ const baseSchema = z.object({ export const createCampaignSchema = baseSchema .extend({ start_ms: futureTimestamp.describe("Campaign Start Date"), - - recipient: z.string().min(1, "Recipient account is required").refine(near.isAccountValid, { - message: `Invalid Account, must be a valid NEAR account`, - }), + project_name: z.string().optional(), + project_description: z.string().optional(), + recipient: nearProtocolSchemas.validAccountId.describe("Recipient's account id"), }) .superRefine((data, ctx) => { if (data.end_ms && data.start_ms && data.start_ms >= data.end_ms) { @@ -171,14 +170,7 @@ export const createCampaignSchema = baseSchema export const updateCampaignSchema = baseSchema.extend({ // start_ms might not be updatable or optional for update, adjust as needed start_ms: futureTimestamp.optional().describe("Campaign Start Date"), - recipient: z - .string() - .min(1) - .refine(near.isAccountValid, { - // Ensure it's still valid if provided - message: `Invalid Account, must be a valid NEAR account`, - }) - .optional(), // Optional for update + recipient: nearProtocolSchemas.validAccountIdOrNothing.describe("Recipient's account id"), }); export type CreateCampaignSchema = z.infer; diff --git a/src/entities/campaign/utils/constants.ts b/src/entities/campaign/utils/constants.ts index 0dba78c01..8dcfa57d7 100644 --- a/src/entities/campaign/utils/constants.ts +++ b/src/entities/campaign/utils/constants.ts @@ -1 +1,12 @@ +import { V1CampaignsRetrieveStatus } from "@/common/api/indexer"; + export const CAMPAIGN_MAX_FEE_POINTS = 1000; + +export const CAMPAIGN_STATUS_OPTIONS: { label: string; val: V1CampaignsRetrieveStatus | "all" }[] = + [ + { label: "All", val: "all" }, + { label: "Active", val: "active" }, + { label: "Ended", val: "ended" }, + { label: "Unfulfilled", val: "unfufilled" }, + { label: "Upcoming", val: "upcoming" }, + ]; diff --git a/src/entities/campaign/utils/contract-campaign.ts b/src/entities/campaign/utils/contract-campaign.ts new file mode 100644 index 000000000..f1710702e --- /dev/null +++ b/src/entities/campaign/utils/contract-campaign.ts @@ -0,0 +1,61 @@ +import type { Campaign as IndexerCampaign } from "@/common/api/indexer/internal/client.generated"; +import { NATIVE_TOKEN_DECIMALS, NATIVE_TOKEN_ID } from "@/common/constants"; +import type { Campaign as ContractCampaign } from "@/common/contracts/core/campaigns/interfaces"; + +const msToIsoString = (ms: number | null | undefined): string | null => { + if (!ms) return null; + return new Date(ms).toISOString(); +}; + +export const mapContractCampaignToIndexerFormat = ( + contractCampaign: ContractCampaign, +): IndexerCampaign => { + const now = Date.now(); + let status = "active"; + + if (contractCampaign.start_ms > now) { + status = "pending"; + } else if (contractCampaign.end_ms && contractCampaign.end_ms < now) { + status = "completed"; + } + + return { + on_chain_id: contractCampaign.id, + name: contractCampaign.name, + description: contractCampaign.description || null, + cover_image_url: contractCampaign.cover_image_url || null, + created_at: new Date().toISOString(), + start_at: msToIsoString(contractCampaign.start_ms) ?? new Date().toISOString(), + end_at: msToIsoString(contractCampaign.end_ms ?? null), + owner: { + id: contractCampaign.owner, + donors_count: 0, + total_donations_in_usd: 0, + total_donations_out_usd: 0, + total_matching_pool_allocations_usd: 0, + }, + recipient: { + id: contractCampaign.recipient, + donors_count: 0, + total_donations_in_usd: 0, + total_donations_out_usd: 0, + total_matching_pool_allocations_usd: 0, + }, + token: { + account: contractCampaign.ft_id ?? NATIVE_TOKEN_ID, + decimals: NATIVE_TOKEN_DECIMALS, + name: contractCampaign.ft_id ?? "NEAR", + symbol: contractCampaign.ft_id?.toUpperCase() ?? "NEAR", + }, + target_amount: contractCampaign.target_amount, + min_amount: contractCampaign.min_amount ?? null, + max_amount: contractCampaign.max_amount ?? null, + escrow_balance: contractCampaign.escrow_balance, + net_raised_amount: contractCampaign.total_raised_amount ?? "0", + total_raised_amount: contractCampaign.total_raised_amount ?? "0", + referral_fee_basis_points: contractCampaign.referral_fee_basis_points ?? 0, + creator_fee_basis_points: contractCampaign.creator_fee_basis_points ?? 0, + allow_fee_avoidance: contractCampaign.allow_fee_avoidance ?? false, + status, + }; +}; diff --git a/src/entities/campaign/utils/errors.ts b/src/entities/campaign/utils/errors.ts new file mode 100644 index 000000000..63ef1a76a --- /dev/null +++ b/src/entities/campaign/utils/errors.ts @@ -0,0 +1,125 @@ +/** + * Maps contract error messages to user-friendly messages with hints + */ + +export interface ParsedError { + title: string; + message: string; + hint?: string; +} + +/** + * Common contract error patterns and their user-friendly equivalents + */ +const ERROR_MAPPINGS: Array<{ + pattern: RegExp; + title: string; + message: string; + hint?: string; +}> = [ + { + pattern: /campaign start time must be in the future/i, + title: "Invalid Start Date", + message: "The campaign start time has already passed.", + hint: "Please select a future date and time for your campaign to start.", + }, + { + pattern: /campaign end time must be after start time/i, + title: "Invalid End Date", + message: "The campaign end time must be after the start time.", + hint: "Please ensure your end date is later than the start date.", + }, + { + pattern: /min.*amount.*cannot.*greater.*max.*amount/i, + title: "Invalid Amount Range", + message: "The minimum amount cannot be greater than the maximum amount.", + hint: "Please adjust your minimum or maximum target amounts.", + }, + { + pattern: /recipient.*not.*exist|account.*not.*found/i, + title: "Invalid Recipient", + message: "The recipient account does not exist.", + hint: "Please verify the NEAR account ID is correct and exists.", + }, + { + pattern: /insufficient.*storage|storage.*balance/i, + title: "Insufficient Storage", + message: "Not enough NEAR deposited for storage.", + hint: "This is usually handled automatically. Please try again or contact support.", + }, + { + pattern: /transaction.*already.*exists/i, + title: "Duplicate Transaction", + message: "This transaction was already submitted.", + hint: "Your campaign may have been created. Please check 'My Campaigns' or refresh the page.", + }, + { + pattern: /gas.*exceeded|out.*of.*gas/i, + title: "Transaction Failed", + message: "The transaction ran out of gas.", + hint: "This is unusual. Please try again or contact support if the issue persists.", + }, + { + pattern: /unauthorized|access.*denied|not.*owner/i, + title: "Permission Denied", + message: "You don't have permission to perform this action.", + hint: "Make sure you're signed in with the correct account.", + }, +]; + +/** + * Parses a contract/wallet error and returns user-friendly message + */ +export function parseContractError(error: unknown): ParsedError { + // Default error + const defaultError: ParsedError = { + title: "Failed to Create Campaign", + message: "An unexpected error occurred.", + hint: "Please try again. If the problem persists, contact support.", + }; + + // Handle various error formats + let errorMessage = ""; + + if (typeof error === "string") { + errorMessage = error; + } else if (error && typeof error === "object") { + if ("message" in error && typeof error.message === "string") { + errorMessage = error.message; + } else if ("toString" in error && typeof error.toString === "function") { + errorMessage = error.toString(); + } + } + + if (!errorMessage) { + return defaultError; + } + + // Try to match against known patterns + for (const mapping of ERROR_MAPPINGS) { + if (mapping.pattern.test(errorMessage)) { + return { + title: mapping.title, + message: mapping.message, + hint: mapping.hint, + }; + } + } + + // If no pattern matched, try to extract contract panic message + const panicMatch = errorMessage.match(/Smart contract panicked: (.+?)(?:\n|$)/i); + + if (panicMatch) { + return { + title: "Contract Error", + message: panicMatch[1], + hint: "Please check your input values and try again.", + }; + } + + // Return default with actual error message if available + return { + ...defaultError, + message: errorMessage.length > 200 ? errorMessage.substring(0, 200) + "..." : errorMessage, + }; +} diff --git a/src/entities/campaign/utils/index.ts b/src/entities/campaign/utils/index.ts new file mode 100644 index 000000000..1399ea7de --- /dev/null +++ b/src/entities/campaign/utils/index.ts @@ -0,0 +1,3 @@ +export * from "./constants"; +export * from "./errors"; +export * from "./validation"; diff --git a/src/entities/cart/components/CartLink.tsx b/src/entities/cart/components/CartLink.tsx index d556f9007..4a6ed55cb 100644 --- a/src/entities/cart/components/CartLink.tsx +++ b/src/entities/cart/components/CartLink.tsx @@ -4,7 +4,7 @@ import { values } from "remeda"; import { Button, ButtonProps } from "@/common/ui/layout/components"; import { CartIcon } from "@/common/ui/layout/svg"; import { cn } from "@/common/ui/layout/utils"; -import { rootPathnames } from "@/pathnames"; +import { rootPathnames } from "@/navigation"; import { useCart } from "../hooks"; diff --git a/src/entities/cart/models/effects.ts b/src/entities/cart/models/effects.ts index 0918630fa..8a41fa900 100644 --- a/src/entities/cart/models/effects.ts +++ b/src/entities/cart/models/effects.ts @@ -1,4 +1,4 @@ -import { AppDispatcher } from "@/store"; +import { type AppDispatcher } from "@/store"; export const effects = (dispatch: AppDispatcher) => ({ checkout: (): void => { diff --git a/src/entities/dao/components/registration-proposal-breakdown.tsx b/src/entities/dao/components/registration-proposal-breakdown.tsx new file mode 100644 index 000000000..4485ca11f --- /dev/null +++ b/src/entities/dao/components/registration-proposal-breakdown.tsx @@ -0,0 +1,85 @@ +import { ArrowUpRightFromSquare } from "lucide-react"; +import Link from "next/link"; +import { MdOutlineInfo } from "react-icons/md"; + +import { PLATFORM_NAME, SOCIAL_PLATFORM_NAME } from "@/common/_config"; +import type { ProposalOutput } from "@/common/contracts/sputnikdao2"; +import type { AccountId } from "@/common/types"; +import { Alert, AlertDescription, AlertTitle, Button } from "@/common/ui/layout/components"; +import { cn } from "@/common/ui/layout/utils"; +import { getDaoProposalViewUrl, getDaoProposalsViewUrl } from "@/entities/dao"; + +export type DaoRegistrationProposalBreakdownProps = { + daoAccountId: AccountId; + proposals: ProposalOutput[]; +}; + +export const DaoRegistrationProposalBreakdown: React.FC = ({ + daoAccountId, + proposals, +}) => { + return ( +
    + + + {"Important Notice"} + + + + {`The ${ + PLATFORM_NAME + } registration process for DAOs consists of 2 steps submitted as separate proposals: ${ + SOCIAL_PLATFORM_NAME + } profile update and the final account listing application.`} + + + + {" Make sure both receive approval before continuing."} + + + + +
    + {proposals.map(({ id, description }) => ( +
    + {`Proposal #${id}`} + {" - "} + {description} + + +
    + ))} +
    + + +
    + ); +}; diff --git a/src/entities/dao/hooks/registration-proposal.ts b/src/entities/dao/hooks/registration-proposal.ts new file mode 100644 index 000000000..65f99ba5d --- /dev/null +++ b/src/entities/dao/hooks/registration-proposal.ts @@ -0,0 +1,46 @@ +import { useMemo } from "react"; + +import { + LISTS_CONTRACT_ACCOUNT_ID, + PLATFORM_NAME, + SOCIAL_DB_CONTRACT_ACCOUNT_ID, +} from "@/common/_config"; +import { ProposalStatus, sputnikDaoHooks } from "@/common/contracts/sputnikdao2"; +import type { ByAccountId, ConditionalActivation } from "@/common/types"; + +export const useDaoRegistrationProposalStatus = ({ + enabled, + accountId, +}: ByAccountId & ConditionalActivation) => { + const { + isProposalListLoading: isRecentProposalListLoading, + proposals: recentProposals, + proposalsError, + refetchProposals: refetchRecentProposals, + } = sputnikDaoHooks.useProposalLookup({ + enabled, + accountId, + fromIndex: 0, + limit: 10, + kind: "FunctionCall", + status: ProposalStatus.InProgress, + receiverAccountIds: [LISTS_CONTRACT_ACCOUNT_ID, SOCIAL_DB_CONTRACT_ACCOUNT_ID], + methodNames: ["register_batch", "set"], + initialSearchTerm: PLATFORM_NAME, + }); + + const isLoading = useMemo( + () => recentProposals === undefined && isRecentProposalListLoading, + [recentProposals, isRecentProposalListLoading], + ); + + const isSubmitted = useMemo(() => (recentProposals ?? []).length > 0, [recentProposals]); + + return { + isLoading, + entries: recentProposals, + error: proposalsError, + isSubmitted, + refetch: refetchRecentProposals, + }; +}; diff --git a/src/entities/dao/index.ts b/src/entities/dao/index.ts index 226118323..44f5da47f 100644 --- a/src/entities/dao/index.ts +++ b/src/entities/dao/index.ts @@ -1 +1,3 @@ -export * from "./utils/validation"; +export { DaoRegistrationProposalBreakdown } from "./components/registration-proposal-breakdown"; +export * from "./hooks/registration-proposal"; +export * from "./utils/proposals"; diff --git a/src/entities/dao/utils/proposals.ts b/src/entities/dao/utils/proposals.ts new file mode 100644 index 000000000..6e6ae12da --- /dev/null +++ b/src/entities/dao/utils/proposals.ts @@ -0,0 +1,20 @@ +import { SOCIAL_APP_LINK_URL } from "@/common/_config"; +import type { ByProposalId } from "@/common/contracts/sputnikdao2"; +import type { AccountId } from "@/common/types"; + +export const getDaoProposalViewUrl = ({ + daoAccountId, + proposalId, +}: ByProposalId & { daoAccountId: AccountId }) => + SOCIAL_APP_LINK_URL + + "/astraplusplus.ndctools.near/widget/home" + + "?page=dao" + + "&tab=proposals" + + `&daoId=${daoAccountId}&proposalId=${proposalId}`; + +export const getDaoProposalsViewUrl = ({ daoAccountId }: { daoAccountId: AccountId }) => + SOCIAL_APP_LINK_URL + + "/astraplusplus.ndctools.near/widget/home" + + "?page=dao" + + "&tab=proposals" + + `&daoId=${daoAccountId}`; diff --git a/src/entities/dao/utils/validation.ts b/src/entities/dao/utils/validation.ts deleted file mode 100644 index 09290f9bb..000000000 --- a/src/entities/dao/utils/validation.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { naxiosInstance } from "@/common/blockchains/near-protocol/client"; - -type Role = { - name: string; - kind: - | string - | { - Group: string[]; - }; - permissions: string[]; - vote_policy: {}; -}; - -type Policy = { - roles: Role[]; - default_vote_policy: { - weight_kind: string; - quorum: string; - threshold: number[]; - }; - proposal_bond: string; - proposal_period: string; - bounty_bond: string; - bounty_forgiveness_period: string; -}; - -function doesUserHaveDaoFunctionCallProposalPermissions(accountId: string, policy: Policy) { - const userRoles = policy.roles.filter((role: any) => { - if (role.kind === "Everyone") return true; - return role.kind.Group && role.kind.Group.includes(accountId); - }); - - const kind = "call"; - const action = "AddProposal"; - - // Check if the user is allowed to perform the action - const allowed = userRoles.some(({ permissions }: any) => { - return ( - permissions.includes(`${kind}:${action}`) || - permissions.includes(`${kind}:*`) || - permissions.includes(`*:${action}`) || - permissions.includes("*:*") - ); - }); - - return allowed; -} - -const checkIfDaoAddress = (address: string): boolean => { - return address.endsWith( - process.env.NEXT_PUBLIC_NETWORK ? "sputnik-dao.near" : "sputnik-dao.testnet", // TODO: not sure about this one - ); -}; - -export const validateUserInDao = async (daoAddress: string, accountId: string) => { - const isValidAddress = checkIfDaoAddress(daoAddress); - - if (!isValidAddress) return "Please enter a valid DAO address."; - - const daoContractApi = naxiosInstance.contractApi({ - contractId: daoAddress, - }); - - const policy = await daoContractApi.view<{}, Policy>("get_policy"); - - const hasPermission = doesUserHaveDaoFunctionCallProposalPermissions(accountId, policy); - - if (!hasPermission) return "The user does not have permission on this DAO."; - return ""; -}; - -export function updateList(list: string[], item: string): string[] { - const index = list.indexOf(item); - - if (index === -1) { - // Item does not exist, add it - list.push(item); - } else { - // Item exists, remove it - list.splice(index, 1); - } - - return list; -} diff --git a/src/entities/list/components/AccountCard.tsx b/src/entities/list/components/AccountCard.tsx index 3d8d0f4be..e9dc738f3 100644 --- a/src/entities/list/components/AccountCard.tsx +++ b/src/entities/list/components/AccountCard.tsx @@ -2,7 +2,6 @@ import { useEffect, useMemo, useState } from "react"; import { Trigger } from "@radix-ui/react-select"; import Link from "next/link"; -import { LazyLoadImage } from "react-lazy-load-image-component"; import { ListRegistration } from "@/common/api/indexer"; import { walletApi } from "@/common/blockchains/near-protocol/client"; @@ -12,6 +11,7 @@ import { Button, Dialog, DialogContent, + DialogDescription, DialogHeader, DialogTitle, DropdownMenu, @@ -23,8 +23,10 @@ import { SelectItem, Textarea, } from "@/common/ui/layout/components"; +import { LazyImage } from "@/common/ui/layout/components/LazyImage"; import DownArrow from "@/common/ui/layout/svg/DownArrow"; import { ListNoteIcon } from "@/common/ui/layout/svg/list-note"; +import SuccessRedIcon from "@/common/ui/layout/svg/success-red-icon"; import { cn } from "@/common/ui/layout/utils"; import { ACCOUNT_LIST_REGISTRATION_STATUS_OPTIONS, @@ -32,7 +34,7 @@ import { AccountProfileCover, AccountProfilePicture, } from "@/entities/_shared/account"; -import { dispatch } from "@/store"; +import { useDispatch } from "@/store/hooks"; import { listRegistrationStatuses } from "../constants"; import { ListFormModalType } from "../types"; @@ -49,12 +51,16 @@ export const ListAccountCard = ({ dataForList: ListRegistration; accountsWithAccess: string[]; }) => { + const dispatch = useDispatch(); + const [registrationStatus, setRegistrationStatus] = useState( RegistrationStatus.Pending, ); const [note, setNote] = useState(""); + const [isUpdateSuccessful, setIsUpdateSuccessful] = useState(false); + const status = listRegistrationStatuses[registrationStatus]; const [statusChange, setStatusChange] = useState({ @@ -77,12 +83,7 @@ export const ListAccountCard = ({ background: status.background, }} > - + {registrationStatus}
    @@ -98,7 +99,10 @@ export const ListAccountCard = ({ ...(note && { notes: note }), status: statusChange.status as RegistrationStatus, }) - .then((data) => setRegistrationStatus(data.status)) + .then((data) => { + setIsUpdateSuccessful(true); + setRegistrationStatus(data.status); + }) .catch((err) => console.error(err)); dispatch.listEditor.handleListToast({ @@ -172,7 +176,10 @@ export const ListAccountCard = ({
    {accountsWithAccess?.includes(walletApi?.accountId || "") ? (